"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0; exports.verifyWebhookSignature = verifyWebhookSignature; const crypto = __importStar(require("crypto")); const https_1 = require("firebase-functions/v2/https"); const params_1 = require("firebase-functions/params"); const entitlementLogic_1 = require("./entitlementLogic"); const log_1 = require("../log"); /** * RevenueCat webhook handler. * * Path: POST /revenueCatWebhook (registered as an HTTPS function) * Triggered by RevenueCat server-to-server events. * * Authentication — HMAC-SHA256 (RevenueCat's webhook "signature verification"): * - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=,v1=`. * - v1 = HMAC-SHA256(secret, "."), hex-encoded, computed over the RAW request * body bytes exactly as received (before any JSON parsing / re-serialization). * - REVENUECAT_WEBHOOK_SECRET is the integration's signing secret from the RevenueCat * dashboard (Integrations → Webhooks → enable signature verification). It is bound as a * Secret Manager secret (below) and injected into process.env at runtime. * - Missing secret → 500 (our config error). Missing/invalid signature, or a timestamp * outside the tolerance window (replay guard) → 401. * * NOTE: RevenueCat does NOT offer an Ed25519 / public-key signing scheme — HMAC-SHA256 with a * shared secret is the only cryptographic option (plus the optional static Authorization header). * * Processing: * - Parses the RevenueCat event payload. * - Writes entitlement state to Firestore at users/{userId}/entitlements. */ const revenueCatWebhookSecret = (0, params_1.defineSecret)('REVENUECAT_WEBHOOK_SECRET'); /** Reject signatures whose timestamp is further than this from now (replay protection). */ const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000; exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatWebhookSecret] }, async (req, res) => { var _a; if (req.method !== 'POST') { res.status(405).json({ error: 'method_not_allowed' }); return; } try { verifyRequest(req, Date.now()); } catch (err) { if (err instanceof ConfigError) { log_1.logger.error('[revenueCatWebhook] configuration error', { error: err.message }); res.status(500).json({ error: 'internal_error' }); return; } res.status(401).json({ error: 'unauthorized' }); return; } const event = (_a = req.body) === null || _a === void 0 ? void 0 : _a.event; if (!event || !event.type || !event.app_user_id) { res.status(400).json({ error: 'malformed_payload' }); return; } // Security review Batch 2: process BEFORE acking. Previously we returned 200 up front // and only logged failures, so a failed entitlement sync was silently dropped (no retry). // RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state), // so returning 500 on failure recovers the event safely. try { await (0, entitlementLogic_1.applyEntitlementEvent)(event); } catch (err) { log_1.logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) }); res.status(500).json({ error: 'processing_failed' }); return; } res.status(200).json({ received: true }); }); class ConfigError extends Error { } exports.ConfigError = ConfigError; class AuthError extends Error { } exports.AuthError = AuthError; /** * Reads the secret + signature header off the request and delegates to the pure verifier. * Throws ConfigError when our secret is unset (→500); AuthError for any auth failure (→401). */ function verifyRequest(req, nowMs) { const secret = process.env.REVENUECAT_WEBHOOK_SECRET; if (!secret) { throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set'); } // Firebase Functions expose the raw request body as req.rawBody (Buffer). const rawBody = req.rawBody; const sigHeader = req.headers['x-revenuecat-webhook-signature']; const header = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader; verifyWebhookSignature({ rawBody, header, secret, nowMs }); } /** * Pure, testable HMAC-SHA256 verification of a RevenueCat webhook signature. * * Parses `t=,v1=` from the header, recomputes HMAC-SHA256 over "." * with the signing secret, constant-time-compares against v1, and rejects timestamps outside * the tolerance window. Throws AuthError on any failure (caller maps to 401). */ function verifyWebhookSignature(opts) { var _a; const { rawBody, header, secret, nowMs } = opts; const toleranceMs = (_a = opts.toleranceMs) !== null && _a !== void 0 ? _a : SIGNATURE_TOLERANCE_MS; if (!rawBody || rawBody.length === 0) { throw new AuthError('request body missing'); } if (!header) { throw new AuthError('signature header missing'); } // Parse "t=,v1=" — order-independent, tolerant of extra fields. let t; let v1; for (const part of header.split(',')) { const seg = part.trim(); const eq = seg.indexOf('='); if (eq < 0) continue; const key = seg.slice(0, eq); const val = seg.slice(eq + 1); if (key === 't') t = val; else if (key === 'v1') v1 = val; } if (!t || !v1) { throw new AuthError('signature header malformed'); } const tsMs = Number(t) * 1000; if (!Number.isFinite(tsMs)) { throw new AuthError('signature timestamp invalid'); } if (Math.abs(nowMs - tsMs) > toleranceMs) { throw new AuthError('signature timestamp outside tolerance'); } // HMAC over the RAW body bytes, prefixed with "." — never re-serialize the JSON. const signedPayload = Buffer.concat([Buffer.from(`${t}.`, 'utf8'), rawBody]); const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex'); // Constant-time compare. timingSafeEqual throws on length mismatch, so guard the length first. const expectedBuf = Buffer.from(expected, 'utf8'); const providedBuf = Buffer.from(v1, 'utf8'); if (expectedBuf.length !== providedBuf.length || !crypto.timingSafeEqual(expectedBuf, providedBuf)) { throw new AuthError('signature mismatch'); } } //# sourceMappingURL=revenueCatWebhook.js.map