diff --git a/apps/api/v1/index.ts b/apps/api/v1/index.ts index 30bc8cc4..aefc5c8e 100644 --- a/apps/api/v1/index.ts +++ b/apps/api/v1/index.ts @@ -3,11 +3,11 @@ import { authMiddleware } from './middlewares/auth' import authApp from './routes/auth' import budgetsApp from './routes/budgets' import categoriesApp from './routes/categories' -import clerkWebhooksApp from './routes/clerk-webhooks' import exchangeRatesApp from './routes/exchange-rates' import transactionsApp from './routes/transactions' import usersApp from './routes/users' import walletsApp from './routes/wallets' +import clerkWebhooksApp from './routes/webhooks/clerk' export const hono = new Hono() .get('/health', (c) => c.text('ok')) diff --git a/apps/api/v1/middlewares/webhook-auth.ts b/apps/api/v1/middlewares/webhook-auth.ts index 7cc68375..f74fd216 100644 --- a/apps/api/v1/middlewares/webhook-auth.ts +++ b/apps/api/v1/middlewares/webhook-auth.ts @@ -3,27 +3,52 @@ import { Webhook } from 'svix' import { getLogger } from '../../lib/log' export const webhookAuthMiddleware = createMiddleware(async (c, next) => { - const { CLERK_WEBHOOK_SECRET_KEY } = process.env + const { path } = c.req - if (!CLERK_WEBHOOK_SECRET_KEY) { - return c.json({ message: 'CLERK_WEBHOOK_SECRET_KEY is not set' }, 500) + if (path.includes('clerk')) { + const { CLERK_WEBHOOK_SECRET_KEY } = process.env + + if (!CLERK_WEBHOOK_SECRET_KEY) { + return c.json({ message: 'CLERK_WEBHOOK_SECRET_KEY is not set' }, 500) + } + + const bodyText = await c.req.text() + const svix = new Webhook(CLERK_WEBHOOK_SECRET_KEY) + + try { + svix.verify(bodyText, { + 'svix-id': c.req.header('svix-id')!, + 'svix-timestamp': c.req.header('svix-timestamp')!, + 'svix-signature': c.req.header('svix-signature')!, + }) + } catch (error) { + const logger = getLogger('webhookAuthMiddleware') + logger.error(error) + + return c.json({ success: false, message: `svix validation failed` }, 400) + } + + return await next() } - const bodyText = await c.req.text() - const svix = new Webhook(CLERK_WEBHOOK_SECRET_KEY) + if (path.includes('revenuecat')) { + const { REVENUECAT_WEBHOOK_SECRET } = process.env + + if (!REVENUECAT_WEBHOOK_SECRET) { + return c.json({ message: 'REVENUECAT_WEBHOOK_SECRET is not set' }, 500) + } + + const authorization = c.req.header('Authorization') - try { - svix.verify(bodyText, { - 'svix-id': c.req.header('svix-id')!, - 'svix-timestamp': c.req.header('svix-timestamp')!, - 'svix-signature': c.req.header('svix-signature')!, - }) - } catch (error) { - const logger = getLogger('webhookAuthMiddleware') - logger.error(error) + if ( + !authorization || + authorization !== `Bearer ${REVENUECAT_WEBHOOK_SECRET}` + ) { + return c.json({ success: false, message: 'unauthorized' }, 401) + } - return c.json({ message: `svix validation failed` }, 400) + return await next() } - await next() + return c.json({ success: false, message: 'Not found' }, 404) }) diff --git a/apps/api/v1/routes/clerk-webhooks.ts b/apps/api/v1/routes/webhooks/clerk.ts similarity index 66% rename from apps/api/v1/routes/clerk-webhooks.ts rename to apps/api/v1/routes/webhooks/clerk.ts index 413517b4..86db0555 100644 --- a/apps/api/v1/routes/clerk-webhooks.ts +++ b/apps/api/v1/routes/webhooks/clerk.ts @@ -1,8 +1,8 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { z } from 'zod' -import { webhookAuthMiddleware } from '../middlewares/webhook-auth' -import { deleteUser } from '../services/user.service' +import { webhookAuthMiddleware } from '../../middlewares/webhook-auth' +import { deleteUser } from '../../services/user.service' const zClerkUserData = z.object({ id: z.string() }) // Define more fields if needed @@ -21,10 +21,13 @@ const router = new Hono() switch (payload.type) { case 'user.deleted': await deleteUser(payload.data.id) - return c.json({ message: 'user deleted' }) + return c.json({ success: true, message: 'user deleted' }) default: - return c.json({ message: `${payload.type} is not supported` }, 400) + return c.json( + { success: false, message: `${payload.type} is not supported` }, + 400, + ) } }) diff --git a/apps/api/v1/routes/webhooks/revenuecat.ts b/apps/api/v1/routes/webhooks/revenuecat.ts new file mode 100644 index 00000000..586381dc --- /dev/null +++ b/apps/api/v1/routes/webhooks/revenuecat.ts @@ -0,0 +1,46 @@ +/* + This webhook route is used to sync user subscription data from RevenueCat. + The webhook only uses the user's ID to find the user and sync their subscription data. +*/ + +import { zValidator } from '@hono/zod-validator' +import { Hono } from 'hono' +import { z } from 'zod' +import { getLogger } from '../../../lib/log' +import { webhookAuthMiddleware } from '../../middlewares/webhook-auth' +import { findUserById, syncUserSubscription } from '../../services/user.service' + +const zPayload = z.object({ + api_version: z.literal('1.0'), + event: z.object({ + app_user_id: z.string(), + environment: z.enum(['SANDBOX', 'PRODUCTION']), + original_app_user_id: z.string(), + }), +}) + +const router = new Hono() + .use(webhookAuthMiddleware) + .post('/', zValidator('json', zPayload), async (c) => { + const logger = getLogger('webhooks:revenuecat') + const payload = c.req.valid('json') + const { event } = payload + + logger.debug('Received payload %o', payload) + + const userId = event.original_app_user_id || event.app_user_id + const user = userId ? await findUserById(userId) : null + + if (!user) { + logger.warn('User not found for id %s', userId) + return c.json({ success: false, message: 'user not found' }, 404) + } + + const syncedUser = await syncUserSubscription(user) + + logger.debug('Synced user %o', syncedUser) + + return c.json({ success: true }) + }) + +export default router