import * as crypto from 'crypto' import * as functions from 'firebase-functions' import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic' /** * RevenueCat webhook handler. * * Path: POST /revenueCatWebhook (registered as an HTTPS function) * Triggered by RevenueCat server-to-server events. * * Authentication: * - Verifies the Ed25519 signature in the X-Signature request header. * - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 * public key from your RevenueCat project dashboard. * - Missing key → 500 (our config error). Invalid/absent signature → 401. * * Processing: * - Parses the RevenueCat event payload. * - Writes entitlement state to Firestore at users/{userId}/entitlements. */ export const revenueCatWebhook = functions.https.onRequest(async (req, res) => { if (req.method !== 'POST') { res.status(405).json({ error: 'method_not_allowed' }) return } try { verifySignature(req) } catch (err) { if (err instanceof ConfigError) { console.error('[revenueCatWebhook] configuration error:', err.message) res.status(500).json({ error: 'internal_error' }) return } res.status(401).json({ error: 'unauthorized' }) return } const event = req.body?.event as EntitlementEvent | undefined if (!event || !event.type || !event.app_user_id) { res.status(400).json({ error: 'malformed_payload' }) return } // Acknowledge immediately to avoid RevenueCat retries. res.status(200).json({ received: true }) try { await applyEntitlementEvent(event) } catch (err) { console.error('[revenueCatWebhook] entitlement sync failed:', err) } }) class ConfigError extends Error {} class AuthError extends Error {} /** * Verifies the Ed25519 signature RevenueCat sends in the X-Signature header. * * Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed — * these are deployment problems, not auth failures. * Throws AuthError for any missing or invalid signature — these are 401s. */ function verifySignature(req: functions.https.Request): void { const signingKey = process.env.REVENUECAT_SIGNING_KEY if (!signingKey) { throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set') } // Firebase Functions expose the raw request body as req.rawBody (Buffer). const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody if (!rawBody || rawBody.length === 0) { throw new AuthError('request body missing') } const sigHeader = req.headers['x-signature'] const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader if (!signature) { throw new AuthError('x-signature header missing') } let publicKey: crypto.KeyObject try { publicKey = crypto.createPublicKey({ key: Buffer.from(signingKey, 'base64'), format: 'der', type: 'spki', }) } catch { throw new ConfigError( 'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key' ) } let sigBuffer: Buffer try { sigBuffer = Buffer.from(signature, 'base64') } catch { throw new AuthError('x-signature is not valid base64') } let valid: boolean try { valid = crypto.verify(null, rawBody, publicKey, sigBuffer) } catch { throw new AuthError('signature verification failed') } if (!valid) { throw new AuthError('signature mismatch') } }