Add HMAC function, and update the CMS and Mailer services
A new module has been added to create HMACs, primarily used in the billing service for data verification. Keystatic CMS usage has been conditioned to Node.js runtime only, and a fallback to the mock CMS client has been implemented for Edge Runtime. Mailer services now accommodate environment-specific providers.
This commit is contained in:
13
README.md
13
README.md
@@ -290,6 +290,7 @@ To deploy the application to Cloudflare, you need to do the following:
|
|||||||
1. Opt-in to the Edge runtime
|
1. Opt-in to the Edge runtime
|
||||||
2. Using the Cloudflare Mailer
|
2. Using the Cloudflare Mailer
|
||||||
3. Install the Cloudflare CLI
|
3. Install the Cloudflare CLI
|
||||||
|
4. Switching CMS
|
||||||
|
|
||||||
### 1. Opting in to the Edge runtime
|
### 1. Opting in to the Edge runtime
|
||||||
|
|
||||||
@@ -318,3 +319,15 @@ Please follow [the Vercel Email documentation](https://github.com/Sh4yy/vercel-e
|
|||||||
### 3. Installing the Cloudflare CLI
|
### 3. Installing the Cloudflare CLI
|
||||||
|
|
||||||
Please follow the instructions on the [Cloudflare documentation](https://github.com/cloudflare/next-on-pages/tree/main/packages/next-on-pages#3-deploy-your-application-to-cloudflare-pages) to install the Cloudflare CLI.
|
Please follow the instructions on the [Cloudflare documentation](https://github.com/cloudflare/next-on-pages/tree/main/packages/next-on-pages#3-deploy-your-application-to-cloudflare-pages) to install the Cloudflare CLI.
|
||||||
|
|
||||||
|
### 4. Switching CMS
|
||||||
|
|
||||||
|
By default, Makerkit uses Keystatic as a CMS. Keystatic's local mode (which relies on the file system) is not supported in the Edge runtime. Therefore, you will need to switch to another CMS.
|
||||||
|
|
||||||
|
At this time, the other CMS supported is WordPress. Set `CMS_CLIENT` to `wordpress` in the `apps/web/.env` file:
|
||||||
|
|
||||||
|
```
|
||||||
|
CMS_CLIENT=wordpress
|
||||||
|
```
|
||||||
|
|
||||||
|
More alternative CMS implementations will be added in the future.
|
||||||
@@ -44,3 +44,6 @@ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true
|
|||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true
|
||||||
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
|
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true
|
||||||
|
|
||||||
|
# MAIL
|
||||||
|
MAILER_PROVIDER=cloudflare
|
||||||
@@ -17,3 +17,5 @@ EMAIL_PASSWORD=password
|
|||||||
|
|
||||||
# STRIPE
|
# STRIPE
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51K9cWKI1i3VnbZTq2HGstY2S8wt3peF1MOqPXFO4LR8ln2QgS7GxL8XyKaKLvn7iFHeqAnvdDw0o48qN7rrwwcHU00jOtKhjsf
|
||||||
|
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
|
||||||
@@ -11,11 +11,13 @@ export async function POST(request: Request) {
|
|||||||
const provider = billingConfig.provider;
|
const provider = billingConfig.provider;
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
name: 'billing.webhook',
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
ctx,
|
||||||
name: 'billing.webhook',
|
|
||||||
provider,
|
|
||||||
},
|
|
||||||
`Received billing webhook. Processing...`,
|
`Received billing webhook. Processing...`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,9 +34,7 @@ export async function POST(request: Request) {
|
|||||||
await service.handleWebhookEvent(request);
|
await service.handleWebhookEvent(request);
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
ctx,
|
||||||
name: 'billing.webhook',
|
|
||||||
},
|
|
||||||
`Successfully processed billing webhook`,
|
`Successfully processed billing webhook`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
name: 'billing',
|
...ctx,
|
||||||
error: JSON.stringify(error),
|
error: JSON.stringify(error),
|
||||||
},
|
},
|
||||||
`Failed to process billing webhook`,
|
`Failed to process billing webhook`,
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js';
|
import { getOrder, getVariant } from '@lemonsqueezy/lemonsqueezy.js';
|
||||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
|
||||||
BillingConfig,
|
|
||||||
BillingWebhookHandlerService,
|
import { BillingConfig, BillingWebhookHandlerService, getLineItemTypeById } from '@kit/billing';
|
||||||
getLineItemTypeById,
|
|
||||||
} from '@kit/billing';
|
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
|
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
|
||||||
import { OrderWebhook } from '../types/order-webhook';
|
import { OrderWebhook } from '../types/order-webhook';
|
||||||
import SubscriptionWebhook from '../types/subscription-webhook';
|
import { SubscriptionWebhook } from '../types/subscription-webhook';
|
||||||
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||||
|
import { createHmac } from "./verify-hmac";
|
||||||
|
|
||||||
|
|
||||||
type UpsertSubscriptionParams =
|
type UpsertSubscriptionParams =
|
||||||
Database['public']['Functions']['upsert_subscription']['Args'];
|
Database['public']['Functions']['upsert_subscription']['Args'];
|
||||||
@@ -68,8 +69,10 @@ export class LemonSqueezyWebhookHandlerService
|
|||||||
throw new Error('Signature header not found');
|
throw new Error('Signature header not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValid = await isSigningSecretValid(rawBody, signature);
|
||||||
|
|
||||||
// if the signature is invalid, throw an error
|
// if the signature is invalid, throw an error
|
||||||
if (!isSigningSecretValid(Buffer.from(rawBody), signature)) {
|
if (!isValid) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{
|
{
|
||||||
eventName,
|
eventName,
|
||||||
@@ -411,12 +414,19 @@ function getISOString(date: number | null) {
|
|||||||
return date ? new Date(date).toISOString() : undefined;
|
return date ? new Date(date).toISOString() : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSigningSecretValid(rawBody: Buffer, signatureHeader: string) {
|
async function isSigningSecretValid(rawBody: string, signatureHeader: string) {
|
||||||
const { webhooksSecret } = getLemonSqueezyEnv();
|
const { webhooksSecret } = getLemonSqueezyEnv();
|
||||||
const hmac = createHmac('sha256', webhooksSecret);
|
|
||||||
|
|
||||||
const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8');
|
const { hex: digest } = await createHmac({
|
||||||
|
key: webhooksSecret,
|
||||||
|
data: rawBody
|
||||||
|
});
|
||||||
|
|
||||||
const signature = Buffer.from(signatureHeader, 'utf8');
|
const signature = Buffer.from(signatureHeader, 'utf8');
|
||||||
|
|
||||||
return timingSafeEqual(digest, signature);
|
return timingSafeEqual(digest, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function timingSafeEqual(digest: string, signature: Buffer) {
|
||||||
|
return digest.toString() === signature.toString();
|
||||||
|
}
|
||||||
|
|||||||
33
packages/billing/lemon-squeezy/src/services/verify-hmac.ts
Normal file
33
packages/billing/lemon-squeezy/src/services/verify-hmac.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
function bufferToHex(buffer: ArrayBuffer) {
|
||||||
|
return Array.from(new Uint8Array(buffer))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createHmac({ key, data }: { key: string; data: string }) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const crypto = new Crypto();
|
||||||
|
const encodedKey = encoder.encode(key);
|
||||||
|
const encodedData = encoder.encode(data);
|
||||||
|
|
||||||
|
const hmacKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encodedKey,
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
['sign', 'verify'],
|
||||||
|
);
|
||||||
|
|
||||||
|
const signature = await window.crypto.subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
hmacKey,
|
||||||
|
encodedData,
|
||||||
|
);
|
||||||
|
|
||||||
|
const hex = bufferToHex(signature);
|
||||||
|
|
||||||
|
return { hex };
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
interface SubscriptionWebhookResponse {
|
export interface SubscriptionWebhook {
|
||||||
meta: Meta;
|
meta: Meta;
|
||||||
data: Data;
|
data: Data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SubscriptionWebhookResponse;
|
|
||||||
|
|
||||||
interface Data {
|
interface Data {
|
||||||
type: string;
|
type: string;
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -34,7 +34,21 @@ async function getWordpressClient() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getKeystaticClient() {
|
async function getKeystaticClient() {
|
||||||
const { KeystaticClient } = await import('../../keystatic/src/client');
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||||
|
const { KeystaticClient } = await import('../../keystatic/src/client');
|
||||||
|
|
||||||
return new KeystaticClient();
|
return new KeystaticClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(`[CMS] Keystatic client is only available in Node.js runtime. Please choose a different CMS client. Returning a mock client instead of throwing an error.`);
|
||||||
|
|
||||||
|
return mockCMSClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockCMSClient() {
|
||||||
|
return {
|
||||||
|
async getContentItems() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,15 @@ const MAILER_PROVIDER = z
|
|||||||
* @description Get the mailer based on the environment variable.
|
* @description Get the mailer based on the environment variable.
|
||||||
*/
|
*/
|
||||||
export async function getMailer() {
|
export async function getMailer() {
|
||||||
switch (MAILER_PROVIDER) {
|
switch (process.env.MAILER_PROVIDER as typeof MAILER_PROVIDER) {
|
||||||
case 'nodemailer': {
|
case 'nodemailer': {
|
||||||
const { Nodemailer } = await import('./impl/nodemailer');
|
if (process.env.NEXT_RUNTIME !== 'edge') {
|
||||||
|
const { Nodemailer } = await import('./impl/nodemailer');
|
||||||
|
|
||||||
return new Nodemailer();
|
return new Nodemailer();
|
||||||
|
} else {
|
||||||
|
throw new Error('Nodemailer is not available on the edge runtime side');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'cloudflare': {
|
case 'cloudflare': {
|
||||||
|
|||||||
Reference in New Issue
Block a user