refactor(functions): B3 migrate 12 callables to v2 + harden

Migrate all callables off functions.https.onCall to firebase-functions/v2/https onCall:
createInvite, acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
sendThinkingOfYou, checkDeviceIntegrity, syncEntitlement, assignDailyQuestionCallable,
wrapReleaseKey. context.auth/app → request.auth/app, data arg → request.data. The 8
client-hardcoded callable names are preserved verbatim (verified via emulator discovery).
The manual `if (!request.app)` App Check check is a 1:1 port (no enforceAppCheck switch).

Hardening folded in:
- acceptInviteCallable: await the previously fire-and-forget partner_joined push (gen 2
  freezes the instance after the response) — still swallows push errors so a failed push
  never fails the accept.
- checkDeviceIntegrity: 10s timeout on the Play Integrity client.request so a hung upstream
  can't pin the instance (fail-closed catch already handles the throw); memory 512MiB.
- wrapReleaseKey: memory 512MiB (tink); HttpsError swapped to v2; lazy tink require + graceful
  failure preserved.
- Error mapping with `if (e instanceof HttpsError) throw e` re-throw guard around the risky
  DB sections in acceptInvite, leaveCouple, submitOutcome, sendGentleReminder,
  sendThinkingOfYou — raw errors map to a clean 'internal' without masking intentional codes
  (resource-exhausted rate limits, permission-denied, etc.). leaveCouple's best-effort
  recursiveDelete sweep now swallows errors (the transactional leave already succeeded).
- Adopt shared sendPushToUser()/logger; remove copied token readers + plaintext token logging.

Delete dead placeholder callables notifications/reminders.ts (sendDailyQuestionReminder,
sendPartnerAnsweredNotification) — no client caller; wrote sent:false rows nothing consumed.

Build clean; 70 tests green; discovery loads all callables as v2 in us-central1. dist rebuilt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-07 23:58:32 -05:00
parent 6681bf1518
commit e0c2d67373
36 changed files with 932 additions and 1252 deletions

View File

@ -1,41 +1,9 @@
"use strict"; "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 }); Object.defineProperty(exports, "__esModule", { value: true });
exports.syncEntitlement = void 0; exports.syncEntitlement = void 0;
const functions = __importStar(require("firebase-functions")); const https_1 = require("firebase-functions/v2/https");
const entitlementLogic_1 = require("./entitlementLogic"); const entitlementLogic_1 = require("./entitlementLogic");
const log_1 = require("../log");
/** /**
* Callable function that forces a re-sync of entitlements for the * Callable function that forces a re-sync of entitlements for the
* authenticated caller. * authenticated caller.
@ -49,19 +17,19 @@ const entitlementLogic_1 = require("./entitlementLogic");
* state from the existing Firestore document and rewrites it, ensuring * state from the existing Firestore document and rewrites it, ensuring
* consistent field shapes. * consistent field shapes.
*/ */
exports.syncEntitlement = functions.https.onCall(async (data, context) => { exports.syncEntitlement = (0, https_1.onCall)(async (request) => {
var _a; var _a;
if (!((_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid)) { if (!((_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid)) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.'); throw new https_1.HttpsError('unauthenticated', 'Caller must be authenticated.');
} }
const userId = context.auth.uid; const userId = request.auth.uid;
try { try {
const state = await (0, entitlementLogic_1.applyEntitlementSync)(userId); const state = await (0, entitlementLogic_1.applyEntitlementSync)(userId);
return state; return state;
} }
catch (err) { catch (err) {
console.error('[syncEntitlement] failed:', err); log_1.logger.error('[syncEntitlement] failed', { error: String(err) });
throw new functions.https.HttpsError('internal', 'Entitlement sync failed.'); throw new https_1.HttpsError('internal', 'Entitlement sync failed.');
} }
}); });
//# sourceMappingURL=syncEntitlement.js.map //# sourceMappingURL=syncEntitlement.js.map

View File

@ -1 +1 @@
{"version":3,"file":"syncEntitlement.js","sourceRoot":"","sources":["../../src/billing/syncEntitlement.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,yDAA2E;AAE3E;;;;;;;;;;;;GAYG;AACU,QAAA,eAAe,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE;;IAC5E,IAAI,CAAC,CAAA,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA,EAAE,CAAC;QACvB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,iBAAiB,EACjB,+BAA+B,CAChC,CAAA;IACH,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,CAAA;IAE/B,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,IAAA,uCAAoB,EAAC,MAAM,CAAC,CAAA;QAChD,OAAO,KAAgC,CAAA;IACzC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,GAAG,CAAC,CAAA;QAC/C,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,UAAU,EACV,0BAA0B,CAC3B,CAAA;IACH,CAAC;AACH,CAAC,CAAC,CAAA"} {"version":3,"file":"syncEntitlement.js","sourceRoot":"","sources":["../../src/billing/syncEntitlement.ts"],"names":[],"mappings":";;;AAAA,uDAAgE;AAChE,yDAA2E;AAC3E,gCAA+B;AAE/B;;;;;;;;;;;;GAYG;AACU,QAAA,eAAe,GAAG,IAAA,cAAM,EAAC,KAAK,EAAE,OAAO,EAAE,EAAE;;IACtD,IAAI,CAAC,CAAA,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA,EAAE,CAAC;QACvB,MAAM,IAAI,kBAAU,CAAC,iBAAiB,EAAE,+BAA+B,CAAC,CAAA;IAC1E,CAAC;IAED,MAAM,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,CAAA;IAE/B,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,MAAM,IAAA,uCAAoB,EAAC,MAAM,CAAC,CAAA;QAChD,OAAO,KAAgC,CAAA;IACzC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,YAAM,CAAC,KAAK,CAAC,0BAA0B,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,kBAAU,CAAC,UAAU,EAAE,0BAA0B,CAAC,CAAA;IAC9D,CAAC;AACH,CAAC,CAAC,CAAA"}

View File

@ -34,9 +34,10 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.acceptInviteCallable = void 0; exports.acceptInviteCallable = 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 https_1 = require("firebase-functions/v2/https");
const push_1 = require("../notifications/push");
const log_1 = require("../log");
/** /**
* HTTPS callable that mediates invite acceptance. * HTTPS callable that mediates invite acceptance.
* *
@ -68,18 +69,18 @@ const pruneTokens_1 = require("../notifications/pruneTokens");
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000; // 25h: rate window + 1h buffer; Firestore TTL cleans up after const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000; // 25h: rate window + 1h buffer; Firestore TTL cleans up after
exports.acceptInviteCallable = functions.https.onCall(async (data, context) => { exports.acceptInviteCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c; var _a, _b, _c, _d;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const code = data === null || data === void 0 ? void 0 : data.code; const code = (_b = request.data) === null || _b === void 0 ? void 0 : _b.code;
if (!code || typeof code !== 'string') { if (!code || typeof code !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'code is required.'); throw new https_1.HttpsError('invalid-argument', 'code is required.');
} }
const db = admin.firestore(); const db = admin.firestore();
// Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes. // Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes.
@ -91,7 +92,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
.count() .count()
.get(); .get();
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) { if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.'); throw new https_1.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.');
} }
// Record this attempt before doing any work, so failures also count. // Record this attempt before doing any work, so failures also count.
// expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides. // expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides.
@ -103,15 +104,15 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
}); });
// Caller must not already be paired. // Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get(); const callerDoc = await db.collection('users').doc(callerId).get();
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) { if (callerDoc.exists && ((_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.coupleId) != null) {
throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.'); throw new https_1.HttpsError('failed-precondition', 'Caller is already paired.');
} }
const inviteRef = db.collection('invites').doc(code); const inviteRef = db.collection('invites').doc(code);
const inviteDoc = await inviteRef.get(); const inviteDoc = await inviteRef.get();
if (!inviteDoc.exists) { if (!inviteDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Invite not found.'); throw new https_1.HttpsError('not-found', 'Invite not found.');
} }
const invite = (_c = inviteDoc.data()) !== null && _c !== void 0 ? _c : {}; const invite = (_d = inviteDoc.data()) !== null && _d !== void 0 ? _d : {};
const inviterUserId = invite.inviterUserId; const inviterUserId = invite.inviterUserId;
const status = invite.status; const status = invite.status;
const expiresAt = invite.expiresAt; const expiresAt = invite.expiresAt;
@ -120,17 +121,17 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
const kdfParams = invite.kdfParams; const kdfParams = invite.kdfParams;
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase; const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase;
if (status !== 'pending') { if (status !== 'pending') {
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.'); throw new https_1.HttpsError('failed-precondition', 'Invite has already been used.');
} }
const now = admin.firestore.Timestamp.now(); const now = admin.firestore.Timestamp.now();
if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) { if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) {
throw new functions.https.HttpsError('failed-precondition', 'Invite has expired.'); throw new https_1.HttpsError('failed-precondition', 'Invite has expired.');
} }
if (!inviterUserId) { if (!inviterUserId) {
throw new functions.https.HttpsError('failed-precondition', 'Invite is missing inviterUserId.'); throw new https_1.HttpsError('failed-precondition', 'Invite is missing inviterUserId.');
} }
if (inviterUserId === callerId) { if (inviterUserId === callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.'); throw new https_1.HttpsError('permission-denied', 'Cannot accept your own invite.');
} }
const coupleId = db.collection('couples').doc().id; const coupleId = db.collection('couples').doc().id;
const coupleRef = db.collection('couples').doc(coupleId); const coupleRef = db.collection('couples').doc(coupleId);
@ -138,7 +139,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
// the invite is malformed (or pre-dates strict E2EE) — reject rather than create a // the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
// broken plaintext couple the client can't use. // broken plaintext couple the client can't use.
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) { if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
throw new functions.https.HttpsError('failed-precondition', 'Invite is missing encryption material. Ask your partner to create a new invite.'); throw new https_1.HttpsError('failed-precondition', 'Invite is missing encryption material. Ask your partner to create a new invite.');
} }
const encryptionVersion = 2; const encryptionVersion = 2;
const batch = db.batch(); const batch = db.batch();
@ -167,10 +168,19 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
// from here on). It was only needed for the pre-accept "X invited you" preview. // from here on). It was only needed for the pre-accept "X invited you" preview.
inviterDisplayName: admin.firestore.FieldValue.delete(), inviterDisplayName: admin.firestore.FieldValue.delete(),
}); });
try {
await batch.commit(); await batch.commit();
console.log(`[acceptInviteCallable] ${callerId} accepted an invite; created couple ${coupleId}`); }
// Notify the inviter that their partner joined — fire-and-forget. catch (e) {
notifyPartnerJoined(db, inviterUserId, coupleId).catch((e) => console.warn('[acceptInviteCallable] partner_joined FCM failed:', e)); if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[acceptInviteCallable] couple creation failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to accept invite. Please try again.');
}
log_1.logger.log(`[acceptInviteCallable] ${callerId} accepted an invite; created couple ${coupleId}`);
// Notify the inviter that their partner joined. Await so gen 2 doesn't freeze the instance
// before the push completes; swallow errors so a failed push never fails the accept itself.
await notifyPartnerJoined(db, inviterUserId, coupleId).catch((e) => log_1.logger.warn('[acceptInviteCallable] partner_joined FCM failed', { error: String(e) }));
return { return {
coupleId, coupleId,
inviterUserId, inviterUserId,
@ -188,11 +198,7 @@ async function notifyPartnerJoined(db, inviterUserId, coupleId) {
read: false, read: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
const tokens = await getUserTokens(db, inviterUserId); await (0, push_1.sendPushToUser)(db, admin.messaging(), inviterUserId, {
if (tokens.length === 0)
return;
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
token,
notification: { notification: {
title: 'Your partner joined!', title: 'Your partner joined!',
body: "You're connected. Time to answer tonight's question together.", body: "You're connected. Time to answer tonight's question together.",
@ -202,23 +208,6 @@ async function notifyPartnerJoined(db, inviterUserId, coupleId) {
type: 'partner_joined', type: 'partner_joined',
couple_id: coupleId, couple_id: coupleId,
}, },
})));
await (0, pruneTokens_1.pruneDeadTokens)(db, inviterUserId, tokens, results);
}
async function getUserTokens(db, userId) {
var _a;
const tokens = [];
const userDoc = await db.collection('users').doc(userId).get();
const legacy = (_a = userDoc.data()) === null || _a === void 0 ? void 0 : _a.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const snap = await db.collection('users').doc(userId).collection('fcmTokens').get();
snap.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);
}); });
return tokens;
} }
//# sourceMappingURL=acceptInviteCallable.js.map //# sourceMappingURL=acceptInviteCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,8 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.createInviteCallable = void 0; exports.createInviteCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
/** /**
* HTTPS callable that creates a secure invite code. * HTTPS callable that creates a secure invite code.
* *
@ -79,20 +80,21 @@ function generateCode() {
} }
return code; return code;
} }
exports.createInviteCallable = functions.https.onCall(async (data, context) => { exports.createInviteCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c; var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const data = request.data;
const db = admin.firestore(); const db = admin.firestore();
// Caller must not already be paired. // Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get(); const callerDoc = await db.collection('users').doc(callerId).get();
if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) { if (callerDoc.exists && ((_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId) != null) {
throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.'); throw new https_1.HttpsError('failed-precondition', 'Caller is already paired.');
} }
const callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName; const callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName;
// Rate limit: count invites created by this user in the last hour. // Rate limit: count invites created by this user in the last hour.
@ -106,7 +108,7 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
.limit(RATE_LIMIT_MAX + 1); .limit(RATE_LIMIT_MAX + 1);
const recentInvites = await recentInvitesQuery.get(); const recentInvites = await recentInvitesQuery.get();
if (recentInvites.size >= RATE_LIMIT_MAX) { if (recentInvites.size >= RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.'); throw new https_1.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
} }
const clientCode = data === null || data === void 0 ? void 0 : data.code; const clientCode = data === null || data === void 0 ? void 0 : data.code;
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey; const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
@ -116,16 +118,16 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied // Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required. // code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
if (!clientCode) { if (!clientCode) {
throw new functions.https.HttpsError('invalid-argument', 'code is required.'); throw new https_1.HttpsError('invalid-argument', 'code is required.');
} }
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style // Security review Batch 2: validate the code is exactly the 6-char Crockford-style
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized // alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
// codes and anything that could be abused as the document id. // codes and anything that could be abused as the document id.
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) { if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
throw new functions.https.HttpsError('invalid-argument', 'code must be 6 valid characters.'); throw new https_1.HttpsError('invalid-argument', 'code must be 6 valid characters.');
} }
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) { if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.'); throw new https_1.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.');
} }
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS); const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
// Android supplies its own code (used as the KDF input for phrase encryption, so the server // Android supplies its own code (used as the KDF input for phrase encryption, so the server
@ -166,7 +168,7 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
} }
if (!code || !inviteRef) { if (!code || !inviteRef) {
// Client-supplied code collided; the Android client will retry with a new code. // Client-supplied code collided; the Android client will retry with a new code.
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.'); throw new https_1.HttpsError('already-exists', 'Invite code is already taken. Please try again.');
} }
// Write a server-side audit log entry for the inviter. This is not read by // Write a server-side audit log entry for the inviter. This is not read by
// clients and supports the rate-limit count as well as future abuse review. // clients and supports the rate-limit count as well as future abuse review.
@ -180,9 +182,9 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
} }
catch (err) { catch (err) {
// Audit write is best-effort; do not fail the invite if it errors. // Audit write is best-effort; do not fail the invite if it errors.
console.warn(`[createInviteCallable] audit log failed for ${callerId}:`, err); log_1.logger.warn(`[createInviteCallable] audit log failed for ${callerId}`, { error: String(err) });
} }
console.log(`[createInviteCallable] ${callerId} created an invite; expires ${expiresAt.toDate().toISOString()}`); log_1.logger.log(`[createInviteCallable] ${callerId} created an invite; expires ${expiresAt.toDate().toISOString()}`);
return { code, expiresAt }; return { code, expiresAt };
}); });
//# sourceMappingURL=createInviteCallable.js.map //# sourceMappingURL=createInviteCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,8 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.leaveCoupleCallable = void 0; exports.leaveCoupleCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
/** /**
* HTTPS callable that atomically unlinks a couple. * HTTPS callable that atomically unlinks a couple.
* *
@ -50,14 +51,14 @@ const admin = __importStar(require("firebase-admin"));
* The existing onCoupleLeave Firestore trigger fires after step 2 and handles * The existing onCoupleLeave Firestore trigger fires after step 2 and handles
* partner notification, so we don't duplicate that here. * partner notification, so we don't duplicate that here.
*/ */
exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => { exports.leaveCoupleCallable = (0, https_1.onCall)(async (request) => {
var _a, _b; var _a, _b;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const db = admin.firestore(); const db = admin.firestore();
const userDoc = await db.collection('users').doc(callerId).get(); const userDoc = await db.collection('users').doc(callerId).get();
@ -71,7 +72,9 @@ exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
// delete in one transaction so two partners leaving concurrently can't clobber state. // delete in one transaction so two partners leaving concurrently can't clobber state.
// Critically, only clear a member's coupleId if it STILL points at this couple — a // Critically, only clear a member's coupleId if it STILL points at this couple — a
// stale concurrent call must never wipe a coupleId set by a fresh re-pair. // stale concurrent call must never wipe a coupleId set by a fresh re-pair.
const result = await db.runTransaction(async (tx) => { let result;
try {
result = await db.runTransaction(async (tx) => {
var _a, _b, _c; var _a, _b, _c;
const coupleSnap = await tx.get(coupleRef); const coupleSnap = await tx.get(coupleRef);
if (!coupleSnap.exists) { if (!coupleSnap.exists) {
@ -97,13 +100,26 @@ exports.leaveCoupleCallable = functions.https.onCall(async (_data, context) => {
tx.delete(coupleRef); tx.delete(coupleRef);
return { membership: true }; return { membership: true };
}); });
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[leaveCoupleCallable] leave transaction failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to leave couple. Please try again.');
}
if (!result.membership) { if (!result.membership) {
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.'); throw new https_1.HttpsError('permission-denied', 'Not a member of this couple.');
} }
// Couple doc is deleted in the transaction; sweep any subcollections left behind. // Couple doc is deleted in the transaction; sweep any subcollections left behind.
// Idempotent if a concurrent caller already removed them. // Best-effort + idempotent — the leave already succeeded, so a failed sweep must not
// surface as an error to the caller (orphaned subcollections can be reaped later).
try {
await db.recursiveDelete(coupleRef); await db.recursiveDelete(coupleRef);
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`); }
catch (e) {
log_1.logger.warn('[leaveCoupleCallable] subcollection sweep failed (ignored)', { error: String(e) });
}
log_1.logger.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`);
return { success: true }; return { success: true };
}); });
//# sourceMappingURL=leaveCoupleCallable.js.map //# sourceMappingURL=leaveCoupleCallable.js.map

View File

@ -1 +1 @@
{"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":"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"} {"version":3,"file":"leaveCoupleCallable.js","sourceRoot":"","sources":["../../src/couples/leaveCoupleCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,sDAAuC;AACvC,uDAAgE;AAChE,gCAA+B;AAE/B;;;;;;;;;;;;;GAaG;AACU,QAAA,mBAAmB,GAAG,IAAA,cAAM,EAAC,KAAK,EAAE,OAAO,EAAE,EAAE;;IAC1D,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,kBAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/D,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,kBAAU,CAAC,qBAAqB,EAAE,kCAAkC,CAAC,CAAA;IACjF,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA8B,CAAA;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,yCAAyC;QACzC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC;IAED,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IAExD,oFAAoF;IACpF,sFAAsF;IACtF,mFAAmF;IACnF,2EAA2E;IAC3E,IAAI,MAA+B,CAAA;IACnC,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;;YAC5C,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;YAC1C,IAAI,CAAC,UAAU,CAAC,MAAM,EAAE,CAAC;gBACvB,MAAM,SAAS,GAAG,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;gBACtD,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAA;gBAC1C,IAAI,CAAA,MAAA,UAAU,CAAC,IAAI,EAAE,0CAAE,QAAQ,MAAK,QAAQ,EAAE,CAAC;oBAC7C,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;gBAC1C,CAAC;gBACD,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,CAAA;YAC7B,CAAC;YAED,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,UAAU,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;YAC9D,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAChC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,CAAA;YAC9B,CAAC;YAED,2EAA2E;YAC3E,MAAM,WAAW,GAAG,MAAM,OAAO,CAAC,GAAG,CACnC,OAAO,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAC9D,CAAA;YACD,WAAW,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,EAAE;;gBAC9B,IAAI,CAAA,MAAA,IAAI,CAAC,IAAI,EAAE,0CAAE,QAAQ,MAAK,QAAQ,EAAE,CAAC;oBACvC,EAAE,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;gBACvE,CAAC;YACH,CAAC,CAAC,CAAA;YACF,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;YACpB,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,CAAA;QAC7B,CAAC,CAAC,CAAA;IACJ,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,IAAI,CAAC,YAAY,kBAAU;YAAE,MAAM,CAAC,CAAA;QACpC,YAAM,CAAC,KAAK,CAAC,gDAAgD,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;QACpF,MAAM,IAAI,kBAAU,CAAC,UAAU,EAAE,2CAA2C,CAAC,CAAA;IAC/E,CAAC;IAED,IAAI,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;QACvB,MAAM,IAAI,kBAAU,CAAC,mBAAmB,EAAE,8BAA8B,CAAC,CAAA;IAC3E,CAAC;IAED,kFAAkF;IAClF,qFAAqF;IACrF,mFAAmF;IACnF,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,eAAe,CAAC,SAAS,CAAC,CAAA;IACrC,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,YAAM,CAAC,IAAI,CAAC,4DAA4D,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAA;IACjG,CAAC;IAED,YAAM,CAAC,GAAG,CAAC,8BAA8B,QAAQ,gBAAgB,QAAQ,EAAE,CAAC,CAAA;IAC5E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;AAC1B,CAAC,CAAC,CAAA"}

View File

@ -34,8 +34,9 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.submitOutcomeCallable = void 0; exports.submitOutcomeCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
const DAY_KEYS = ['day_0', 'day_30', 'day_60', 'day_90']; const DAY_KEYS = ['day_0', 'day_30', 'day_60', 'day_90'];
const SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness']; const SCORE_KEYS = ['connection', 'communication', 'intimacy', 'happiness'];
const MIN_SCORE = 1; const MIN_SCORE = 1;
@ -56,41 +57,44 @@ function isValidScoreMap(value) {
} }
return true; return true;
} }
exports.submitOutcomeCallable = functions.https.onCall(async (data, context) => { exports.submitOutcomeCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c; var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const data = request.data;
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId; const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
if (typeof coupleId !== 'string' || coupleId.length === 0) { if (typeof coupleId !== 'string' || coupleId.length === 0) {
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.'); throw new https_1.HttpsError('invalid-argument', 'coupleId is required.');
} }
const dayKey = data === null || data === void 0 ? void 0 : data.dayKey; const dayKey = data === null || data === void 0 ? void 0 : data.dayKey;
if (!isValidDayKey(dayKey)) { if (!isValidDayKey(dayKey)) {
throw new functions.https.HttpsError('invalid-argument', `dayKey must be one of: ${DAY_KEYS.join(', ')}.`); throw new https_1.HttpsError('invalid-argument', `dayKey must be one of: ${DAY_KEYS.join(', ')}.`);
} }
const scores = data === null || data === void 0 ? void 0 : data.scores; const scores = data === null || data === void 0 ? void 0 : data.scores;
if (!isValidScoreMap(scores)) { if (!isValidScoreMap(scores)) {
throw new functions.https.HttpsError('invalid-argument', `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`); throw new https_1.HttpsError('invalid-argument', `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`);
} }
const db = admin.firestore(); const db = admin.firestore();
const coupleRef = db.collection('couples').doc(coupleId); const coupleRef = db.collection('couples').doc(coupleId);
// Caller must be a member of the couple. // Caller must be a member of the couple.
const coupleDoc = await coupleRef.get(); const coupleDoc = await coupleRef.get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.'); throw new https_1.HttpsError('not-found', 'Couple not found.');
} }
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []); const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
if (!userIds.includes(callerId)) { if (!userIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.'); throw new https_1.HttpsError('permission-denied', 'Caller is not a member of this couple.');
} }
const now = admin.firestore.Timestamp.now(); const now = admin.firestore.Timestamp.now();
const outcomeRef = coupleRef.collection('outcomes').doc(dayKey); const outcomeRef = coupleRef.collection('outcomes').doc(dayKey);
const result = await db.runTransaction(async (tx) => { let result;
try {
result = await db.runTransaction(async (tx) => {
var _a, _b, _c, _d, _e, _f, _g, _h; var _a, _b, _c, _d, _e, _f, _g, _h;
const existing = await tx.get(outcomeRef); const existing = await tx.get(outcomeRef);
const existingData = existing.exists ? ((_a = existing.data()) !== null && _a !== void 0 ? _a : {}) : {}; const existingData = existing.exists ? ((_a = existing.data()) !== null && _a !== void 0 ? _a : {}) : {};
@ -99,7 +103,7 @@ exports.submitOutcomeCallable = functions.https.onCall(async (data, context) =>
const baselineRef = coupleRef.collection('outcomes').doc('day_0'); const baselineRef = coupleRef.collection('outcomes').doc('day_0');
const baselineSnap = await tx.get(baselineRef); const baselineSnap = await tx.get(baselineRef);
if (!baselineSnap.exists) { if (!baselineSnap.exists) {
throw new functions.https.HttpsError('failed-precondition', 'Baseline (day_0) outcome must be submitted before follow-up outcomes.'); throw new https_1.HttpsError('failed-precondition', 'Baseline (day_0) outcome must be submitted before follow-up outcomes.');
} }
} }
const answeredBy = ((_b = existingData.answeredBy) !== null && _b !== void 0 ? _b : []); const answeredBy = ((_b = existingData.answeredBy) !== null && _b !== void 0 ? _b : []);
@ -138,7 +142,14 @@ exports.submitOutcomeCallable = functions.https.onCall(async (data, context) =>
coupleId }, (dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta })), { answeredBy: [callerId], createdAt: (_h = existingData.createdAt) !== null && _h !== void 0 ? _h : now, updatedAt: now }), { merge: true }); coupleId }, (dayKey === 'day_0' ? { baseline: scores } : { scores, delta: payload.delta })), { answeredBy: [callerId], createdAt: (_h = existingData.createdAt) !== null && _h !== void 0 ? _h : now, updatedAt: now }), { merge: true });
return { dayKey, answeredBy }; return { dayKey, answeredBy };
}); });
console.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`); }
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[submitOutcomeCallable] outcome transaction failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to submit outcome. Please try again.');
}
log_1.logger.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`);
return Object.assign({ success: true }, result); return Object.assign({ success: true }, result);
}); });
//# sourceMappingURL=submitOutcomeCallable.js.map //# sourceMappingURL=submitOutcomeCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
}; };
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0; exports.wrapReleaseKeyCallable = exports.onGamePartFinished = exports.onGameSessionUpdate = exports.onUserDelete = exports.scheduledOutcomesReminder = exports.aggregateOutcomeStats = exports.submitOutcomeCallable = exports.createInviteCallable = exports.acceptInviteCallable = exports.leaveCoupleCallable = exports.onCoupleLeave = exports.onMessageWritten = exports.onAnswerRevealed = exports.onAnswerWritten = exports.assignDailyQuestionCallable = exports.assignDailyQuestion = exports.onRestoreFulfilled = exports.onRestoreRequested = exports.onDateHistoryCreated = exports.onDateReflectionRevealed = exports.onDateReflectionWritten = exports.notifyOnDateMatch = exports.checkDeviceIntegrity = exports.sendReengagementReminder = exports.sendStreakReminder = exports.sendDailyQuestionProactiveReminder = exports.unlockDueMemoryCapsules = exports.sendChallengeDayReminders = exports.sendThinkingOfYouCallable = exports.sendGentleReminderCallable = exports.onEntitlementChanged = exports.syncEntitlement = exports.revenueCatWebhook = void 0;
// Global v2 options (region pin, instance cap) — MUST be imported before any function module so // Global v2 options (region pin, instance cap) — MUST be imported before any function module so
// setGlobalOptions runs before the functions below are defined. // setGlobalOptions runs before the functions below are defined.
require("./options"); require("./options");
@ -50,9 +50,6 @@ var syncEntitlement_1 = require("./billing/syncEntitlement");
Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } }); Object.defineProperty(exports, "syncEntitlement", { enumerable: true, get: function () { return syncEntitlement_1.syncEntitlement; } });
var onEntitlementChanged_1 = require("./billing/onEntitlementChanged"); var onEntitlementChanged_1 = require("./billing/onEntitlementChanged");
Object.defineProperty(exports, "onEntitlementChanged", { enumerable: true, get: function () { return onEntitlementChanged_1.onEntitlementChanged; } }); Object.defineProperty(exports, "onEntitlementChanged", { enumerable: true, get: function () { return onEntitlementChanged_1.onEntitlementChanged; } });
var reminders_1 = require("./notifications/reminders");
Object.defineProperty(exports, "sendDailyQuestionReminder", { enumerable: true, get: function () { return reminders_1.sendDailyQuestionReminder; } });
Object.defineProperty(exports, "sendPartnerAnsweredNotification", { enumerable: true, get: function () { return reminders_1.sendPartnerAnsweredNotification; } });
var sendGentleReminderCallable_1 = require("./notifications/sendGentleReminderCallable"); var sendGentleReminderCallable_1 = require("./notifications/sendGentleReminderCallable");
Object.defineProperty(exports, "sendGentleReminderCallable", { enumerable: true, get: function () { return sendGentleReminderCallable_1.sendGentleReminderCallable; } }); Object.defineProperty(exports, "sendGentleReminderCallable", { enumerable: true, get: function () { return sendGentleReminderCallable_1.sendGentleReminderCallable; } });
var sendThinkingOfYouCallable_1 = require("./notifications/sendThinkingOfYouCallable"); var sendThinkingOfYouCallable_1 = require("./notifications/sendThinkingOfYouCallable");

View File

@ -1 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uDAGkC;AAFhC,sHAAA,yBAAyB,OAAA;AACzB,4HAAA,+BAA+B,OAAA;AAEjC,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"} {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,gGAAgG;AAChG,gEAAgE;AAChE,qBAAkB;AAClB,sDAAuC;AAEvC,qEAAqE;AACrE,8EAA8E;AAC9E,gFAAgF;AAChF,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;IAC5B,KAAK,CAAC,aAAa,EAAE,CAAA;AACvB,CAAC;AAED,iEAA+D;AAAtD,sHAAA,iBAAiB,OAAA;AAC1B,6DAA2D;AAAlD,kHAAA,eAAe,OAAA;AACxB,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yFAAuF;AAA9E,wIAAA,0BAA0B,OAAA;AACnC,uFAAqF;AAA5E,sIAAA,yBAAyB,OAAA;AAClC,+DAGsC;AAFpC,0HAAA,yBAAyB,OAAA;AACzB,wHAAA,uBAAuB,OAAA;AAEzB,+EAA0F;AAAjF,2IAAA,kCAAkC,OAAA;AAC3C,iEAAmE;AAA1D,oHAAA,kBAAkB,OAAA;AAC3B,6DAAuE;AAA9D,wHAAA,wBAAwB,OAAA;AACjC,wEAAsE;AAA7D,4HAAA,oBAAoB,OAAA;AAC7B,2DAA2D;AAAlD,oHAAA,iBAAiB,OAAA;AAC1B,2EAAyE;AAAhE,kIAAA,uBAAuB,OAAA;AAChC,6EAA2E;AAAlE,oIAAA,wBAAwB,OAAA;AACjC,qEAAmE;AAA1D,4HAAA,oBAAoB,OAAA;AAC7B,kEAAoF;AAA3E,wHAAA,kBAAkB,OAAA;AAAE,wHAAA,kBAAkB,OAAA;AAC/C,uEAGwC;AAFtC,0HAAA,mBAAmB,OAAA;AACnB,kIAAA,2BAA2B,OAAA;AAE7B,+DAA6D;AAApD,kHAAA,eAAe,OAAA;AACxB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,iEAA+D;AAAtD,oHAAA,gBAAgB,OAAA;AACzB,yDAAuD;AAA9C,8GAAA,aAAa,OAAA;AACtB,qEAAmE;AAA1D,0HAAA,mBAAmB,OAAA;AAC5B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,uEAAqE;AAA5D,4HAAA,oBAAoB,OAAA;AAC7B,yEAAuE;AAA9D,8HAAA,qBAAqB,OAAA;AAC9B,iEAAmE;AAA1D,0HAAA,qBAAqB,OAAA;AAC9B,iFAA+E;AAAtE,sIAAA,yBAAyB,OAAA;AAClC,qDAAmD;AAA1C,4GAAA,YAAY,OAAA;AACrB,mEAAqF;AAA5E,0HAAA,mBAAmB,OAAA;AAAE,yHAAA,kBAAkB,OAAA;AAEhD,8EAA4E;AAAnE,gIAAA,sBAAsB,OAAA;AAE/B,oFAAoF;AACpF,uEAAuE;AACvE,iFAAiF;AACjF,0DAA0D"}

View File

@ -1,102 +0,0 @@
"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.sendPartnerAnsweredNotification = exports.sendDailyQuestionReminder = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* Callable function that queues a daily-question reminder.
*
* Expected body: { userId: string }
* Auth context supplies the caller uid; the request `userId` must match
* the caller to prevent cross-user notification spam.
*
* This is a placeholder scheduler. The actual daily scheduling will be
* handled by a Firestore trigger / Cloud Scheduler integration later.
*/
exports.sendDailyQuestionReminder = functions.https.onCall(async (data, context) => {
var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
const userId = data.userId;
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.');
}
// Users may only request reminders for themselves.
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.');
}
await writeNotificationRecord(userId, 'daily_question', {
title: (_b = data.title) !== null && _b !== void 0 ? _b : 'Your daily question is waiting!',
body: (_c = data.body) !== null && _c !== void 0 ? _c : "Tap to answer today's question together.",
});
return { queued: true, type: 'daily_question' };
});
/**
* Callable function that queues a partner-answered notification.
*
* Expected body: { userId: string }
* Auth context must match the request `userId`.
*/
exports.sendPartnerAnsweredNotification = functions.https.onCall(async (data, context) => {
var _a, _b, _c;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.');
}
const userId = data.userId;
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.');
}
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.');
}
await writeNotificationRecord(userId, 'partner_answered', {
title: (_b = data.title) !== null && _b !== void 0 ? _b : 'Your partner just answered!',
body: (_c = data.body) !== null && _c !== void 0 ? _c : 'See what your partner shared.',
});
return { queued: true, type: 'partner_answered' };
});
async function writeNotificationRecord(userId, type, message) {
const db = admin.firestore();
await db
.collection('users')
.doc(userId)
.collection('notification_queue')
.add(Object.assign(Object.assign({ type }, message), { read: false, sent: false, createdAt: admin.firestore.FieldValue.serverTimestamp() }));
}
//# sourceMappingURL=reminders.js.map

View File

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

View File

@ -34,9 +34,10 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sendGentleReminderCallable = void 0; exports.sendGentleReminderCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const pruneTokens_1 = require("./pruneTokens"); const https_1 = require("firebase-functions/v2/https");
const push_1 = require("./push");
const log_1 = require("../log");
const GENTLE_REMINDER_MAX_PER_HOUR = 5; const GENTLE_REMINDER_MAX_PER_HOUR = 5;
const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000; // 1 hour const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000; // 1 hour
/** /**
@ -55,31 +56,32 @@ const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000; // 1 hour
* The notification is both an FCM push (for the system tray) and an entry in * The notification is both an FCM push (for the system tray) and an entry in
* the partner's notification_queue (for in-app display). * the partner's notification_queue (for in-app display).
*/ */
exports.sendGentleReminderCallable = functions.https.onCall(async (_data, context) => { exports.sendGentleReminderCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d, _e; var _a, _b, _c, _d;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const db = admin.firestore(); const db = admin.firestore();
// ── 1. Resolve couple + partner ────────────────────────────────────────── // ── 1. Resolve couple + partner ──────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get(); const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId; const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) { if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.'); throw new https_1.HttpsError('failed-precondition', 'Not in a couple.');
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.'); throw new https_1.HttpsError('not-found', 'Couple not found.');
} }
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const partnerId = userIds.find((id) => id !== callerId); const partnerId = userIds.find((id) => id !== callerId);
if (!partnerId) { if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.'); throw new https_1.HttpsError('failed-precondition', 'No partner found.');
} }
try {
// ── 2. Server-side per-user throttle: 5 per hour (rolling window) ──────── // ── 2. Server-side per-user throttle: 5 per hour (rolling window) ────────
const now = admin.firestore.Timestamp.now(); const now = admin.firestore.Timestamp.now();
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`); const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_gentle_reminder`);
@ -112,10 +114,10 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
windowStart, windowStart,
updatedAt: now, updatedAt: now,
}, { merge: true }); }, { merge: true });
return { allowed: true, count: count + 1, windowStart }; return { allowed: true };
}); });
if (!throttleResult.allowed) { if (!throttleResult.allowed) {
throw new functions.https.HttpsError('resource-exhausted', `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`); throw new https_1.HttpsError('resource-exhausted', `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`);
} }
// ── 3. Rate limit: one per couple per day ──────────────────────────────── // ── 3. Rate limit: one per couple per day ────────────────────────────────
const today = new Date().toISOString().slice(0, 10); // e.g. "2026-06-19" const today = new Date().toISOString().slice(0, 10); // e.g. "2026-06-19"
@ -128,28 +130,7 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
if (existingLock.exists) { if (existingLock.exists) {
return { sent: false, reason: 'already_sent_today' }; return { sent: false, reason: 'already_sent_today' };
} }
// ── 4. Collect partner FCM tokens ──────────────────────────────────────── // ── 4. Write in-app notification record ──────────────────────────────────
const tokens = [];
const partnerDoc = await db.collection('users').doc(partnerId).get();
if (partnerDoc.exists) {
const legacyToken = (_e = partnerDoc.data()) === null || _e === void 0 ? void 0 : _e.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);
}
});
// ── 5. Write in-app notification record ──────────────────────────────────
await db await db
.collection('users') .collection('users')
.doc(partnerId) .doc(partnerId)
@ -162,15 +143,13 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
sent: false, sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
// ── 6. Claim the daily rate-limit lock ─────────────────────────────────── // ── 5. Claim the daily rate-limit lock ───────────────────────────────────
await lockRef.set({ await lockRef.set({
sentBy: callerId, sentBy: callerId,
sentAt: admin.firestore.FieldValue.serverTimestamp(), sentAt: admin.firestore.FieldValue.serverTimestamp(),
}); });
// ── 7. Send FCM push ───────────────────────────────────────────────────── // ── 6. Send FCM push ─────────────────────────────────────────────────────
if (tokens.length > 0) { await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
const sendResults = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
token,
notification: { notification: {
title: 'Your partner is thinking about you.', title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.", body: "They left tonight's question open. Answer when you're ready.",
@ -180,15 +159,15 @@ exports.sendGentleReminderCallable = functions.https.onCall(async (_data, contex
type: 'gentle_reminder', type: 'gentle_reminder',
couple_id: coupleId, couple_id: coupleId,
}, },
})));
sendResults.forEach((result, i) => {
if (result.status === 'rejected') {
console.warn(`[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`, result.reason);
}
}); });
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, sendResults); log_1.logger.log(`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
}
console.log(`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
return { sent: true }; return { sent: true };
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[sendGentleReminderCallable] failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to send reminder. Please try again.');
}
}); });
//# sourceMappingURL=sendGentleReminderCallable.js.map //# sourceMappingURL=sendGentleReminderCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -34,10 +34,11 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.sendThinkingOfYouCallable = void 0; exports.sendThinkingOfYouCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const quietHours_1 = require("./quietHours"); const quietHours_1 = require("./quietHours");
const pruneTokens_1 = require("./pruneTokens"); const push_1 = require("./push");
const log_1 = require("../log");
const THINKING_OF_YOU_MAX_PER_DAY = 10; const THINKING_OF_YOU_MAX_PER_DAY = 10;
const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000; // rolling 24h const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000; // rolling 24h
/** /**
@ -51,31 +52,32 @@ const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000; // rolling 24h
* *
* Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push. * Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push.
*/ */
exports.sendThinkingOfYouCallable = functions.https.onCall(async (_data, context) => { exports.sendThinkingOfYouCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d, _e; var _a, _b, _c, _d;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const db = admin.firestore(); const db = admin.firestore();
// ── Resolve couple + partner ───────────────────────────────────────────── // ── Resolve couple + partner ─────────────────────────────────────────────
const userDoc = await db.collection('users').doc(callerId).get(); const userDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId; const coupleId = (_b = userDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) { if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.'); throw new https_1.HttpsError('failed-precondition', 'Not in a couple.');
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.'); throw new https_1.HttpsError('not-found', 'Couple not found.');
} }
const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []); const userIds = ((_d = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds) !== null && _d !== void 0 ? _d : []);
const partnerId = userIds.find((id) => id !== callerId); const partnerId = userIds.find((id) => id !== callerId);
if (!partnerId) { if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.'); throw new https_1.HttpsError('failed-precondition', 'No partner found.');
} }
try {
// ── Per-user rolling rate limit (transactional) ────────────────────────── // ── Per-user rolling rate limit (transactional) ──────────────────────────
const now = admin.firestore.Timestamp.now(); const now = admin.firestore.Timestamp.now();
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`); const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`);
@ -99,7 +101,7 @@ exports.sendThinkingOfYouCallable = functions.https.onCall(async (_data, context
return { allowed: true }; return { allowed: true };
}); });
if (!throttle.allowed) { if (!throttle.allowed) {
throw new functions.https.HttpsError('resource-exhausted', "You've sent a few already — give it a moment."); throw new https_1.HttpsError('resource-exhausted', "You've sent a few already — give it a moment.");
} }
// ── In-app record (always — shows in-app + the Together feed) ──────────── // ── In-app record (always — shows in-app + the Together feed) ────────────
await db.collection('users').doc(partnerId).collection('notification_queue').add({ await db.collection('users').doc(partnerId).collection('notification_queue').add({
@ -111,37 +113,25 @@ exports.sendThinkingOfYouCallable = functions.https.onCall(async (_data, context
}); });
// ── Quiet hours: keep the in-app record, suppress the disruptive push ───── // ── Quiet hours: keep the in-app record, suppress the disruptive push ─────
const partnerDoc = await db.collection('users').doc(partnerId).get(); const partnerDoc = await db.collection('users').doc(partnerId).get();
if ((0, quietHours_1.recipientInQuietHours)(partnerDoc.data())) { const partnerData = partnerDoc.data();
console.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours — push suppressed (in-app kept)`); if ((0, quietHours_1.recipientInQuietHours)(partnerData)) {
log_1.logger.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours — push suppressed (in-app kept)`);
return { sent: true }; return { sent: true };
} }
// ── FCM push ───────────────────────────────────────────────────────────── // ── FCM push ─────────────────────────────────────────────────────────────
const tokens = []; await (0, push_1.sendPushToUser)(db, admin.messaging(), partnerId, {
const legacy = (_e = partnerDoc.data()) === null || _e === void 0 ? void 0 : _e.fcmToken;
if (typeof legacy === 'string' && legacy.length > 0)
tokens.push(legacy);
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get();
tokenSnap.docs.forEach((d) => {
var _a;
const t = (_a = d.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) {
const results = await Promise.allSettled(tokens.map((token) => admin.messaging().send({
token,
notification: { title: 'Your partner is thinking of you 💜', body: 'Tap to send one back.' }, notification: { title: 'Your partner is thinking of you 💜', body: 'Tap to send one back.' },
android: { notification: { channelId: 'partner_activity' } }, // E-OBS android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: { type: 'thinking_of_you', couple_id: coupleId }, data: { type: 'thinking_of_you', couple_id: coupleId },
}))); }, partnerData);
results.forEach((r, i) => { log_1.logger.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
if (r.status === 'rejected') {
console.warn(`[sendThinkingOfYouCallable] FCM failed for token ${tokens[i]}:`, r.reason);
}
});
await (0, pruneTokens_1.pruneDeadTokens)(db, partnerId, tokens, results);
}
console.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`);
return { sent: true }; return { sent: true };
}
catch (e) {
if (e instanceof https_1.HttpsError)
throw e;
log_1.logger.error('[sendThinkingOfYouCallable] failed', { error: String(e) });
throw new https_1.HttpsError('internal', 'Failed to send. Please try again.');
}
}); });
//# sourceMappingURL=sendThinkingOfYouCallable.js.map //# sourceMappingURL=sendThinkingOfYouCallable.js.map

View File

@ -1 +1 @@
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,sDAAuC;AACvC,6CAAoD;AACpD,+CAA+C;AAE/C,MAAM,2BAA2B,GAAG,EAAE,CAAA;AACtC,MAAM,yBAAyB,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA,CAAC,cAAc;AAEpE;;;;;;;;;;GAUG;AACU,QAAA,yBAAyB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE;;IACvF,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,GAAG,CAAA;IAClC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,iBAAiB,EAAE,oBAAoB,CAAC,CAAA;IAC/E,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,kCAAkC,CAAC,CAAA;IACjG,CAAC;IAED,MAAM,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAA;IAE5B,4EAA4E;IAC5E,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IAChE,MAAM,QAAQ,GAAG,MAAA,OAAO,CAAC,IAAI,EAAE,0CAAE,QAA8B,CAAA;IAC/D,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,kBAAkB,CAAC,CAAA;IACjF,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC;QACtB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,WAAW,EAAE,mBAAmB,CAAC,CAAA;IACxE,CAAC;IACD,MAAM,OAAO,GAAG,CAAC,MAAA,MAAA,SAAS,CAAC,IAAI,EAAE,0CAAE,OAAO,mCAAI,EAAE,CAAa,CAAA;IAC7D,MAAM,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,KAAK,QAAQ,CAAC,CAAA;IACvD,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAAC,qBAAqB,EAAE,mBAAmB,CAAC,CAAA;IAClF,CAAC;IAED,4EAA4E;IAC5E,MAAM,GAAG,GAAG,KAAK,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,EAAE,CAAA;IAC3C,MAAM,YAAY,GAAG,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC,CAAC,GAAG,CAAC,GAAG,QAAQ,kBAAkB,CAAC,CAAA;IACpF,MAAM,QAAQ,GAAG,MAAM,EAAE,CAAC,cAAc,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACpD,MAAM,IAAI,GAAG,MAAM,EAAE,CAAC,GAAG,CAAC,YAAY,CAAC,CAAA;QACvC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,CAAA;QACxB,IAAI,WAAW,GAAG,GAAG,CAAA;QACrB,IAAI,KAAK,GAAG,CAAC,CAAA;QACb,IAAI,IAAI,CAAC,MAAM,IAAI,IAAI,EAAE,CAAC;YACxB,WAAW,GAAG,IAAI,CAAC,WAAwC,CAAA;YAC3D,KAAK,GAAG,OAAO,IAAI,CAAC,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAA;YACvD,IAAI,GAAG,CAAC,QAAQ,EAAE,GAAG,WAAW,CAAC,QAAQ,EAAE,IAAI,yBAAyB,EAAE,CAAC;gBACzE,WAAW,GAAG,GAAG,CAAA;gBACjB,KAAK,GAAG,CAAC,CAAA;YACX,CAAC;QACH,CAAC;QACD,IAAI,KAAK,IAAI,2BAA2B,EAAE,CAAC;YACzC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAA;QAC3B,CAAC;QACD,EAAE,CAAC,GAAG,CAAC,YAAY,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,EAAE,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAA;QACxF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;IAC1B,CAAC,CAAC,CAAA;IACF,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC;QACtB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,oBAAoB,EACpB,+CAA+C,CAChD,CAAA;IACH,CAAC;IAED,4EAA4E;IAC5E,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC,GAAG,CAAC;QAC/E,IAAI,EAAE,iBAAiB;QACvB,KAAK,EAAE,oCAAoC;QAC3C,IAAI,EAAE,uBAAuB;QAC7B,IAAI,EAAE,KAAK;QACX,SAAS,EAAE,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,eAAe,EAAE;KACxD,CAAC,CAAA;IAEF,6EAA6E;IAC7E,MAAM,UAAU,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,GAAG,EAAE,CAAA;IACpE,IAAI,IAAA,kCAAqB,EAAC,UAAU,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QAC7C,OAAO,CAAC,GAAG,CAAC,+BAA+B,SAAS,iDAAiD,CAAC,CAAA;QACtG,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;IACvB,CAAC;IAED,4EAA4E;IAC5E,MAAM,MAAM,GAAa,EAAE,CAAA;IAC3B,MAAM,MAAM,GAAG,MAAA,UAAU,CAAC,IAAI,EAAE,0CAAE,QAAQ,CAAA;IAC1C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC;QAAE,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;IACxE,MAAM,SAAS,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,GAAG,EAAE,CAAA;IAC3F,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE;;QAC3B,MAAM,CAAC,GAAG,MAAA,CAAC,CAAC,IAAI,EAAE,0CAAE,KAAK,CAAA;QACzB,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,CAAC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IAClF,CAAC,CAAC,CAAA;IAEF,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QACtB,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,UAAU,CACtC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CACnB,KAAK,CAAC,SAAS,EAAE,CAAC,IAAI,CAAC;YACrB,KAAK;YACL,YAAY,EAAE,EAAE,KAAK,EAAE,oCAAoC,EAAE,IAAI,EAAE,uBAAuB,EAAE;YAC5F,OAAO,EAAE,EAAE,YAAY,EAAE,EAAE,SAAS,EAAE,kBAAkB,EAAE,EAAE,EAAE,QAAQ;YACtE,IAAI,EAAE,EAAE,IAAI,EAAE,iBAAiB,EAAE,SAAS,EAAE,QAAQ,EAAE;SACvD,CAAC,CACH,CACF,CAAA;QACD,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;YACvB,IAAI,CAAC,CAAC,MAAM,KAAK,UAAU,EAAE,CAAC;gBAC5B,OAAO,CAAC,IAAI,CAAC,oDAAoD,MAAM,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,MAAM,CAAC,CAAA;YAC1F,CAAC;QACH,CAAC,CAAC,CAAA;QACF,MAAM,IAAA,6BAAe,EAAC,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,CAAC,CAAA;IACvD,CAAC;IAED,OAAO,CAAC,GAAG,CAAC,yCAAyC,QAAQ,OAAO,SAAS,cAAc,QAAQ,EAAE,CAAC,CAAA;IACtG,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;AACvB,CAAC,CAAC,CAAA"} {"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}

View File

@ -37,9 +37,9 @@ exports.assignDailyQuestionCallable = exports.assignDailyQuestion = void 0;
exports.formatCstDate = formatCstDate; exports.formatCstDate = formatCstDate;
exports.parseCstDate = parseCstDate; exports.parseCstDate = parseCstDate;
exports.timestampAt6PmCst = timestampAt6PmCst; exports.timestampAt6PmCst = timestampAt6PmCst;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const scheduler_1 = require("firebase-functions/v2/scheduler"); const scheduler_1 = require("firebase-functions/v2/scheduler");
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log"); const log_1 = require("../log");
/** /**
* Scheduled function that assigns one daily question per couple every day. * Scheduled function that assigns one daily question per couple every day.
@ -101,28 +101,29 @@ exports.assignDailyQuestion = (0, scheduler_1.onSchedule)({ schedule: '0 23 * *
* *
* Body: { coupleId: string, date?: string } * Body: { coupleId: string, date?: string }
*/ */
exports.assignDailyQuestionCallable = functions.https.onCall(async (data, context) => { exports.assignDailyQuestionCallable = (0, https_1.onCall)(async (request) => {
var _a, _b, _c, _d; var _a, _b, _c, _d;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.'); throw new https_1.HttpsError('unauthenticated', 'Caller must be authenticated.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const data = request.data;
const coupleId = data === null || data === void 0 ? void 0 : data.coupleId; const coupleId = data === null || data === void 0 ? void 0 : data.coupleId;
if (!coupleId || typeof coupleId !== 'string') { if (!coupleId || typeof coupleId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.'); throw new https_1.HttpsError('invalid-argument', 'coupleId is required.');
} }
const db = admin.firestore(); const db = admin.firestore();
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.'); throw new https_1.HttpsError('not-found', 'Couple not found.');
} }
// Caller must be a member of the couple. // Caller must be a member of the couple.
const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []); const userIds = ((_c = (_b = coupleDoc.data()) === null || _b === void 0 ? void 0 : _b.userIds) !== null && _c !== void 0 ? _c : []);
if (!userIds.includes(callerId)) { if (!userIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.'); throw new https_1.HttpsError('permission-denied', 'Caller is not a couple member.');
} }
// Security review Batch 2: constrain the client-supplied date. Only today's CST date // Security review Batch 2: constrain the client-supplied date. Only today's CST date
// may be assigned on demand — this blocks creating arbitrary past/future daily_question // may be assigned on demand — this blocks creating arbitrary past/future daily_question
@ -131,14 +132,14 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
const today = cstDateString(); const today = cstDateString();
const date = (data === null || data === void 0 ? void 0 : data.date) && typeof data.date === 'string' ? data.date : today; const date = (data === null || data === void 0 ? void 0 : data.date) && typeof data.date === 'string' ? data.date : today;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new functions.https.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.'); throw new https_1.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.');
} }
if (date !== today) { if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.'); throw new https_1.HttpsError('invalid-argument', 'Daily question can only be assigned for today.');
} }
const questionId = await pickDailyQuestionId(today); const questionId = await pickDailyQuestionId(today);
if (!questionId) { if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.'); throw new https_1.HttpsError('internal', 'No active questions available.');
} }
const nextDay = nextCstDateString(date); const nextDay = nextCstDateString(date);
const docRef = db const docRef = db
@ -155,10 +156,12 @@ exports.assignDailyQuestionCallable = functions.https.onCall(async (data, contex
}); });
} }
catch (err) { catch (err) {
if (err instanceof https_1.HttpsError)
throw err;
if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_d = err === null || err === void 0 ? void 0 : err.message) === null || _d === void 0 ? void 0 : _d.includes('ALREADY_EXISTS'))) { if ((err === null || err === void 0 ? void 0 : err.code) === 6 || ((_d = err === null || err === void 0 ? void 0 : err.message) === null || _d === void 0 ? void 0 : _d.includes('ALREADY_EXISTS'))) {
throw new functions.https.HttpsError('already-exists', `Daily question already assigned for ${date}.`); throw new https_1.HttpsError('already-exists', `Daily question already assigned for ${date}.`);
} }
throw new functions.https.HttpsError('internal', 'Failed to assign daily question.'); throw new https_1.HttpsError('internal', 'Failed to assign daily question.');
} }
return { success: true, coupleId, date, questionId }; return { success: true, coupleId, date, questionId };
}); });

File diff suppressed because one or more lines are too long

View File

@ -34,25 +34,27 @@ var __importStar = (this && this.__importStar) || (function () {
})(); })();
Object.defineProperty(exports, "__esModule", { value: true }); Object.defineProperty(exports, "__esModule", { value: true });
exports.wrapReleaseKeyCallable = void 0; exports.wrapReleaseKeyCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin")); const admin = __importStar(require("firebase-admin"));
const https_1 = require("firebase-functions/v2/https");
const log_1 = require("../log");
const DEFAULT_AAD = 'closer_release_key'; const DEFAULT_AAD = 'closer_release_key';
exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) => { exports.wrapReleaseKeyCallable = (0, https_1.onCall)({ memory: '512MiB' }, async (request) => {
var _a, _b, _c, _d, _e; var _a, _b, _c, _d, _e;
const callerId = (_a = context.auth) === null || _a === void 0 ? void 0 : _a.uid; const callerId = (_a = request.auth) === null || _a === void 0 ? void 0 : _a.uid;
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.'); throw new https_1.HttpsError('unauthenticated', 'Must be signed in.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const data = request.data;
const recipientUserId = data === null || data === void 0 ? void 0 : data.recipientUserId; const recipientUserId = data === null || data === void 0 ? void 0 : data.recipientUserId;
const oneTimeKeyB64 = data === null || data === void 0 ? void 0 : data.oneTimeKey; const oneTimeKeyB64 = data === null || data === void 0 ? void 0 : data.oneTimeKey;
if (!recipientUserId || typeof recipientUserId !== 'string') { if (!recipientUserId || typeof recipientUserId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'recipientUserId is required.'); throw new https_1.HttpsError('invalid-argument', 'recipientUserId is required.');
} }
if (!oneTimeKeyB64 || typeof oneTimeKeyB64 !== 'string') { if (!oneTimeKeyB64 || typeof oneTimeKeyB64 !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is required.'); throw new https_1.HttpsError('invalid-argument', 'oneTimeKey is required.');
} }
// Validate the oneTimeKey is well-formed base64 of a 32-byte AES-256 key. // Validate the oneTimeKey is well-formed base64 of a 32-byte AES-256 key.
let oneTimeKey; let oneTimeKey;
@ -60,40 +62,40 @@ exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) =>
oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64'); oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64');
} }
catch (_f) { catch (_f) {
throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is not valid base64.'); throw new https_1.HttpsError('invalid-argument', 'oneTimeKey is not valid base64.');
} }
if (oneTimeKey.length !== 32) { if (oneTimeKey.length !== 32) {
throw new functions.https.HttpsError('invalid-argument', `oneTimeKey must be 32 bytes; got ${oneTimeKey.length}.`); throw new https_1.HttpsError('invalid-argument', `oneTimeKey must be 32 bytes; got ${oneTimeKey.length}.`);
} }
const db = admin.firestore(); const db = admin.firestore();
// Caller must be in a couple and the recipient must be their partner. // Caller must be in a couple and the recipient must be their partner.
const callerDoc = await db.collection('users').doc(callerId).get(); const callerDoc = await db.collection('users').doc(callerId).get();
const coupleId = (_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId; const coupleId = (_b = callerDoc.data()) === null || _b === void 0 ? void 0 : _b.coupleId;
if (!coupleId) { if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Caller is not paired.'); throw new https_1.HttpsError('failed-precondition', 'Caller is not paired.');
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get(); const coupleDoc = await db.collection('couples').doc(coupleId).get();
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.'); throw new https_1.HttpsError('not-found', 'Couple not found.');
} }
const coupleUserIds = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds; const coupleUserIds = (_c = coupleDoc.data()) === null || _c === void 0 ? void 0 : _c.userIds;
if (!coupleUserIds || !coupleUserIds.includes(callerId)) { if (!coupleUserIds || !coupleUserIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.'); throw new https_1.HttpsError('permission-denied', 'Caller is not a member of this couple.');
} }
if (!coupleUserIds.includes(recipientUserId)) { if (!coupleUserIds.includes(recipientUserId)) {
throw new functions.https.HttpsError('permission-denied', 'recipientUserId is not the caller\'s partner.'); throw new https_1.HttpsError('permission-denied', 'recipientUserId is not the caller\'s partner.');
} }
if (recipientUserId === callerId) { if (recipientUserId === callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot wrap a release key for yourself.'); throw new https_1.HttpsError('permission-denied', 'Cannot wrap a release key for yourself.');
} }
// Read recipient's public key. Android stores it at users/{uid}/devices/primary. // Read recipient's public key. Android stores it at users/{uid}/devices/primary.
const deviceDoc = await db.collection('users').doc(recipientUserId).collection('devices').doc('primary').get(); const deviceDoc = await db.collection('users').doc(recipientUserId).collection('devices').doc('primary').get();
if (!deviceDoc.exists) { if (!deviceDoc.exists) {
throw new functions.https.HttpsError('failed-precondition', 'Recipient has not registered a release-key device. Ask them to open the app first.'); throw new https_1.HttpsError('failed-precondition', 'Recipient has not registered a release-key device. Ask them to open the app first.');
} }
const publicKeyB64 = (_d = deviceDoc.data()) === null || _d === void 0 ? void 0 : _d.publicKey; const publicKeyB64 = (_d = deviceDoc.data()) === null || _d === void 0 ? void 0 : _d.publicKey;
if (!publicKeyB64 || typeof publicKeyB64 !== 'string') { if (!publicKeyB64 || typeof publicKeyB64 !== 'string') {
throw new functions.https.HttpsError('failed-precondition', 'Recipient device is missing a public key.'); throw new https_1.HttpsError('failed-precondition', 'Recipient device is missing a public key.');
} }
// The actual Tink wrap requires the Tink runtime, which is available on the server via // The actual Tink wrap requires the Tink runtime, which is available on the server via
// the Node.js tink-crypto package. We import it lazily so missing Tink is a clear runtime // the Node.js tink-crypto package. We import it lazily so missing Tink is a clear runtime
@ -105,7 +107,7 @@ exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) =>
tinkAead = tink.aead; tinkAead = tink.aead;
} }
catch (_g) { catch (_g) {
throw new functions.https.HttpsError('internal', 'Tink crypto library is not available on the server.'); throw new https_1.HttpsError('internal', 'Tink crypto library is not available on the server.');
} }
// Decode the recipient's public keyset and create a HybridEncrypt primitive. // Decode the recipient's public keyset and create a HybridEncrypt primitive.
let hybridEncrypt; let hybridEncrypt;
@ -115,8 +117,8 @@ exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) =>
hybridEncrypt = publicHandle.getPrimitive(tinkAead.hybrid.HybridEncrypt); hybridEncrypt = publicHandle.getPrimitive(tinkAead.hybrid.HybridEncrypt);
} }
catch (err) { catch (err) {
console.warn(`[wrapReleaseKeyCallable] public key parse failed for ${recipientUserId}:`, err); log_1.logger.warn(`[wrapReleaseKeyCallable] public key parse failed for ${recipientUserId}`, { error: String(err) });
throw new functions.https.HttpsError('failed-precondition', 'Recipient public key could not be parsed.'); throw new https_1.HttpsError('failed-precondition', 'Recipient public key could not be parsed.');
} }
// Wrap the one-time key. // Wrap the one-time key.
const aad = (_e = data === null || data === void 0 ? void 0 : data.aad) !== null && _e !== void 0 ? _e : DEFAULT_AAD; const aad = (_e = data === null || data === void 0 ? void 0 : data.aad) !== null && _e !== void 0 ? _e : DEFAULT_AAD;
@ -125,8 +127,8 @@ exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) =>
ciphertext = Buffer.from(hybridEncrypt.encrypt(oneTimeKey, Buffer.from(aad, 'utf-8'))); ciphertext = Buffer.from(hybridEncrypt.encrypt(oneTimeKey, Buffer.from(aad, 'utf-8')));
} }
catch (err) { catch (err) {
console.warn(`[wrapReleaseKeyCallable] wrap failed for ${recipientUserId}:`, err); log_1.logger.warn(`[wrapReleaseKeyCallable] wrap failed for ${recipientUserId}`, { error: String(err) });
throw new functions.https.HttpsError('internal', 'Failed to wrap release key.'); throw new https_1.HttpsError('internal', 'Failed to wrap release key.');
} }
// Build the keybox:v1: envelope. The response also exposes the raw components so iOS // Build the keybox:v1: envelope. The response also exposes the raw components so iOS
// can log them for debugging without learning the Tink-internal layout. // can log them for debugging without learning the Tink-internal layout.
@ -144,7 +146,7 @@ exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) =>
await db.collection('users').doc(callerId).collection('notification_queue').add(Object.assign(Object.assign({}, auditFields), { type: 'wrap_release_key', read: true })); await db.collection('users').doc(callerId).collection('notification_queue').add(Object.assign(Object.assign({}, auditFields), { type: 'wrap_release_key', read: true }));
} }
catch (err) { catch (err) {
console.warn(`[wrapReleaseKeyCallable] audit log failed for ${callerId}:`, err); log_1.logger.warn(`[wrapReleaseKeyCallable] audit log failed for ${callerId}`, { error: String(err) });
} }
// Tink's ECIES ciphertext is a single opaque blob; the ephemeral public key and MAC are // Tink's ECIES ciphertext is a single opaque blob; the ephemeral public key and MAC are
// embedded inside it. We expose placeholder fields for iOS diagnostics. // embedded inside it. We expose placeholder fields for iOS diagnostics.
@ -154,7 +156,7 @@ exports.wrapReleaseKeyCallable = functions.https.onCall(async (data, context) =>
ciphertext: keyboxB64, ciphertext: keyboxB64,
mac: '', mac: '',
}; };
console.log(`[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}`); log_1.logger.log(`[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}`);
return response; return response;
}); });
//# sourceMappingURL=wrapReleaseKeyCallable.js.map //# sourceMappingURL=wrapReleaseKeyCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,43 +1,14 @@
"use strict"; "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 }); Object.defineProperty(exports, "__esModule", { value: true });
exports.checkDeviceIntegrity = void 0; exports.checkDeviceIntegrity = void 0;
const functions = __importStar(require("firebase-functions")); const https_1 = require("firebase-functions/v2/https");
const google_auth_library_1 = require("google-auth-library"); const google_auth_library_1 = require("google-auth-library");
const log_1 = require("../log");
const PACKAGE_NAME = 'app.closer'; const PACKAGE_NAME = 'app.closer';
const PLAY_INTEGRITY_URL = `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`; const PLAY_INTEGRITY_URL = `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`;
// Cap the upstream call so a hung Play Integrity API can't pin the instance for the whole
// function timeout; the fail-closed catch below turns a timeout into passed:false.
const PLAY_INTEGRITY_TIMEOUT_MS = 10000;
/** /**
* Verifies a Play Integrity API token server-side and returns whether the * Verifies a Play Integrity API token server-side and returns whether the
* device meets basic integrity requirements. * device meets basic integrity requirements.
@ -58,16 +29,17 @@ const PLAY_INTEGRITY_URL = `https://playintegrity.googleapis.com/v1/${PACKAGE_NA
* (returns passed: false). Configure the API and grant the service account * (returns passed: false). Configure the API and grant the service account
* the "Play Integrity API User" IAM role before deploying to production. * the "Play Integrity API User" IAM role before deploying to production.
*/ */
exports.checkDeviceIntegrity = functions.https.onCall(async (data, context) => { exports.checkDeviceIntegrity = (0, https_1.onCall)({ memory: '512MiB' }, async (request) => {
if (!context.auth) { var _a;
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.'); if (!request.auth) {
throw new https_1.HttpsError('unauthenticated', 'Caller must be authenticated.');
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); throw new https_1.HttpsError('failed-precondition', 'App Check verification required.');
} }
const token = data === null || data === void 0 ? void 0 : data.token; const token = (_a = request.data) === null || _a === void 0 ? void 0 : _a.token;
if (!token || typeof token !== 'string') { if (!token || typeof token !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'token is required.'); throw new https_1.HttpsError('invalid-argument', 'token is required.');
} }
try { try {
const verdicts = await decodeIntegrityToken(token); const verdicts = await decodeIntegrityToken(token);
@ -76,7 +48,7 @@ exports.checkDeviceIntegrity = functions.https.onCall(async (data, context) => {
return { passed, verdicts }; return { passed, verdicts };
} }
catch (err) { catch (err) {
console.error('[checkDeviceIntegrity] verification failed:', err); log_1.logger.error('[checkDeviceIntegrity] verification failed', { error: String(err) });
// Fail-closed: an unverifiable request is treated as failed, not passed. // Fail-closed: an unverifiable request is treated as failed, not passed.
// Ensure the Play Integrity API is enabled and the service account has // Ensure the Play Integrity API is enabled and the service account has
// "Play Integrity API User" role before deploying to production. // "Play Integrity API User" role before deploying to production.
@ -93,6 +65,7 @@ async function decodeIntegrityToken(token) {
url: PLAY_INTEGRITY_URL, url: PLAY_INTEGRITY_URL,
method: 'POST', method: 'POST',
data: { integrity_token: token }, data: { integrity_token: token },
timeout: PLAY_INTEGRITY_TIMEOUT_MS,
}); });
return ((_c = (_b = (_a = response.data.tokenPayloadExternal) === null || _a === void 0 ? void 0 : _a.deviceIntegrity) === null || _b === void 0 ? void 0 : _b.deviceRecognitionVerdict) !== null && _c !== void 0 ? _c : []); return ((_c = (_b = (_a = response.data.tokenPayloadExternal) === null || _a === void 0 ? void 0 : _a.deviceIntegrity) === null || _b === void 0 ? void 0 : _b.deviceRecognitionVerdict) !== null && _c !== void 0 ? _c : []);
} }

View File

@ -1 +1 @@
{"version":3,"file":"checkDeviceIntegrity.js","sourceRoot":"","sources":["../../src/security/checkDeviceIntegrity.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,8DAA+C;AAC/C,6DAAgD;AAEhD,MAAM,YAAY,GAAG,YAAY,CAAA;AACjC,MAAM,kBAAkB,GACtB,2CAA2C,YAAY,uBAAuB,CAAA;AAEhF;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,oBAAoB,GAAG,SAAS,CAAC,KAAK,CAAC,MAAM,CACxD,KAAK,EAAE,IAAwB,EAAE,OAAO,EAAE,EAAE;IAC1C,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAClB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,iBAAiB,EACjB,+BAA+B,CAChC,CAAA;IACH,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,qBAAqB,EACrB,kCAAkC,CACnC,CAAA;IACH,CAAC;IAED,MAAM,KAAK,GAAG,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,KAAK,CAAA;IACzB,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACxC,MAAM,IAAI,SAAS,CAAC,KAAK,CAAC,UAAU,CAClC,kBAAkB,EAClB,oBAAoB,CACrB,CAAA;IACH,CAAC;IAED,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,KAAK,CAAC,CAAA;QAClD,MAAM,MAAM,GACV,QAAQ,CAAC,QAAQ,CAAC,wBAAwB,CAAC;YAC3C,QAAQ,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAA;QAC7C,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAA;IAC7B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,6CAA6C,EAAE,GAAG,CAAC,CAAA;QACjE,yEAAyE;QACzE,uEAAuE;QACvE,iEAAiE;QACjE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAA;IAC3E,CAAC;AACH,CAAC,CACF,CAAA;AAUD,KAAK,UAAU,oBAAoB,CAAC,KAAa;;IAC/C,MAAM,IAAI,GAAG,IAAI,gCAAU,CAAC;QAC1B,MAAM,EAAE,CAAC,+CAA+C,CAAC;KAC1D,CAAC,CAAA;IACF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,CAAA;IACrC,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAoB;QACvD,GAAG,EAAE,kBAAkB;QACvB,MAAM,EAAE,MAAM;QACd,IAAI,EAAE,EAAE,eAAe,EAAE,KAAK,EAAE;KACjC,CAAC,CAAA;IACF,OAAO,CACL,MAAA,MAAA,MAAA,QAAQ,CAAC,IAAI,CAAC,oBAAoB,0CAAE,eAAe,0CAC/C,wBAAwB,mCAAI,EAAE,CACnC,CAAA;AACH,CAAC"} {"version":3,"file":"checkDeviceIntegrity.js","sourceRoot":"","sources":["../../src/security/checkDeviceIntegrity.ts"],"names":[],"mappings":";;;AAAA,uDAAiF;AACjF,6DAAgD;AAChD,gCAA+B;AAE/B,MAAM,YAAY,GAAG,YAAY,CAAA;AACjC,MAAM,kBAAkB,GACtB,2CAA2C,YAAY,uBAAuB,CAAA;AAChF,0FAA0F;AAC1F,mFAAmF;AACnF,MAAM,yBAAyB,GAAG,KAAM,CAAA;AAExC;;;;;;;;;;;;;;;;;;;GAmBG;AACU,QAAA,oBAAoB,GAAG,IAAA,cAAM,EACxC,EAAE,MAAM,EAAE,QAAQ,EAAE,EACpB,KAAK,EAAE,OAA4C,EAAE,EAAE;;IACrD,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,CAAC;QAClB,MAAM,IAAI,kBAAU,CAAC,iBAAiB,EAAE,+BAA+B,CAAC,CAAA;IAC1E,CAAC;IACD,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC;QACjB,MAAM,IAAI,kBAAU,CAAC,qBAAqB,EAAE,kCAAkC,CAAC,CAAA;IACjF,CAAC;IAED,MAAM,KAAK,GAAG,MAAA,OAAO,CAAC,IAAI,0CAAE,KAAK,CAAA;IACjC,IAAI,CAAC,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACxC,MAAM,IAAI,kBAAU,CAAC,kBAAkB,EAAE,oBAAoB,CAAC,CAAA;IAChE,CAAC;IAED,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,KAAK,CAAC,CAAA;QAClD,MAAM,MAAM,GACV,QAAQ,CAAC,QAAQ,CAAC,wBAAwB,CAAC;YAC3C,QAAQ,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAA;QAC7C,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAA;IAC7B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,YAAM,CAAC,KAAK,CAAC,4CAA4C,EAAE,EAAE,KAAK,EAAE,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAClF,yEAAyE;QACzE,uEAAuE;QACvE,iEAAiE;QACjE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAA;IAC3E,CAAC;AACH,CAAC,CACF,CAAA;AAUD,KAAK,UAAU,oBAAoB,CAAC,KAAa;;IAC/C,MAAM,IAAI,GAAG,IAAI,gCAAU,CAAC;QAC1B,MAAM,EAAE,CAAC,+CAA+C,CAAC;KAC1D,CAAC,CAAA;IACF,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,EAAE,CAAA;IACrC,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,OAAO,CAAoB;QACvD,GAAG,EAAE,kBAAkB;QACvB,MAAM,EAAE,MAAM;QACd,IAAI,EAAE,EAAE,eAAe,EAAE,KAAK,EAAE;QAChC,OAAO,EAAE,yBAAyB;KACnC,CAAC,CAAA;IACF,OAAO,CACL,MAAA,MAAA,MAAA,QAAQ,CAAC,IAAI,CAAC,oBAAoB,0CAAE,eAAe,0CAC/C,wBAAwB,mCAAI,EAAE,CACnC,CAAA;AACH,CAAC"}

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions' import { onCall, HttpsError } from 'firebase-functions/v2/https'
import { applyEntitlementSync, EntitlementState } from './entitlementLogic' import { applyEntitlementSync, EntitlementState } from './entitlementLogic'
import { logger } from '../log'
/** /**
* Callable function that forces a re-sync of entitlements for the * Callable function that forces a re-sync of entitlements for the
@ -14,24 +15,18 @@ import { applyEntitlementSync, EntitlementState } from './entitlementLogic'
* state from the existing Firestore document and rewrites it, ensuring * state from the existing Firestore document and rewrites it, ensuring
* consistent field shapes. * consistent field shapes.
*/ */
export const syncEntitlement = functions.https.onCall(async (data, context) => { export const syncEntitlement = onCall(async (request) => {
if (!context.auth?.uid) { if (!request.auth?.uid) {
throw new functions.https.HttpsError( throw new HttpsError('unauthenticated', 'Caller must be authenticated.')
'unauthenticated',
'Caller must be authenticated.'
)
} }
const userId = context.auth.uid const userId = request.auth.uid
try { try {
const state = await applyEntitlementSync(userId) const state = await applyEntitlementSync(userId)
return state satisfies EntitlementState return state satisfies EntitlementState
} catch (err) { } catch (err) {
console.error('[syncEntitlement] failed:', err) logger.error('[syncEntitlement] failed', { error: String(err) })
throw new functions.https.HttpsError( throw new HttpsError('internal', 'Entitlement sync failed.')
'internal',
'Entitlement sync failed.'
)
} }
}) })

View File

@ -1,6 +1,7 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { pruneDeadTokens } from '../notifications/pruneTokens' import { onCall, HttpsError, CallableRequest } from 'firebase-functions/v2/https'
import { sendPushToUser } from '../notifications/push'
import { logger } from '../log'
/** /**
* HTTPS callable that mediates invite acceptance. * HTTPS callable that mediates invite acceptance.
@ -34,18 +35,18 @@ const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000 // 25h: rate window + 1h buffer; Firestore TTL cleans up after const ACCEPT_ATTEMPT_TTL_MS = 25 * 60 * 60 * 1000 // 25h: rate window + 1h buffer; Firestore TTL cleans up after
export const acceptInviteCallable = functions.https.onCall(async (data: Record<string, unknown>, context) => { export const acceptInviteCallable = onCall(async (request: CallableRequest<Record<string, unknown>>) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const code = data?.code const code = request.data?.code
if (!code || typeof code !== 'string') { if (!code || typeof code !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'code is required.') throw new HttpsError('invalid-argument', 'code is required.')
} }
const db = admin.firestore() const db = admin.firestore()
@ -61,7 +62,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: Record<s
.count() .count()
.get() .get()
if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) { if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.') throw new HttpsError('resource-exhausted', 'Too many code attempts. Try again later.')
} }
// Record this attempt before doing any work, so failures also count. // Record this attempt before doing any work, so failures also count.
// expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides. // expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides.
@ -77,14 +78,14 @@ export const acceptInviteCallable = functions.https.onCall(async (data: Record<s
// Caller must not already be paired. // Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get() const callerDoc = await db.collection('users').doc(callerId).get()
if (callerDoc.exists && callerDoc.data()?.coupleId != null) { if (callerDoc.exists && callerDoc.data()?.coupleId != null) {
throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.') throw new HttpsError('failed-precondition', 'Caller is already paired.')
} }
const inviteRef = db.collection('invites').doc(code) const inviteRef = db.collection('invites').doc(code)
const inviteDoc = await inviteRef.get() const inviteDoc = await inviteRef.get()
if (!inviteDoc.exists) { if (!inviteDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Invite not found.') throw new HttpsError('not-found', 'Invite not found.')
} }
const invite = inviteDoc.data() ?? {} const invite = inviteDoc.data() ?? {}
@ -97,20 +98,20 @@ export const acceptInviteCallable = functions.https.onCall(async (data: Record<s
const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase as string | undefined const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase as string | undefined
if (status !== 'pending') { if (status !== 'pending') {
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.') throw new HttpsError('failed-precondition', 'Invite has already been used.')
} }
const now = admin.firestore.Timestamp.now() const now = admin.firestore.Timestamp.now()
if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) { if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) {
throw new functions.https.HttpsError('failed-precondition', 'Invite has expired.') throw new HttpsError('failed-precondition', 'Invite has expired.')
} }
if (!inviterUserId) { if (!inviterUserId) {
throw new functions.https.HttpsError('failed-precondition', 'Invite is missing inviterUserId.') throw new HttpsError('failed-precondition', 'Invite is missing inviterUserId.')
} }
if (inviterUserId === callerId) { if (inviterUserId === callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.') throw new HttpsError('permission-denied', 'Cannot accept your own invite.')
} }
const coupleId = db.collection('couples').doc().id const coupleId = db.collection('couples').doc().id
@ -120,7 +121,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: Record<s
// the invite is malformed (or pre-dates strict E2EE) — reject rather than create a // the invite is malformed (or pre-dates strict E2EE) — reject rather than create a
// broken plaintext couple the client can't use. // broken plaintext couple the client can't use.
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) { if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null) {
throw new functions.https.HttpsError( throw new HttpsError(
'failed-precondition', 'failed-precondition',
'Invite is missing encryption material. Ask your partner to create a new invite.' 'Invite is missing encryption material. Ask your partner to create a new invite.'
) )
@ -157,13 +158,20 @@ export const acceptInviteCallable = functions.https.onCall(async (data: Record<s
inviterDisplayName: admin.firestore.FieldValue.delete(), inviterDisplayName: admin.firestore.FieldValue.delete(),
}) })
try {
await batch.commit() await batch.commit()
} catch (e) {
if (e instanceof HttpsError) throw e
logger.error('[acceptInviteCallable] couple creation failed', { error: String(e) })
throw new HttpsError('internal', 'Failed to accept invite. Please try again.')
}
console.log(`[acceptInviteCallable] ${callerId} accepted an invite; created couple ${coupleId}`) logger.log(`[acceptInviteCallable] ${callerId} accepted an invite; created couple ${coupleId}`)
// Notify the inviter that their partner joined — fire-and-forget. // Notify the inviter that their partner joined. Await so gen 2 doesn't freeze the instance
notifyPartnerJoined(db, inviterUserId, coupleId).catch((e) => // before the push completes; swallow errors so a failed push never fails the accept itself.
console.warn('[acceptInviteCallable] partner_joined FCM failed:', e) await notifyPartnerJoined(db, inviterUserId, coupleId).catch((e) =>
logger.warn('[acceptInviteCallable] partner_joined FCM failed', { error: String(e) })
) )
return { return {
@ -189,13 +197,7 @@ async function notifyPartnerJoined(
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
const tokens = await getUserTokens(db, inviterUserId) await sendPushToUser(db, admin.messaging(), inviterUserId, {
if (tokens.length === 0) return
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: { notification: {
title: 'Your partner joined!', title: 'Your partner joined!',
body: "You're connected. Time to answer tonight's question together.", body: "You're connected. Time to answer tonight's question together.",
@ -206,24 +208,4 @@ async function notifyPartnerJoined(
couple_id: coupleId, couple_id: coupleId,
}, },
}) })
)
)
await pruneDeadTokens(db, inviterUserId, tokens, results)
}
async function getUserTokens(
db: admin.firestore.Firestore,
userId: string
): Promise<string[]> {
const tokens: string[] = []
const userDoc = await db.collection('users').doc(userId).get()
const legacy = userDoc.data()?.fcmToken
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy)
const snap = await db.collection('users').doc(userId).collection('fcmTokens').get()
snap.docs.forEach((doc) => {
const t = doc.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
return tokens
} }

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onCall, HttpsError, CallableRequest } from 'firebase-functions/v2/https'
import { logger } from '../log'
/** /**
* HTTPS callable that creates a secure invite code. * HTTPS callable that creates a secure invite code.
@ -47,21 +48,22 @@ function generateCode(): string {
return code return code
} }
export const createInviteCallable = functions.https.onCall(async (data: Record<string, unknown>, context) => { export const createInviteCallable = onCall(async (request: CallableRequest<Record<string, unknown>>) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const data = request.data
const db = admin.firestore() const db = admin.firestore()
// Caller must not already be paired. // Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get() const callerDoc = await db.collection('users').doc(callerId).get()
if (callerDoc.exists && callerDoc.data()?.coupleId != null) { if (callerDoc.exists && callerDoc.data()?.coupleId != null) {
throw new functions.https.HttpsError('failed-precondition', 'Caller is already paired.') throw new HttpsError('failed-precondition', 'Caller is already paired.')
} }
const callerDisplayName = callerDoc.data()?.displayName as string | undefined const callerDisplayName = callerDoc.data()?.displayName as string | undefined
@ -78,7 +80,7 @@ export const createInviteCallable = functions.https.onCall(async (data: Record<s
const recentInvites = await recentInvitesQuery.get() const recentInvites = await recentInvitesQuery.get()
if (recentInvites.size >= RATE_LIMIT_MAX) { if (recentInvites.size >= RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.') throw new HttpsError('resource-exhausted', 'Too many invites created. Try again later.')
} }
const clientCode = data?.code as string | undefined const clientCode = data?.code as string | undefined
@ -90,16 +92,16 @@ export const createInviteCallable = functions.https.onCall(async (data: Record<s
// Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied // Strict E2EE: every couple must be created with a wrapped couple key. The client-supplied
// code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required. // code, wrapped key, KDF salt/params, and encrypted recovery phrase are all required.
if (!clientCode) { if (!clientCode) {
throw new functions.https.HttpsError('invalid-argument', 'code is required.') throw new HttpsError('invalid-argument', 'code is required.')
} }
// Security review Batch 2: validate the code is exactly the 6-char Crockford-style // Security review Batch 2: validate the code is exactly the 6-char Crockford-style
// alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized // alphabet the client generates (CODE_CHARS, no I/O/0/1). Rejects malformed/oversized
// codes and anything that could be abused as the document id. // codes and anything that could be abused as the document id.
if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) { if (!/^[A-HJ-NP-Z2-9]{6}$/.test(clientCode)) {
throw new functions.https.HttpsError('invalid-argument', 'code must be 6 valid characters.') throw new HttpsError('invalid-argument', 'code must be 6 valid characters.')
} }
if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) { if (wrappedCoupleKey == null || kdfSalt == null || kdfParams == null || encryptedRecoveryPhrase == null) {
throw new functions.https.HttpsError( throw new HttpsError(
'invalid-argument', 'invalid-argument',
'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.' 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase) are required.'
) )
@ -149,7 +151,7 @@ export const createInviteCallable = functions.https.onCall(async (data: Record<s
if (!code || !inviteRef) { if (!code || !inviteRef) {
// Client-supplied code collided; the Android client will retry with a new code. // Client-supplied code collided; the Android client will retry with a new code.
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.') throw new HttpsError('already-exists', 'Invite code is already taken. Please try again.')
} }
// Write a server-side audit log entry for the inviter. This is not read by // Write a server-side audit log entry for the inviter. This is not read by
@ -163,10 +165,10 @@ export const createInviteCallable = functions.https.onCall(async (data: Record<s
}) })
} catch (err) { } catch (err) {
// Audit write is best-effort; do not fail the invite if it errors. // Audit write is best-effort; do not fail the invite if it errors.
console.warn(`[createInviteCallable] audit log failed for ${callerId}:`, err) logger.warn(`[createInviteCallable] audit log failed for ${callerId}`, { error: String(err) })
} }
console.log(`[createInviteCallable] ${callerId} created an invite; expires ${expiresAt.toDate().toISOString()}`) logger.log(`[createInviteCallable] ${callerId} created an invite; expires ${expiresAt.toDate().toISOString()}`)
return { code, expiresAt } return { code, expiresAt }
}) })

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onCall, HttpsError } from 'firebase-functions/v2/https'
import { logger } from '../log'
/** /**
* HTTPS callable that atomically unlinks a couple. * HTTPS callable that atomically unlinks a couple.
@ -15,13 +16,13 @@ import * as admin from 'firebase-admin'
* The existing onCoupleLeave Firestore trigger fires after step 2 and handles * The existing onCoupleLeave Firestore trigger fires after step 2 and handles
* partner notification, so we don't duplicate that here. * partner notification, so we don't duplicate that here.
*/ */
export const leaveCoupleCallable = functions.https.onCall(async (_data, context) => { export const leaveCoupleCallable = onCall(async (request) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const db = admin.firestore() const db = admin.firestore()
@ -39,7 +40,9 @@ export const leaveCoupleCallable = functions.https.onCall(async (_data, context)
// delete in one transaction so two partners leaving concurrently can't clobber state. // delete in one transaction so two partners leaving concurrently can't clobber state.
// Critically, only clear a member's coupleId if it STILL points at this couple — a // Critically, only clear a member's coupleId if it STILL points at this couple — a
// stale concurrent call must never wipe a coupleId set by a fresh re-pair. // stale concurrent call must never wipe a coupleId set by a fresh re-pair.
const result = await db.runTransaction(async (tx) => { let result: { membership: boolean }
try {
result = await db.runTransaction(async (tx) => {
const coupleSnap = await tx.get(coupleRef) const coupleSnap = await tx.get(coupleRef)
if (!coupleSnap.exists) { if (!coupleSnap.exists) {
const callerRef = db.collection('users').doc(callerId) const callerRef = db.collection('users').doc(callerId)
@ -67,15 +70,25 @@ export const leaveCoupleCallable = functions.https.onCall(async (_data, context)
tx.delete(coupleRef) tx.delete(coupleRef)
return { membership: true } return { membership: true }
}) })
} catch (e) {
if (e instanceof HttpsError) throw e
logger.error('[leaveCoupleCallable] leave transaction failed', { error: String(e) })
throw new HttpsError('internal', 'Failed to leave couple. Please try again.')
}
if (!result.membership) { if (!result.membership) {
throw new functions.https.HttpsError('permission-denied', 'Not a member of this couple.') throw new HttpsError('permission-denied', 'Not a member of this couple.')
} }
// Couple doc is deleted in the transaction; sweep any subcollections left behind. // Couple doc is deleted in the transaction; sweep any subcollections left behind.
// Idempotent if a concurrent caller already removed them. // Best-effort + idempotent — the leave already succeeded, so a failed sweep must not
// surface as an error to the caller (orphaned subcollections can be reaped later).
try {
await db.recursiveDelete(coupleRef) await db.recursiveDelete(coupleRef)
} catch (e) {
logger.warn('[leaveCoupleCallable] subcollection sweep failed (ignored)', { error: String(e) })
}
console.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`) logger.log(`[leaveCoupleCallable] user ${callerId} left couple ${coupleId}`)
return { success: true } return { success: true }
}) })

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onCall, HttpsError, CallableRequest } from 'firebase-functions/v2/https'
import { logger } from '../log'
/** /**
* Cloud Function: submitOutcomeCallable * Cloud Function: submitOutcomeCallable
@ -54,23 +55,24 @@ function isValidScoreMap(value: unknown): value is Record<ScoreKey, number> {
return true return true
} }
export const submitOutcomeCallable = functions.https.onCall(async (data: Record<string, unknown>, context) => { export const submitOutcomeCallable = onCall(async (request: CallableRequest<Record<string, unknown>>) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const data = request.data
const coupleId = data?.coupleId const coupleId = data?.coupleId
if (typeof coupleId !== 'string' || coupleId.length === 0) { if (typeof coupleId !== 'string' || coupleId.length === 0) {
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.') throw new HttpsError('invalid-argument', 'coupleId is required.')
} }
const dayKey = data?.dayKey const dayKey = data?.dayKey
if (!isValidDayKey(dayKey)) { if (!isValidDayKey(dayKey)) {
throw new functions.https.HttpsError( throw new HttpsError(
'invalid-argument', 'invalid-argument',
`dayKey must be one of: ${DAY_KEYS.join(', ')}.` `dayKey must be one of: ${DAY_KEYS.join(', ')}.`
) )
@ -78,7 +80,7 @@ export const submitOutcomeCallable = functions.https.onCall(async (data: Record<
const scores = data?.scores const scores = data?.scores
if (!isValidScoreMap(scores)) { if (!isValidScoreMap(scores)) {
throw new functions.https.HttpsError( throw new HttpsError(
'invalid-argument', 'invalid-argument',
`scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.` `scores must contain ${SCORE_KEYS.join(', ')} with values ${MIN_SCORE}-${MAX_SCORE}.`
) )
@ -90,17 +92,19 @@ export const submitOutcomeCallable = functions.https.onCall(async (data: Record<
// Caller must be a member of the couple. // Caller must be a member of the couple.
const coupleDoc = await coupleRef.get() const coupleDoc = await coupleRef.get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.') throw new HttpsError('not-found', 'Couple not found.')
} }
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(callerId)) { if (!userIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.') throw new HttpsError('permission-denied', 'Caller is not a member of this couple.')
} }
const now = admin.firestore.Timestamp.now() const now = admin.firestore.Timestamp.now()
const outcomeRef = coupleRef.collection('outcomes').doc(dayKey) const outcomeRef = coupleRef.collection('outcomes').doc(dayKey)
const result = await db.runTransaction(async (tx) => { let result: { dayKey: OutcomeDayKey; answeredBy: string[] }
try {
result = await db.runTransaction(async (tx) => {
const existing = await tx.get(outcomeRef) const existing = await tx.get(outcomeRef)
const existingData = existing.exists ? (existing.data() ?? {}) : {} const existingData = existing.exists ? (existing.data() ?? {}) : {}
@ -109,7 +113,7 @@ export const submitOutcomeCallable = functions.https.onCall(async (data: Record<
const baselineRef = coupleRef.collection('outcomes').doc('day_0') const baselineRef = coupleRef.collection('outcomes').doc('day_0')
const baselineSnap = await tx.get(baselineRef) const baselineSnap = await tx.get(baselineRef)
if (!baselineSnap.exists) { if (!baselineSnap.exists) {
throw new functions.https.HttpsError( throw new HttpsError(
'failed-precondition', 'failed-precondition',
'Baseline (day_0) outcome must be submitted before follow-up outcomes.' 'Baseline (day_0) outcome must be submitted before follow-up outcomes.'
) )
@ -163,8 +167,13 @@ export const submitOutcomeCallable = functions.https.onCall(async (data: Record<
return { dayKey, answeredBy } return { dayKey, answeredBy }
}) })
} catch (e) {
if (e instanceof HttpsError) throw e
logger.error('[submitOutcomeCallable] outcome transaction failed', { error: String(e) })
throw new HttpsError('internal', 'Failed to submit outcome. Please try again.')
}
console.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`) logger.log(`[submitOutcomeCallable] ${callerId} submitted ${dayKey} for couple ${coupleId}`)
return { success: true, ...result } return { success: true, ...result }
}) })

View File

@ -13,10 +13,6 @@ if (admin.apps.length === 0) {
export { revenueCatWebhook } from './billing/revenueCatWebhook' export { revenueCatWebhook } from './billing/revenueCatWebhook'
export { syncEntitlement } from './billing/syncEntitlement' export { syncEntitlement } from './billing/syncEntitlement'
export { onEntitlementChanged } from './billing/onEntitlementChanged' export { onEntitlementChanged } from './billing/onEntitlementChanged'
export {
sendDailyQuestionReminder,
sendPartnerAnsweredNotification,
} from './notifications/reminders'
export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable' export { sendGentleReminderCallable } from './notifications/sendGentleReminderCallable'
export { sendThinkingOfYouCallable } from './notifications/sendThinkingOfYouCallable' export { sendThinkingOfYouCallable } from './notifications/sendThinkingOfYouCallable'
export { export {

View File

@ -1,94 +0,0 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin'
type ReminderType = 'daily_question' | 'partner_answered' | 'streak'
interface NotificationPayload {
userId: string
type: ReminderType
title?: string
body?: string
}
/**
* Callable function that queues a daily-question reminder.
*
* Expected body: { userId: string }
* Auth context supplies the caller uid; the request `userId` must match
* the caller to prevent cross-user notification spam.
*
* This is a placeholder scheduler. The actual daily scheduling will be
* handled by a Firestore trigger / Cloud Scheduler integration later.
*/
export const sendDailyQuestionReminder = functions.https.onCall(async (data: NotificationPayload, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.')
}
const userId = data.userId
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.')
}
// Users may only request reminders for themselves.
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.')
}
await writeNotificationRecord(userId, 'daily_question', {
title: data.title ?? 'Your daily question is waiting!',
body: data.body ?? "Tap to answer today's question together.",
})
return { queued: true, type: 'daily_question' }
})
/**
* Callable function that queues a partner-answered notification.
*
* Expected body: { userId: string }
* Auth context must match the request `userId`.
*/
export const sendPartnerAnsweredNotification = functions.https.onCall(async (data: NotificationPayload, context) => {
const callerId = context.auth?.uid
if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.')
}
const userId = data.userId
if (!userId || typeof userId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'userId is required.')
}
if (userId !== callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot send notifications to another user.')
}
await writeNotificationRecord(userId, 'partner_answered', {
title: data.title ?? 'Your partner just answered!',
body: data.body ?? 'See what your partner shared.',
})
return { queued: true, type: 'partner_answered' }
})
async function writeNotificationRecord(
userId: string,
type: ReminderType,
message: { title: string; body: string }
): Promise<void> {
const db = admin.firestore()
await db
.collection('users')
.doc(userId)
.collection('notification_queue')
.add({
type,
...message,
read: false,
sent: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
})
}

View File

@ -1,6 +1,7 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { pruneDeadTokens } from './pruneTokens' import { onCall, HttpsError } from 'firebase-functions/v2/https'
import { sendPushToUser } from './push'
import { logger } from '../log'
const GENTLE_REMINDER_MAX_PER_HOUR = 5 const GENTLE_REMINDER_MAX_PER_HOUR = 5
const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000 // 1 hour const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000 // 1 hour
@ -21,13 +22,13 @@ const GENTLE_REMINDER_WINDOW_MS = 60 * 60 * 1000 // 1 hour
* The notification is both an FCM push (for the system tray) and an entry in * The notification is both an FCM push (for the system tray) and an entry in
* the partner's notification_queue (for in-app display). * the partner's notification_queue (for in-app display).
*/ */
export const sendGentleReminderCallable = functions.https.onCall(async (_data, context) => { export const sendGentleReminderCallable = onCall(async (request) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const db = admin.firestore() const db = admin.firestore()
@ -37,20 +38,21 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
const userDoc = await db.collection('users').doc(callerId).get() const userDoc = await db.collection('users').doc(callerId).get()
const coupleId = userDoc.data()?.coupleId as string | undefined const coupleId = userDoc.data()?.coupleId as string | undefined
if (!coupleId) { if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.') throw new HttpsError('failed-precondition', 'Not in a couple.')
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.') throw new HttpsError('not-found', 'Couple not found.')
} }
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== callerId) const partnerId = userIds.find((id) => id !== callerId)
if (!partnerId) { if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.') throw new HttpsError('failed-precondition', 'No partner found.')
} }
try {
// ── 2. Server-side per-user throttle: 5 per hour (rolling window) ──────── // ── 2. Server-side per-user throttle: 5 per hour (rolling window) ────────
const now = admin.firestore.Timestamp.now() const now = admin.firestore.Timestamp.now()
@ -81,7 +83,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
if (count >= GENTLE_REMINDER_MAX_PER_HOUR) { if (count >= GENTLE_REMINDER_MAX_PER_HOUR) {
const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis()) const retryAfterMs = GENTLE_REMINDER_WINDOW_MS - (now.toMillis() - windowStart.toMillis())
const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60_000)) const retryAfterMinutes = Math.max(1, Math.ceil(retryAfterMs / 60_000))
return { allowed: false, retryAfterMinutes } return { allowed: false as const, retryAfterMinutes }
} }
tx.set(rateLimitRef, { tx.set(rateLimitRef, {
@ -90,11 +92,11 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
updatedAt: now, updatedAt: now,
}, { merge: true }) }, { merge: true })
return { allowed: true, count: count + 1, windowStart } return { allowed: true as const }
}) })
if (!throttleResult.allowed) { if (!throttleResult.allowed) {
throw new functions.https.HttpsError( throw new HttpsError(
'resource-exhausted', 'resource-exhausted',
`Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.` `Too many gentle reminders. Try again in ${throttleResult.retryAfterMinutes} minutes.`
) )
@ -114,30 +116,7 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
return { sent: false, reason: 'already_sent_today' } return { sent: false, reason: 'already_sent_today' }
} }
// ── 4. Collect partner FCM tokens ──────────────────────────────────────── // ── 4. Write in-app notification record ──────────────────────────────────
const tokens: string[] = []
const partnerDoc = await db.collection('users').doc(partnerId).get()
if (partnerDoc.exists) {
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)
}
})
// ── 5. Write in-app notification record ──────────────────────────────────
await db await db
.collection('users') .collection('users')
@ -152,20 +131,16 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
createdAt: admin.firestore.FieldValue.serverTimestamp(), createdAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
// ── 6. Claim the daily rate-limit lock ─────────────────────────────────── // ── 5. Claim the daily rate-limit lock ───────────────────────────────────
await lockRef.set({ await lockRef.set({
sentBy: callerId, sentBy: callerId,
sentAt: admin.firestore.FieldValue.serverTimestamp(), sentAt: admin.firestore.FieldValue.serverTimestamp(),
}) })
// ── 7. Send FCM push ───────────────────────────────────────────────────── // ── 6. Send FCM push ─────────────────────────────────────────────────────
if (tokens.length > 0) { await sendPushToUser(db, admin.messaging(), partnerId, {
const sendResults = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: { notification: {
title: 'Your partner is thinking about you.', title: 'Your partner is thinking about you.',
body: "They left tonight's question open. Answer when you're ready.", body: "They left tonight's question open. Answer when you're ready.",
@ -176,23 +151,14 @@ export const sendGentleReminderCallable = functions.https.onCall(async (_data, c
couple_id: coupleId, couple_id: coupleId,
}, },
}) })
)
)
sendResults.forEach((result, i) => { logger.log(
if (result.status === 'rejected') {
console.warn(
`[sendGentleReminderCallable] FCM failed for token ${tokens[i]}:`,
result.reason
)
}
})
await pruneDeadTokens(db, partnerId, tokens, sendResults)
}
console.log(
`[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}` `[sendGentleReminderCallable] reminder sent from ${callerId} to ${partnerId} in couple ${coupleId}`
) )
return { sent: true } return { sent: true }
} catch (e) {
if (e instanceof HttpsError) throw e
logger.error('[sendGentleReminderCallable] failed', { error: String(e) })
throw new HttpsError('internal', 'Failed to send reminder. Please try again.')
}
}) })

View File

@ -1,7 +1,8 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onCall, HttpsError } from 'firebase-functions/v2/https'
import { recipientInQuietHours } from './quietHours' import { recipientInQuietHours } from './quietHours'
import { pruneDeadTokens } from './pruneTokens' import { sendPushToUser } from './push'
import { logger } from '../log'
const THINKING_OF_YOU_MAX_PER_DAY = 10 const THINKING_OF_YOU_MAX_PER_DAY = 10
const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000 // rolling 24h const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000 // rolling 24h
@ -17,13 +18,13 @@ const THINKING_OF_YOU_WINDOW_MS = 24 * 60 * 60 * 1000 // rolling 24h
* *
* Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push. * Writes a partner-safe `notification_queue` entry (in-app + the "Together" feed) and an FCM push.
*/ */
export const sendThinkingOfYouCallable = functions.https.onCall(async (_data, context) => { export const sendThinkingOfYouCallable = onCall(async (request) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const db = admin.firestore() const db = admin.firestore()
@ -32,18 +33,19 @@ export const sendThinkingOfYouCallable = functions.https.onCall(async (_data, co
const userDoc = await db.collection('users').doc(callerId).get() const userDoc = await db.collection('users').doc(callerId).get()
const coupleId = userDoc.data()?.coupleId as string | undefined const coupleId = userDoc.data()?.coupleId as string | undefined
if (!coupleId) { if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Not in a couple.') throw new HttpsError('failed-precondition', 'Not in a couple.')
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.') throw new HttpsError('not-found', 'Couple not found.')
} }
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
const partnerId = userIds.find((id) => id !== callerId) const partnerId = userIds.find((id) => id !== callerId)
if (!partnerId) { if (!partnerId) {
throw new functions.https.HttpsError('failed-precondition', 'No partner found.') throw new HttpsError('failed-precondition', 'No partner found.')
} }
try {
// ── Per-user rolling rate limit (transactional) ────────────────────────── // ── Per-user rolling rate limit (transactional) ──────────────────────────
const now = admin.firestore.Timestamp.now() const now = admin.firestore.Timestamp.now()
const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`) const rateLimitRef = db.collection('rate_limits').doc(`${callerId}_thinking_of_you`)
@ -61,16 +63,13 @@ export const sendThinkingOfYouCallable = functions.https.onCall(async (_data, co
} }
} }
if (count >= THINKING_OF_YOU_MAX_PER_DAY) { if (count >= THINKING_OF_YOU_MAX_PER_DAY) {
return { allowed: false } return { allowed: false as const }
} }
tx.set(rateLimitRef, { count: count + 1, windowStart, updatedAt: now }, { merge: true }) tx.set(rateLimitRef, { count: count + 1, windowStart, updatedAt: now }, { merge: true })
return { allowed: true } return { allowed: true as const }
}) })
if (!throttle.allowed) { if (!throttle.allowed) {
throw new functions.https.HttpsError( throw new HttpsError('resource-exhausted', "You've sent a few already — give it a moment.")
'resource-exhausted',
"You've sent a few already — give it a moment."
)
} }
// ── In-app record (always — shows in-app + the Together feed) ──────────── // ── In-app record (always — shows in-app + the Together feed) ────────────
@ -84,40 +83,30 @@ export const sendThinkingOfYouCallable = functions.https.onCall(async (_data, co
// ── Quiet hours: keep the in-app record, suppress the disruptive push ───── // ── Quiet hours: keep the in-app record, suppress the disruptive push ─────
const partnerDoc = await db.collection('users').doc(partnerId).get() const partnerDoc = await db.collection('users').doc(partnerId).get()
if (recipientInQuietHours(partnerDoc.data())) { const partnerData = partnerDoc.data()
console.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours — push suppressed (in-app kept)`) if (recipientInQuietHours(partnerData)) {
logger.log(`[sendThinkingOfYouCallable] ${partnerId} in quiet hours — push suppressed (in-app kept)`)
return { sent: true } return { sent: true }
} }
// ── FCM push ───────────────────────────────────────────────────────────── // ── FCM push ─────────────────────────────────────────────────────────────
const tokens: string[] = [] await sendPushToUser(
const legacy = partnerDoc.data()?.fcmToken db,
if (typeof legacy === 'string' && legacy.length > 0) tokens.push(legacy) admin.messaging(),
const tokenSnap = await db.collection('users').doc(partnerId).collection('fcmTokens').get() partnerId,
tokenSnap.docs.forEach((d) => { {
const t = d.data()?.token
if (typeof t === 'string' && t.length > 0 && !tokens.includes(t)) tokens.push(t)
})
if (tokens.length > 0) {
const results = await Promise.allSettled(
tokens.map((token) =>
admin.messaging().send({
token,
notification: { title: 'Your partner is thinking of you 💜', body: 'Tap to send one back.' }, notification: { title: 'Your partner is thinking of you 💜', body: 'Tap to send one back.' },
android: { notification: { channelId: 'partner_activity' } }, // E-OBS android: { notification: { channelId: 'partner_activity' } }, // E-OBS
data: { type: 'thinking_of_you', couple_id: coupleId }, data: { type: 'thinking_of_you', couple_id: coupleId },
}) },
partnerData,
) )
)
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.warn(`[sendThinkingOfYouCallable] FCM failed for token ${tokens[i]}:`, r.reason)
}
})
await pruneDeadTokens(db, partnerId, tokens, results)
}
console.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`) logger.log(`[sendThinkingOfYouCallable] sent from ${callerId} to ${partnerId} in couple ${coupleId}`)
return { sent: true } return { sent: true }
} catch (e) {
if (e instanceof HttpsError) throw e
logger.error('[sendThinkingOfYouCallable] failed', { error: String(e) })
throw new HttpsError('internal', 'Failed to send. Please try again.')
}
}) })

View File

@ -1,6 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onSchedule } from 'firebase-functions/v2/scheduler' import { onSchedule } from 'firebase-functions/v2/scheduler'
import { onCall, HttpsError, CallableRequest } from 'firebase-functions/v2/https'
import { logger } from '../log' import { logger } from '../log'
/** /**
@ -72,30 +72,31 @@ export const assignDailyQuestion = onSchedule(
* *
* Body: { coupleId: string, date?: string } * Body: { coupleId: string, date?: string }
*/ */
export const assignDailyQuestionCallable = functions.https.onCall(async (data: Record<string, unknown>, context) => { export const assignDailyQuestionCallable = onCall(async (request: CallableRequest<Record<string, unknown>>) => {
const callerId = context.auth?.uid const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Caller must be authenticated.') throw new HttpsError('unauthenticated', 'Caller must be authenticated.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const data = request.data
const coupleId = data?.coupleId const coupleId = data?.coupleId
if (!coupleId || typeof coupleId !== 'string') { if (!coupleId || typeof coupleId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'coupleId is required.') throw new HttpsError('invalid-argument', 'coupleId is required.')
} }
const db = admin.firestore() const db = admin.firestore()
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.') throw new HttpsError('not-found', 'Couple not found.')
} }
// Caller must be a member of the couple. // Caller must be a member of the couple.
const userIds = (coupleDoc.data()?.userIds ?? []) as string[] const userIds = (coupleDoc.data()?.userIds ?? []) as string[]
if (!userIds.includes(callerId)) { if (!userIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Caller is not a couple member.') throw new HttpsError('permission-denied', 'Caller is not a couple member.')
} }
// Security review Batch 2: constrain the client-supplied date. Only today's CST date // Security review Batch 2: constrain the client-supplied date. Only today's CST date
@ -105,14 +106,14 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
const today = cstDateString() const today = cstDateString()
const date = data?.date && typeof data.date === 'string' ? data.date : today const date = data?.date && typeof data.date === 'string' ? data.date : today
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new functions.https.HttpsError('invalid-argument', 'date must be YYYY-MM-DD.') throw new HttpsError('invalid-argument', 'date must be YYYY-MM-DD.')
} }
if (date !== today) { if (date !== today) {
throw new functions.https.HttpsError('invalid-argument', 'Daily question can only be assigned for today.') throw new HttpsError('invalid-argument', 'Daily question can only be assigned for today.')
} }
const questionId = await pickDailyQuestionId(today) const questionId = await pickDailyQuestionId(today)
if (!questionId) { if (!questionId) {
throw new functions.https.HttpsError('internal', 'No active questions available.') throw new HttpsError('internal', 'No active questions available.')
} }
const nextDay = nextCstDateString(date) const nextDay = nextCstDateString(date)
@ -130,10 +131,11 @@ export const assignDailyQuestionCallable = functions.https.onCall(async (data: R
expiresAt: timestampAt6PmCst(nextDay), expiresAt: timestampAt6PmCst(nextDay),
}) })
} catch (err: any) { } catch (err: any) {
if (err instanceof HttpsError) throw err
if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) { if (err?.code === 6 || err?.message?.includes('ALREADY_EXISTS')) {
throw new functions.https.HttpsError('already-exists', `Daily question already assigned for ${date}.`) throw new HttpsError('already-exists', `Daily question already assigned for ${date}.`)
} }
throw new functions.https.HttpsError('internal', 'Failed to assign daily question.') throw new HttpsError('internal', 'Failed to assign daily question.')
} }
return { success: true, coupleId, date, questionId } return { success: true, coupleId, date, questionId }

View File

@ -1,5 +1,6 @@
import * as functions from 'firebase-functions'
import * as admin from 'firebase-admin' import * as admin from 'firebase-admin'
import { onCall, HttpsError, CallableRequest } from 'firebase-functions/v2/https'
import { logger } from '../log'
/** /**
* HTTPS callable that wraps an iOS one-time AES-256 answer key for an Android partner. * HTTPS callable that wraps an iOS one-time AES-256 answer key for an Android partner.
@ -47,22 +48,25 @@ export interface WrapReleaseKeyResponse {
const DEFAULT_AAD = 'closer_release_key' const DEFAULT_AAD = 'closer_release_key'
export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record<string, unknown>, context) => { export const wrapReleaseKeyCallable = onCall(
const callerId = context.auth?.uid { memory: '512MiB' },
async (request: CallableRequest<Record<string, unknown>>) => {
const callerId = request.auth?.uid
if (!callerId) { if (!callerId) {
throw new functions.https.HttpsError('unauthenticated', 'Must be signed in.') throw new HttpsError('unauthenticated', 'Must be signed in.')
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.') throw new HttpsError('failed-precondition', 'App Check verification required.')
} }
const data = request.data
const recipientUserId = data?.recipientUserId const recipientUserId = data?.recipientUserId
const oneTimeKeyB64 = data?.oneTimeKey const oneTimeKeyB64 = data?.oneTimeKey
if (!recipientUserId || typeof recipientUserId !== 'string') { if (!recipientUserId || typeof recipientUserId !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'recipientUserId is required.') throw new HttpsError('invalid-argument', 'recipientUserId is required.')
} }
if (!oneTimeKeyB64 || typeof oneTimeKeyB64 !== 'string') { if (!oneTimeKeyB64 || typeof oneTimeKeyB64 !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is required.') throw new HttpsError('invalid-argument', 'oneTimeKey is required.')
} }
// Validate the oneTimeKey is well-formed base64 of a 32-byte AES-256 key. // Validate the oneTimeKey is well-formed base64 of a 32-byte AES-256 key.
@ -70,10 +74,10 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
try { try {
oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64') oneTimeKey = Buffer.from(oneTimeKeyB64, 'base64')
} catch { } catch {
throw new functions.https.HttpsError('invalid-argument', 'oneTimeKey is not valid base64.') throw new HttpsError('invalid-argument', 'oneTimeKey is not valid base64.')
} }
if (oneTimeKey.length !== 32) { if (oneTimeKey.length !== 32) {
throw new functions.https.HttpsError( throw new HttpsError(
'invalid-argument', 'invalid-argument',
`oneTimeKey must be 32 bytes; got ${oneTimeKey.length}.` `oneTimeKey must be 32 bytes; got ${oneTimeKey.length}.`
) )
@ -85,41 +89,35 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
const callerDoc = await db.collection('users').doc(callerId).get() const callerDoc = await db.collection('users').doc(callerId).get()
const coupleId = callerDoc.data()?.coupleId as string | undefined const coupleId = callerDoc.data()?.coupleId as string | undefined
if (!coupleId) { if (!coupleId) {
throw new functions.https.HttpsError('failed-precondition', 'Caller is not paired.') throw new HttpsError('failed-precondition', 'Caller is not paired.')
} }
const coupleDoc = await db.collection('couples').doc(coupleId).get() const coupleDoc = await db.collection('couples').doc(coupleId).get()
if (!coupleDoc.exists) { if (!coupleDoc.exists) {
throw new functions.https.HttpsError('not-found', 'Couple not found.') throw new HttpsError('not-found', 'Couple not found.')
} }
const coupleUserIds = coupleDoc.data()?.userIds as string[] | undefined const coupleUserIds = coupleDoc.data()?.userIds as string[] | undefined
if (!coupleUserIds || !coupleUserIds.includes(callerId)) { if (!coupleUserIds || !coupleUserIds.includes(callerId)) {
throw new functions.https.HttpsError('permission-denied', 'Caller is not a member of this couple.') throw new HttpsError('permission-denied', 'Caller is not a member of this couple.')
} }
if (!coupleUserIds.includes(recipientUserId)) { if (!coupleUserIds.includes(recipientUserId)) {
throw new functions.https.HttpsError( throw new HttpsError('permission-denied', 'recipientUserId is not the caller\'s partner.')
'permission-denied',
'recipientUserId is not the caller\'s partner.'
)
} }
if (recipientUserId === callerId) { if (recipientUserId === callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot wrap a release key for yourself.') throw new HttpsError('permission-denied', 'Cannot wrap a release key for yourself.')
} }
// Read recipient's public key. Android stores it at users/{uid}/devices/primary. // Read recipient's public key. Android stores it at users/{uid}/devices/primary.
const deviceDoc = await db.collection('users').doc(recipientUserId).collection('devices').doc('primary').get() const deviceDoc = await db.collection('users').doc(recipientUserId).collection('devices').doc('primary').get()
if (!deviceDoc.exists) { if (!deviceDoc.exists) {
throw new functions.https.HttpsError( throw new HttpsError(
'failed-precondition', 'failed-precondition',
'Recipient has not registered a release-key device. Ask them to open the app first.' 'Recipient has not registered a release-key device. Ask them to open the app first.'
) )
} }
const publicKeyB64 = deviceDoc.data()?.publicKey as string | undefined const publicKeyB64 = deviceDoc.data()?.publicKey as string | undefined
if (!publicKeyB64 || typeof publicKeyB64 !== 'string') { if (!publicKeyB64 || typeof publicKeyB64 !== 'string') {
throw new functions.https.HttpsError( throw new HttpsError('failed-precondition', 'Recipient device is missing a public key.')
'failed-precondition',
'Recipient device is missing a public key.'
)
} }
// The actual Tink wrap requires the Tink runtime, which is available on the server via // The actual Tink wrap requires the Tink runtime, which is available on the server via
@ -131,7 +129,7 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
const tink = require('@tink-crypto/tink-crypto') const tink = require('@tink-crypto/tink-crypto')
tinkAead = tink.aead tinkAead = tink.aead
} catch { } catch {
throw new functions.https.HttpsError('internal', 'Tink crypto library is not available on the server.') throw new HttpsError('internal', 'Tink crypto library is not available on the server.')
} }
// Decode the recipient's public keyset and create a HybridEncrypt primitive. // Decode the recipient's public keyset and create a HybridEncrypt primitive.
@ -143,11 +141,8 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
) )
hybridEncrypt = publicHandle.getPrimitive(tinkAead.hybrid.HybridEncrypt) hybridEncrypt = publicHandle.getPrimitive(tinkAead.hybrid.HybridEncrypt)
} catch (err) { } catch (err) {
console.warn(`[wrapReleaseKeyCallable] public key parse failed for ${recipientUserId}:`, err) logger.warn(`[wrapReleaseKeyCallable] public key parse failed for ${recipientUserId}`, { error: String(err) })
throw new functions.https.HttpsError( throw new HttpsError('failed-precondition', 'Recipient public key could not be parsed.')
'failed-precondition',
'Recipient public key could not be parsed.'
)
} }
// Wrap the one-time key. // Wrap the one-time key.
@ -156,8 +151,8 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
try { try {
ciphertext = Buffer.from(hybridEncrypt.encrypt(oneTimeKey, Buffer.from(aad, 'utf-8'))) ciphertext = Buffer.from(hybridEncrypt.encrypt(oneTimeKey, Buffer.from(aad, 'utf-8')))
} catch (err) { } catch (err) {
console.warn(`[wrapReleaseKeyCallable] wrap failed for ${recipientUserId}:`, err) logger.warn(`[wrapReleaseKeyCallable] wrap failed for ${recipientUserId}`, { error: String(err) })
throw new functions.https.HttpsError('internal', 'Failed to wrap release key.') throw new HttpsError('internal', 'Failed to wrap release key.')
} }
// Build the keybox:v1: envelope. The response also exposes the raw components so iOS // Build the keybox:v1: envelope. The response also exposes the raw components so iOS
@ -181,7 +176,7 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
read: true, read: true,
}) })
} catch (err) { } catch (err) {
console.warn(`[wrapReleaseKeyCallable] audit log failed for ${callerId}:`, err) logger.warn(`[wrapReleaseKeyCallable] audit log failed for ${callerId}`, { error: String(err) })
} }
// Tink's ECIES ciphertext is a single opaque blob; the ephemeral public key and MAC are // Tink's ECIES ciphertext is a single opaque blob; the ephemeral public key and MAC are
@ -193,9 +188,10 @@ export const wrapReleaseKeyCallable = functions.https.onCall(async (data: Record
mac: '', mac: '',
} }
console.log( logger.log(
`[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}` `[wrapReleaseKeyCallable] ${callerId} wrapped release key for ${recipientUserId} in couple ${coupleId}`
) )
return response return response
}) }
)

View File

@ -1,9 +1,13 @@
import * as functions from 'firebase-functions' import { onCall, HttpsError, CallableRequest } from 'firebase-functions/v2/https'
import { GoogleAuth } from 'google-auth-library' import { GoogleAuth } from 'google-auth-library'
import { logger } from '../log'
const PACKAGE_NAME = 'app.closer' const PACKAGE_NAME = 'app.closer'
const PLAY_INTEGRITY_URL = const PLAY_INTEGRITY_URL =
`https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken` `https://playintegrity.googleapis.com/v1/${PACKAGE_NAME}:decodeIntegrityToken`
// Cap the upstream call so a hung Play Integrity API can't pin the instance for the whole
// function timeout; the fail-closed catch below turns a timeout into passed:false.
const PLAY_INTEGRITY_TIMEOUT_MS = 10_000
/** /**
* Verifies a Play Integrity API token server-side and returns whether the * Verifies a Play Integrity API token server-side and returns whether the
@ -25,27 +29,19 @@ const PLAY_INTEGRITY_URL =
* (returns passed: false). Configure the API and grant the service account * (returns passed: false). Configure the API and grant the service account
* the "Play Integrity API User" IAM role before deploying to production. * the "Play Integrity API User" IAM role before deploying to production.
*/ */
export const checkDeviceIntegrity = functions.https.onCall( export const checkDeviceIntegrity = onCall(
async (data: { token?: string }, context) => { { memory: '512MiB' },
if (!context.auth) { async (request: CallableRequest<{ token?: string }>) => {
throw new functions.https.HttpsError( if (!request.auth) {
'unauthenticated', throw new HttpsError('unauthenticated', 'Caller must be authenticated.')
'Caller must be authenticated.'
)
} }
if (!context.app) { if (!request.app) {
throw new functions.https.HttpsError( throw new HttpsError('failed-precondition', 'App Check verification required.')
'failed-precondition',
'App Check verification required.'
)
} }
const token = data?.token const token = request.data?.token
if (!token || typeof token !== 'string') { if (!token || typeof token !== 'string') {
throw new functions.https.HttpsError( throw new HttpsError('invalid-argument', 'token is required.')
'invalid-argument',
'token is required.'
)
} }
try { try {
@ -55,7 +51,7 @@ export const checkDeviceIntegrity = functions.https.onCall(
verdicts.includes('MEETS_STRONG_INTEGRITY') verdicts.includes('MEETS_STRONG_INTEGRITY')
return { passed, verdicts } return { passed, verdicts }
} catch (err) { } catch (err) {
console.error('[checkDeviceIntegrity] verification failed:', err) logger.error('[checkDeviceIntegrity] verification failed', { error: String(err) })
// Fail-closed: an unverifiable request is treated as failed, not passed. // Fail-closed: an unverifiable request is treated as failed, not passed.
// Ensure the Play Integrity API is enabled and the service account has // Ensure the Play Integrity API is enabled and the service account has
// "Play Integrity API User" role before deploying to production. // "Play Integrity API User" role before deploying to production.
@ -81,6 +77,7 @@ async function decodeIntegrityToken(token: string): Promise<string[]> {
url: PLAY_INTEGRITY_URL, url: PLAY_INTEGRITY_URL,
method: 'POST', method: 'POST',
data: { integrity_token: token }, data: { integrity_token: token },
timeout: PLAY_INTEGRITY_TIMEOUT_MS,
}) })
return ( return (
response.data.tokenPayloadExternal?.deviceIntegrity response.data.tokenPayloadExternal?.deviceIntegrity