"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.acceptInviteCallable = void 0; const functions = __importStar(require("firebase-functions")); const admin = __importStar(require("firebase-admin")); /** * HTTPS callable that mediates invite acceptance. * * Issue #9 fix: clients are no longer allowed to read invites directly, because * the 6-character document ID was enumerable. The invite is looked up by code * server-side, validated, and accepted atomically here. * * Request body: { code: string } * - code: the 6-character invite code the partner shared. * * The recovery phrase is stored on the invite document by the inviter and returned * directly — the acceptor never needs to type it manually. * * Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase } * - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor * decrypts it client-side using the invite code they entered. The server never sees * the plaintext phrase. * * Operations (all via Admin SDK, so Firestore rules are bypassed): * 1. Verify caller is authenticated and not already paired. * 2. Rate-limit accept attempts per UID. * 3. Look up the invite document by code. * 4. Validate status == 'pending' and not expired. * 5. Prevent self-acceptance. * 6. Create the couple document with the wrapped couple key from the invite. * 7. Update both user documents with the new coupleId. * 8. Mark the invite as accepted and wipe the encrypted phrase from the doc. */ 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_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) => { 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', 'Must be signed in.'); } if (!context.app) { throw new functions.https.HttpsError('failed-precondition', 'App Check verification required.'); } const code = data === null || data === void 0 ? void 0 : data.code; if (!code || typeof code !== 'string') { throw new functions.https.HttpsError('invalid-argument', 'code is required.'); } const db = admin.firestore(); // Rate-limit accept attempts per caller to prevent brute-forcing 6-char codes. const windowStart = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() - ACCEPT_RATE_LIMIT_WINDOW_MS); const recentAttempts = await db .collection('users').doc(callerId) .collection('invite_attempts') .where('attemptedAt', '>=', windowStart) .count() .get(); if (recentAttempts.data().count >= ACCEPT_RATE_LIMIT_MAX) { throw new functions.https.HttpsError('resource-exhausted', 'Too many code attempts. Try again later.'); } // Record this attempt before doing any work, so failures also count. // expiresAt is the Firestore TTL field — see firestore.indexes.json fieldOverrides. const attemptExpiresAt = admin.firestore.Timestamp.fromMillis(admin.firestore.Timestamp.now().toMillis() + ACCEPT_ATTEMPT_TTL_MS); // The code is the KDF seed for the couple's recovery phrase — never store it raw. await db.collection('users').doc(callerId).collection('invite_attempts').add({ attemptedAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt: attemptExpiresAt, }); // Caller must not already be paired. const callerDoc = await db.collection('users').doc(callerId).get(); 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.'); } const inviteRef = db.collection('invites').doc(code); const inviteDoc = await inviteRef.get(); if (!inviteDoc.exists) { throw new functions.https.HttpsError('not-found', 'Invite not found.'); } const invite = (_c = inviteDoc.data()) !== null && _c !== void 0 ? _c : {}; const inviterUserId = invite.inviterUserId; const status = invite.status; const expiresAt = invite.expiresAt; const wrappedCoupleKey = invite.wrappedCoupleKey; const kdfSalt = invite.kdfSalt; const kdfParams = invite.kdfParams; const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase; if (status !== 'pending') { throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.'); } const now = admin.firestore.Timestamp.now(); if (expiresAt != null && expiresAt.toMillis() <= now.toMillis()) { throw new functions.https.HttpsError('failed-precondition', 'Invite has expired.'); } if (!inviterUserId) { throw new functions.https.HttpsError('failed-precondition', 'Invite is missing inviterUserId.'); } if (inviterUserId === callerId) { throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.'); } const coupleId = db.collection('couples').doc().id; const coupleRef = db.collection('couples').doc(coupleId); // Strict E2EE only: a valid invite always carries a wrapped couple key. If it doesn't, // the invite is malformed (or pre-dates strict E2EE) — reject rather than create a // broken plaintext couple the client can't use. 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.'); } const encryptionVersion = 2; const batch = db.batch(); batch.set(coupleRef, { id: coupleId, userIds: [inviterUserId, callerId], inviteCode: code, createdAt: admin.firestore.FieldValue.serverTimestamp(), streakCount: 0, encryptionVersion, wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, }); batch.update(db.collection('users').doc(inviterUserId), { coupleId }); batch.update(db.collection('users').doc(callerId), { coupleId }); // Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor // who will decrypt it client-side. No reason to keep it in Firestore after use. batch.update(inviteRef, { status: 'accepted', acceptedByUserId: callerId, acceptedAt: admin.firestore.FieldValue.serverTimestamp(), coupleId, encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(), }); await batch.commit(); console.log(`[acceptInviteCallable] ${callerId} accepted an invite; created couple ${coupleId}`); // Notify the inviter that their partner joined — fire-and-forget. notifyPartnerJoined(db, inviterUserId, coupleId).catch((e) => console.warn('[acceptInviteCallable] partner_joined FCM failed:', e)); return { coupleId, inviterUserId, wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null, }; }); async function notifyPartnerJoined(db, inviterUserId, coupleId) { await db.collection('users').doc(inviterUserId).collection('notification_queue').add({ type: 'partner_joined', title: 'Your partner joined!', body: "You're connected. Time to answer tonight's question together.", read: false, createdAt: admin.firestore.FieldValue.serverTimestamp(), }); const tokens = await getUserTokens(db, inviterUserId); if (tokens.length === 0) return; await Promise.allSettled(tokens.map((token) => admin.messaging().send({ token, notification: { title: 'Your partner joined!', body: "You're connected. Time to answer tonight's question together.", }, android: { notification: { channelId: 'partner_activity' } }, // E-OBS data: { type: 'partner_joined', couple_id: coupleId, }, }))); } 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