"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, recoveryPhrase } * * Operations (all via Admin SDK, so Firestore rules are bypassed): * 1. Verify caller is authenticated and not already paired. * 2. Look up the invite document by code. * 3. Validate status == 'pending' and not expired. * 4. Prevent self-acceptance. * 5. Create the couple document with the wrapped couple key from the invite. * 6. Update both user documents with the new coupleId. * 7. Mark the invite as accepted. */ 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.'); } 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); await db.collection('users').doc(callerId).collection('invite_attempts').add({ attemptedAt: admin.firestore.FieldValue.serverTimestamp(), expiresAt: attemptExpiresAt, code, }); // 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 recoveryPhrase = invite.recoveryPhrase; 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); // Derive encryption version from E2EE field presence. // encryptionVersion must stay in sync with EncryptionVersion.kt: // 0 = plaintext (no couple key; iOS MVP path) // 1 = legacy migration (mixed) // 2 = strict E2EE (all new Android couples) // Hardcoding 2 when wrappedCoupleKey is null creates a broken couple state // where the client expects a key that does not exist. const hasE2EE = wrappedCoupleKey != null && kdfSalt != null && kdfParams != null; const encryptionVersion = hasE2EE ? 2 : 0; 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 recovery phrase from the invite doc on acceptance. // It served its purpose (returned to the acceptor above); leaving key material // in a document that stays in Firestore for 24h is unnecessary exposure. batch.update(inviteRef, { status: 'accepted', acceptedByUserId: callerId, acceptedAt: admin.firestore.FieldValue.serverTimestamp(), coupleId, recoveryPhrase: admin.firestore.FieldValue.delete(), }); await batch.commit(); console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`); 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, recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null, }; }); //# sourceMappingURL=acceptInviteCallable.js.map