2026-06-17 01:25:51 -05:00
|
|
|
"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 });
|
2026-07-09 03:43:59 -05:00
|
|
|
exports.AuthError = exports.ConfigError = exports.revenueCatWebhook = void 0;
|
|
|
|
|
exports.verifyWebhookSignature = verifyWebhookSignature;
|
2026-06-17 19:08:53 -05:00
|
|
|
const crypto = __importStar(require("crypto"));
|
2026-07-08 00:00:29 -05:00
|
|
|
const https_1 = require("firebase-functions/v2/https");
|
|
|
|
|
const params_1 = require("firebase-functions/params");
|
2026-06-17 01:25:51 -05:00
|
|
|
const entitlementLogic_1 = require("./entitlementLogic");
|
2026-07-08 00:00:29 -05:00
|
|
|
const log_1 = require("../log");
|
2026-06-17 01:25:51 -05:00
|
|
|
/**
|
|
|
|
|
* RevenueCat webhook handler.
|
|
|
|
|
*
|
|
|
|
|
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
|
|
|
|
|
* Triggered by RevenueCat server-to-server events.
|
|
|
|
|
*
|
2026-07-09 03:43:59 -05:00
|
|
|
* Authentication — HMAC-SHA256 (RevenueCat's webhook "signature verification"):
|
|
|
|
|
* - RevenueCat sends `X-RevenueCat-Webhook-Signature: t=<unix_ts>,v1=<hmac_sha256_hex>`.
|
|
|
|
|
* - v1 = HMAC-SHA256(secret, "<t>.<raw_body>"), 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).
|
2026-06-17 01:25:51 -05:00
|
|
|
*
|
|
|
|
|
* Processing:
|
|
|
|
|
* - Parses the RevenueCat event payload.
|
|
|
|
|
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
|
|
|
|
|
*/
|
2026-07-09 03:43:59 -05:00
|
|
|
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) => {
|
2026-06-17 01:25:51 -05:00
|
|
|
var _a;
|
|
|
|
|
if (req.method !== 'POST') {
|
|
|
|
|
res.status(405).json({ error: 'method_not_allowed' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-17 19:08:53 -05:00
|
|
|
try {
|
2026-07-09 03:43:59 -05:00
|
|
|
verifyRequest(req, Date.now());
|
2026-06-17 19:08:53 -05:00
|
|
|
}
|
|
|
|
|
catch (err) {
|
|
|
|
|
if (err instanceof ConfigError) {
|
2026-07-08 00:00:29 -05:00
|
|
|
log_1.logger.error('[revenueCatWebhook] configuration error', { error: err.message });
|
2026-06-17 19:08:53 -05:00
|
|
|
res.status(500).json({ error: 'internal_error' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-17 01:25:51 -05:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-24 16:15:30 -05:00
|
|
|
// 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.
|
2026-06-17 01:25:51 -05:00
|
|
|
try {
|
|
|
|
|
await (0, entitlementLogic_1.applyEntitlementEvent)(event);
|
|
|
|
|
}
|
|
|
|
|
catch (err) {
|
2026-07-08 00:00:29 -05:00
|
|
|
log_1.logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) });
|
2026-06-24 16:15:30 -05:00
|
|
|
res.status(500).json({ error: 'processing_failed' });
|
|
|
|
|
return;
|
2026-06-17 01:25:51 -05:00
|
|
|
}
|
2026-06-24 16:15:30 -05:00
|
|
|
res.status(200).json({ received: true });
|
2026-06-17 01:25:51 -05:00
|
|
|
});
|
2026-06-17 19:08:53 -05:00
|
|
|
class ConfigError extends Error {
|
|
|
|
|
}
|
2026-07-09 03:43:59 -05:00
|
|
|
exports.ConfigError = ConfigError;
|
2026-06-17 19:08:53 -05:00
|
|
|
class AuthError extends Error {
|
|
|
|
|
}
|
2026-07-09 03:43:59 -05:00
|
|
|
exports.AuthError = AuthError;
|
2026-06-17 01:25:51 -05:00
|
|
|
/**
|
2026-07-09 03:43:59 -05:00
|
|
|
* 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).
|
2026-06-17 01:25:51 -05:00
|
|
|
*/
|
2026-07-09 03:43:59 -05:00
|
|
|
function verifyRequest(req, nowMs) {
|
|
|
|
|
const secret = process.env.REVENUECAT_WEBHOOK_SECRET;
|
|
|
|
|
if (!secret) {
|
|
|
|
|
throw new ConfigError('REVENUECAT_WEBHOOK_SECRET environment variable is not set');
|
2026-06-17 19:08:53 -05:00
|
|
|
}
|
|
|
|
|
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
|
|
|
|
|
const rawBody = req.rawBody;
|
2026-07-09 03:43:59 -05:00
|
|
|
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=<unix_ts>,v1=<hex>` from the header, recomputes HMAC-SHA256 over "<t>.<rawBody>"
|
|
|
|
|
* 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;
|
2026-06-17 19:08:53 -05:00
|
|
|
if (!rawBody || rawBody.length === 0) {
|
|
|
|
|
throw new AuthError('request body missing');
|
|
|
|
|
}
|
2026-07-09 03:43:59 -05:00
|
|
|
if (!header) {
|
|
|
|
|
throw new AuthError('signature header missing');
|
|
|
|
|
}
|
|
|
|
|
// Parse "t=<ts>,v1=<hex>" — 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 "<t>." — 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)) {
|
2026-06-17 19:08:53 -05:00
|
|
|
throw new AuthError('signature mismatch');
|
|
|
|
|
}
|
2026-06-17 01:25:51 -05:00
|
|
|
}
|
|
|
|
|
//# sourceMappingURL=revenueCatWebhook.js.map
|