refactor(functions): B4+B5 migrate webhook to v2 + pin onUserDelete on v1

B4 — revenueCatWebhook: functions.https.onRequest → firebase-functions/v2/https onRequest,
with REVENUECAT_SIGNING_KEY bound as a Secret Manager secret via defineSecret (injected into
process.env, so the Ed25519 verify + process-before-ack/500-retry logic is unchanged). Request
type retyped to the v2 Request; console → logger. The key must be seeded in Secret Manager at
deploy (runbook) — it isn't in the repo.

B5 — onUserDelete: kept on the v1 API (2nd gen has no auth.user().onDelete), imported explicitly
from firebase-functions/v1 and wrapped in runWith({ timeoutSeconds: 300, memory: '512MB' }) for
its dual recursiveDelete + Storage sweep. Adopts shared getUserTokens/sendPushToUser + logger;
preserves the original "only notify if the partner has a live token" behavior.
(wrapReleaseKey's HttpsError→v2 swap already landed in B3.)

Build clean; 70 tests green. dist rebuilt. Still on firebase-functions v5.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-08 00:00:29 -05:00
parent e0c2d67373
commit a5af32d3d2
6 changed files with 144 additions and 151 deletions

View File

@ -35,8 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.revenueCatWebhook = void 0; exports.revenueCatWebhook = void 0;
const crypto = __importStar(require("crypto")); const crypto = __importStar(require("crypto"));
const functions = __importStar(require("firebase-functions")); const https_1 = require("firebase-functions/v2/https");
const params_1 = require("firebase-functions/params");
const entitlementLogic_1 = require("./entitlementLogic"); const entitlementLogic_1 = require("./entitlementLogic");
const log_1 = require("../log");
/** /**
* RevenueCat webhook handler. * RevenueCat webhook handler.
* *
@ -46,14 +48,16 @@ const entitlementLogic_1 = require("./entitlementLogic");
* Authentication: * Authentication:
* - Verifies the Ed25519 signature in the X-Signature request header. * - Verifies the Ed25519 signature in the X-Signature request header.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 * - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
* public key from your RevenueCat project dashboard. * public key from your RevenueCat project dashboard. It is bound as a Secret
* Manager secret (below) and injected into process.env at runtime.
* - Missing key 500 (our config error). Invalid/absent signature 401. * - Missing key 500 (our config error). Invalid/absent signature 401.
* *
* Processing: * Processing:
* - Parses the RevenueCat event payload. * - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements. * - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/ */
exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => { const revenueCatSigningKey = (0, params_1.defineSecret)('REVENUECAT_SIGNING_KEY');
exports.revenueCatWebhook = (0, https_1.onRequest)({ secrets: [revenueCatSigningKey] }, async (req, res) => {
var _a; var _a;
if (req.method !== 'POST') { if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' }); res.status(405).json({ error: 'method_not_allowed' });
@ -64,7 +68,7 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
} }
catch (err) { catch (err) {
if (err instanceof ConfigError) { if (err instanceof ConfigError) {
console.error('[revenueCatWebhook] configuration error:', err.message); log_1.logger.error('[revenueCatWebhook] configuration error', { error: err.message });
res.status(500).json({ error: 'internal_error' }); res.status(500).json({ error: 'internal_error' });
return; return;
} }
@ -84,7 +88,7 @@ exports.revenueCatWebhook = functions.https.onRequest(async (req, res) => {
await (0, entitlementLogic_1.applyEntitlementEvent)(event); await (0, entitlementLogic_1.applyEntitlementEvent)(event);
} }
catch (err) { catch (err) {
console.error('[revenueCatWebhook] entitlement sync failed:', err); log_1.logger.error('[revenueCatWebhook] entitlement sync failed', { error: String(err) });
res.status(500).json({ error: 'processing_failed' }); res.status(500).json({ error: 'processing_failed' });
return; return;
} }

View File

@ -1 +1 @@
{"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"} {"version":3,"file":"revenueCatWebhook.js","sourceRoot":"","sources":["../../src/billing/revenueCatWebhook.ts"],"names":[],"mappings":"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"}

View File

@ -34,12 +34,19 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.onUserDelete = void 0; exports.onUserDelete = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const pruneTokens_1 = require("../notifications/pruneTokens"); const v1_1 = require("firebase-functions/v1");
const push_1 = require("../notifications/push");
const log_1 = require("../log");
/** /**
* Auth deletion trigger cascades account deletion server-side. * Auth deletion trigger cascades account deletion server-side.
* *
* This is the ONE function kept on the v1 (1st-gen) API: 2nd gen has no
* `auth.user().onDelete` equivalent, so it is imported explicitly from
* firebase-functions/v1 and coexists with the v2 functions in this codebase.
* Global v2 options (setGlobalOptions) do not apply to it, so the timeout/memory
* for its dual recursiveDelete + Storage sweep are set here via runWith().
*
* Fires when a Firebase Auth user is deleted (via client deleteAccount() or * Fires when a Firebase Auth user is deleted (via client deleteAccount() or
* Admin SDK). The Admin SDK bypasses Firestore rules, so this function can * Admin SDK). The Admin SDK bypasses Firestore rules, so this function can
* reach everything the client cannot. * reach everything the client cannot.
@ -53,7 +60,9 @@ const pruneTokens_1 = require("../notifications/pruneTokens");
* 3. Recursively delete the user doc + all subcollections * 3. Recursively delete the user doc + all subcollections
* (entitlements, fcmTokens, notification_queue). * (entitlements, fcmTokens, notification_queue).
*/ */
exports.onUserDelete = functions.auth.user().onDelete(async (user) => { exports.onUserDelete = (0, v1_1.runWith)({ timeoutSeconds: 300, memory: '512MB' })
.auth.user()
.onDelete(async (user) => {
var _a, _b, _c, _d; var _a, _b, _c, _d;
const uid = user.uid; const uid = user.uid;
const db = admin.firestore(); const db = admin.firestore();
@ -79,7 +88,7 @@ exports.onUserDelete = functions.auth.user().onDelete(async (user) => {
await partnerRef.update({ coupleId: null }); await partnerRef.update({ coupleId: null });
// Send push notification to the partner. // Send push notification to the partner.
await notifyPartner(partnerId, partnerDoc, db).catch((err) => { await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
console.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed:`, err); log_1.logger.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed`, { error: String(err) });
}); });
} }
} }
@ -92,29 +101,17 @@ exports.onUserDelete = functions.auth.user().onDelete(async (user) => {
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` }); await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` });
} }
catch (err) { catch (err) {
console.warn(`[onUserDelete] Storage cleanup failed for ${uid}:`, err); log_1.logger.warn(`[onUserDelete] Storage cleanup failed for ${uid}`, { error: String(err) });
} }
// ── 3. User doc + subcollections ────────────────────────────────────────── // ── 3. User doc + subcollections ──────────────────────────────────────────
await db.recursiveDelete(userDocRef); await db.recursiveDelete(userDocRef);
console.log(`[onUserDelete] completed cleanup for user ${uid}`); log_1.logger.log(`[onUserDelete] completed cleanup for user ${uid}`);
}); });
async function notifyPartner(partnerId, partnerDoc, db) { async function notifyPartner(partnerId, partnerDoc, db) {
var _a; const partnerData = partnerDoc.data();
const messaging = admin.messaging(); // Preserve the original behavior: only write the in-app record + push if the partner has a
// Collect FCM tokens (legacy field + fcmTokens subcollection). // live device token (nothing to deliver otherwise).
const tokens = []; const tokens = await (0, push_1.getUserTokens)(db, partnerId, partnerData);
const legacyToken = (_a = partnerDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken);
}
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get();
tokenSnap.docs.forEach((doc) => {
var _a;
const t = (_a = doc.data()) === null || _a === void 0 ? void 0 : _a.token;
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t);
}
});
if (tokens.length === 0) if (tokens.length === 0)
return; return;
// Write an in-app notification record. // Write an in-app notification record.
@ -129,20 +126,13 @@ async function notifyPartner(partnerId, partnerDoc, db) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const sendResults = await Promise.allSettled(tokens.map((token) => messaging.send({ await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
token,
notification: { notification: {
title: 'Your partner deleted their account', title: 'Your partner deleted their account',
body: 'You are no longer paired. Tap to create a new invite.', body: 'You are no longer paired. Tap to create a new invite.',
}, },
android: { notification: { channelId: 'partner_activity' } }, // E-OBS android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: { type: 'partner_deleted_account' }, data: { type: 'partner_deleted_account' },
}))); }, partnerData);
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[onUserDelete] FCM send to token ${tokens[i]} failed:`, result.reason);
}
});
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults);
} }
//# sourceMappingURL=onUserDelete.js.map //# sourceMappingURL=onUserDelete.js.map

View File

@ -1 +1 @@
{"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":"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"} {"version":3,"file":"onUserDelete.js","sourceRoot":"","sources":["../../src/users/onUserDelete.ts"],"names":[],"mappings":"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"}

View File

@ -1,6 +1,8 @@
import * as crypto from 'crypto' import * as crypto from 'crypto'
import * as functions from 'firebase-functions' import { onRequest, Request } from 'firebase-functions/v2/https'
import { defineSecret } from 'firebase-functions/params'
import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic' import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
import { logger } from '../log'
/** /**
* RevenueCat webhook handler. * RevenueCat webhook handler.
@ -11,51 +13,57 @@ import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
* Authentication: * Authentication:
* - Verifies the Ed25519 signature in the X-Signature request header. * - Verifies the Ed25519 signature in the X-Signature request header.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519 * - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
* public key from your RevenueCat project dashboard. * public key from your RevenueCat project dashboard. It is bound as a Secret
* Manager secret (below) and injected into process.env at runtime.
* - Missing key 500 (our config error). Invalid/absent signature 401. * - Missing key 500 (our config error). Invalid/absent signature 401.
* *
* Processing: * Processing:
* - Parses the RevenueCat event payload. * - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements. * - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/ */
export const revenueCatWebhook = functions.https.onRequest(async (req, res) => { const revenueCatSigningKey = defineSecret('REVENUECAT_SIGNING_KEY')
if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' })
return
}
try { export const revenueCatWebhook = onRequest(
verifySignature(req) { secrets: [revenueCatSigningKey] },
} catch (err) { async (req, res) => {
if (err instanceof ConfigError) { if (req.method !== 'POST') {
console.error('[revenueCatWebhook] configuration error:', err.message) res.status(405).json({ error: 'method_not_allowed' })
res.status(500).json({ error: 'internal_error' })
return return
} }
res.status(401).json({ error: 'unauthorized' })
return
}
const event = req.body?.event as EntitlementEvent | undefined try {
if (!event || !event.type || !event.app_user_id) { verifySignature(req)
res.status(400).json({ error: 'malformed_payload' }) } catch (err) {
return if (err instanceof ConfigError) {
} logger.error('[revenueCatWebhook] configuration error', { error: err.message })
res.status(500).json({ error: 'internal_error' })
return
}
res.status(401).json({ error: 'unauthorized' })
return
}
// Security review Batch 2: process BEFORE acking. Previously we returned 200 up front const event = req.body?.event as EntitlementEvent | undefined
// and only logged failures, so a failed entitlement sync was silently dropped (no retry). if (!event || !event.type || !event.app_user_id) {
// RevenueCat retries on non-2xx, and applyEntitlementEvent is idempotent (it sets state), res.status(400).json({ error: 'malformed_payload' })
// so returning 500 on failure recovers the event safely. return
try { }
await applyEntitlementEvent(event)
} catch (err) {
console.error('[revenueCatWebhook] entitlement sync failed:', err)
res.status(500).json({ error: 'processing_failed' })
return
}
res.status(200).json({ received: true }) // 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 applyEntitlementEvent(event)
} catch (err) {
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 {} class ConfigError extends Error {}
class AuthError extends Error {} class AuthError extends Error {}
@ -67,7 +75,7 @@ class AuthError extends Error {}
* these are deployment problems, not auth failures. * these are deployment problems, not auth failures.
* Throws AuthError for any missing or invalid signature these are 401s. * Throws AuthError for any missing or invalid signature these are 401s.
*/ */
function verifySignature(req: functions.https.Request): void { function verifySignature(req: Request): void {
const signingKey = process.env.REVENUECAT_SIGNING_KEY const signingKey = process.env.REVENUECAT_SIGNING_KEY
if (!signingKey) { if (!signingKey) {
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set') throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set')

View File

@ -1,10 +1,17 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { pruneDeadTokens } from '../notifications/pruneTokens' import { auth, runWith } from 'firebase-functions/v1'
import { getUserTokens, sendPushToUser } from '../notifications/push'
import { logger } from '../log'
/** /**
* Auth deletion trigger cascades account deletion server-side. * Auth deletion trigger cascades account deletion server-side.
* *
* This is the ONE function kept on the v1 (1st-gen) API: 2nd gen has no
* `auth.user().onDelete` equivalent, so it is imported explicitly from
* firebase-functions/v1 and coexists with the v2 functions in this codebase.
* Global v2 options (setGlobalOptions) do not apply to it, so the timeout/memory
* for its dual recursiveDelete + Storage sweep are set here via runWith().
*
* Fires when a Firebase Auth user is deleted (via client deleteAccount() or * Fires when a Firebase Auth user is deleted (via client deleteAccount() or
* Admin SDK). The Admin SDK bypasses Firestore rules, so this function can * Admin SDK). The Admin SDK bypasses Firestore rules, so this function can
* reach everything the client cannot. * reach everything the client cannot.
@ -18,84 +25,75 @@ import { pruneDeadTokens } from '../notifications/pruneTokens'
* 3. Recursively delete the user doc + all subcollections * 3. Recursively delete the user doc + all subcollections
* (entitlements, fcmTokens, notification_queue). * (entitlements, fcmTokens, notification_queue).
*/ */
export const onUserDelete = functions.auth.user().onDelete(async (user) => { export const onUserDelete = runWith({ timeoutSeconds: 300, memory: '512MB' })
const uid = user.uid .auth.user()
const db = admin.firestore() .onDelete(async (user: auth.UserRecord) => {
const uid = user.uid
const db = admin.firestore()
// ── 1. Couple unpair ────────────────────────────────────────────────────── // ── 1. Couple unpair ──────────────────────────────────────────────────────
const userDocRef = db.collection('users').doc(uid) const userDocRef = db.collection('users').doc(uid)
const userDoc = await userDocRef.get() const userDoc = await userDocRef.get()
const coupleId = userDoc.exists const coupleId = userDoc.exists
? (userDoc.data()?.coupleId as string | undefined) ? (userDoc.data()?.coupleId as string | undefined)
: undefined : undefined
if (coupleId) { if (coupleId) {
const coupleRef = db.collection('couples').doc(coupleId) const coupleRef = db.collection('couples').doc(coupleId)
const coupleDoc = await coupleRef.get() const coupleDoc = await coupleRef.get()
if (coupleDoc.exists) { if (coupleDoc.exists) {
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== uid) const partnerId = userIds.find((id) => id !== uid)
if (partnerId) { if (partnerId) {
const partnerRef = db.collection('users').doc(partnerId) const partnerRef = db.collection('users').doc(partnerId)
const partnerDoc = await partnerRef.get() const partnerDoc = await partnerRef.get()
// Only act if the partner is still recorded in this couple. // Only act if the partner is still recorded in this couple.
if (partnerDoc.exists && partnerDoc.data()?.coupleId === coupleId) { if (partnerDoc.exists && partnerDoc.data()?.coupleId === coupleId) {
// Clear partner's coupleId first so the onCoupleLeave trigger does // Clear partner's coupleId first so the onCoupleLeave trigger does
// not fire a second time (it would find no partner left to notify). // not fire a second time (it would find no partner left to notify).
await partnerRef.update({ coupleId: null }) await partnerRef.update({ coupleId: null })
// Send push notification to the partner. // Send push notification to the partner.
await notifyPartner(partnerId, partnerDoc, db).catch((err) => { await notifyPartner(partnerId, partnerDoc, db).catch((err) => {
console.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed:`, err) logger.warn(`[onUserDelete] FCM notification to partner ${partnerId} failed`, { error: String(err) })
}) })
}
} }
// Recursively delete the couple doc + all subcollections.
await db.recursiveDelete(coupleRef)
} }
// Recursively delete the couple doc + all subcollections.
await db.recursiveDelete(coupleRef)
} }
}
// ── 2. Storage cleanup ──────────────────────────────────────────────────── // ── 2. Storage cleanup ────────────────────────────────────────────────────
try { try {
await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` }) await admin.storage().bucket().deleteFiles({ prefix: `users/${uid}/` })
} catch (err) { } catch (err) {
console.warn(`[onUserDelete] Storage cleanup failed for ${uid}:`, err) logger.warn(`[onUserDelete] Storage cleanup failed for ${uid}`, { error: String(err) })
} }
// ── 3. User doc + subcollections ────────────────────────────────────────── // ── 3. User doc + subcollections ──────────────────────────────────────────
await db.recursiveDelete(userDocRef) await db.recursiveDelete(userDocRef)
console.log(`[onUserDelete] completed cleanup for user ${uid}`) logger.log(`[onUserDelete] completed cleanup for user ${uid}`)
}) })
async function notifyPartner( async function notifyPartner(
partnerId: string, partnerId: string,
partnerDoc: admin.firestore.DocumentSnapshot, partnerDoc: admin.firestore.DocumentSnapshot,
db: admin.firestore.Firestore db: admin.firestore.Firestore
): Promise<void> { ): Promise<void> {
const messaging = admin.messaging() const partnerData = partnerDoc.data()
// Collect FCM tokens (legacy field + fcmTokens subcollection).
const tokens: string[] = []
const legacyToken = partnerDoc.data()?.fcmToken
if (typeof legacyToken === 'string' && legacyToken.length > 0) {
tokens.push(legacyToken)
}
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get()
tokenSnap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) {
tokens.push(t)
}
})
// Preserve the original behavior: only write the in-app record + push if the partner has a
// live device token (nothing to deliver otherwise).
const tokens = await getUserTokens(db, partnerId, partnerData)
if (tokens.length === 0) return if (tokens.length === 0) return
// Write an in-app notification record. // Write an in-app notification record.
@ -111,25 +109,18 @@ async function notifyPartner(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const sendResults = await Promise.allSettled( await sendPushToUser(
tokens.map((token) => db,
messaging.send({ admin.messaging(),
token, partnerId,
notification: { {
title: 'Your partner deleted their account', notification: {
body: 'You are no longer paired. Tap to create a new invite.', title: 'Your partner deleted their account',
}, body: 'You are no longer paired. Tap to create a new invite.',
android: { notification: { channelId: 'partner_activity' } }, // E-OBS },
data: { type: 'partner_deleted_account' }, android: { notification: { channelId: 'partner_activity' } }, // E-OBS
}) data: { type: 'partner_deleted_account' },
) },
partnerData,
) )
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[onUserDelete] FCM send to token ${tokens[i]} failed:`, result.reason)
}
})
await pruneDeadTokens(db, partnerId, tokens, sendResults)
} }