Closer/server/src/routes/webhooks.ts

55 lines
1.7 KiB
TypeScript
Raw Normal View History

import { Router, Request, Response } from 'express'
import { syncEntitlement } from '../services/entitlement'
import { RevenueCatEvent } from '../types'
import { getEnv } from '../config/env'
import * as crypto from 'crypto'
const router = Router()
/**
* Verifies RevenueCat webhook signature using constant-time comparison.
* Assumes validateEnv() was called at startup - throws if secret is unexpectedly missing.
*/
function verifyRevenueCatSecret(req: Request): boolean {
const secret = getEnv('REVENUECAT_WEBHOOK_SECRET')
const auth = req.headers['authorization'] ?? ''
// Constant-time comparison to prevent timing attacks
const authBuffer = Buffer.from(auth)
const secretBuffer = Buffer.from(secret)
// Use timingSafeEqual with fixed buffer length to prevent length leakage
const maxLength = Math.max(authBuffer.length, secretBuffer.length)
const paddedAuth = Buffer.alloc(maxLength)
const paddedSecret = Buffer.alloc(maxLength)
authBuffer.copy(paddedAuth)
secretBuffer.copy(paddedSecret)
return crypto.timingSafeEqual(paddedAuth, paddedSecret)
}
router.post('/revenuecat', async (req: Request, res: Response) => {
if (!verifyRevenueCatSecret(req)) {
res.status(401).json({ error: 'unauthorized' })
return
}
const body = req.body as RevenueCatEvent
if (!body?.event?.type || !body?.event?.app_user_id) {
res.status(400).json({ error: 'malformed payload' })
return
}
// Acknowledge immediately — RC retries if we don't respond within 10s
res.status(200).json({ received: true })
try {
await syncEntitlement(body)
} catch (err) {
console.error('[webhook] entitlement sync failed:', err)
}
})
export default router