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:
parent
6681bf1518
commit
e0c2d67373
|
|
@ -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
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -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":"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"}
|
||||||
|
|
@ -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
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"file":"reminders.js","sourceRoot":"","sources":["../../src/notifications/reminders.ts"],"names":[],"mappings":"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"}
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
@ -1 +1 @@
|
||||||
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}
|
{"version":3,"file":"sendThinkingOfYouCallable.js","sourceRoot":"","sources":["../../src/notifications/sendThinkingOfYouCallable.ts"],"names":[],"mappings":"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"}
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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 : []);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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.'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -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.')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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.')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue