Closer/functions/dist/couples/createInviteCallable.js

174 lines
8.3 KiB
JavaScript
Raw Normal View History

"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.createInviteCallable = void 0;
const functions = __importStar(require("firebase-functions"));
const admin = __importStar(require("firebase-admin"));
/**
* HTTPS callable that creates a secure invite code.
*
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
* invites directly. 6-character document IDs are enumerable, so a direct client
* write would expose pending invites to scanning. This function generates a
* unique 6-character code server-side, stores the invite document, and returns
* only the code and expiry to the inviter.
*
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string }
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
* - kdfSalt: base64 KDF salt
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor)
*
* When E2EE fields are omitted the function writes nulls; iOS MVP creates
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not
* supply these fields. Android always supplies them.
*
* Response: { code: string, expiresAt: Timestamp }
*
* Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired.
* 2. Rate-limit the caller to 5 invite creations per rolling hour.
* 3. Generate a unique 6-character alphanumeric code via transaction.
* 4. Write the invite document with a 24-hour TTL.
*/
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_LENGTH = 6;
const INVITE_TTL_MS = 24 * 60 * 60 * 1000;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const RATE_LIMIT_MAX = 5;
function generateCode() {
let code = '';
const randomValues = Buffer.alloc(CODE_LENGTH);
// crypto.randomBytes is synchronous and suitable for Cloud Functions.
require('crypto').randomFillSync(randomValues);
for (let i = 0; i < CODE_LENGTH; i++) {
code += CODE_CHARS[randomValues[i] % CODE_CHARS.length];
}
return code;
}
exports.createInviteCallable = 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 db = admin.firestore();
// 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 callerDisplayName = (_c = callerDoc.data()) === null || _c === void 0 ? void 0 : _c.displayName;
// Rate limit: count invites created by this user in the last hour.
const now = admin.firestore.Timestamp.now();
const windowStart = admin.firestore.Timestamp.fromMillis(now.toMillis() - RATE_LIMIT_WINDOW_MS);
const recentInvitesQuery = db
.collection('invites')
.where('inviterUserId', '==', callerId)
.where('createdAt', '>=', windowStart)
.orderBy('createdAt', 'desc')
.limit(RATE_LIMIT_MAX + 1);
const recentInvites = await recentInvitesQuery.get();
if (recentInvites.size >= RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
}
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase;
// E2EE fields must be supplied together or omitted together.
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
if (suppliedE2ee > 0 && suppliedE2ee < e2eeFields.length) {
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
}
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
// Race-safe unique code creation via transaction. We attempt a bounded number
// of times; each attempt verifies the candidate code is free before creating.
const maxAttempts = 10;
let inviteRef = null;
let code = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const candidate = generateCode();
const candidateRef = db.collection('invites').doc(candidate);
// eslint-disable-next-line no-await-in-loop
const created = await db.runTransaction(async (tx) => {
const snap = await tx.get(candidateRef);
if (snap.exists) {
return false;
}
tx.set(candidateRef, {
code: candidate,
inviterUserId: callerId,
inviterDisplayName: callerDisplayName !== null && callerDisplayName !== void 0 ? callerDisplayName : null,
status: 'pending',
createdAt: admin.firestore.FieldValue.serverTimestamp(),
expiresAt,
usedAt: null,
usedByUserId: null,
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,
});
return true;
});
if (created) {
code = candidate;
inviteRef = candidateRef;
break;
}
}
if (!code || !inviteRef) {
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.');
}
// 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.
try {
await db.collection('users').doc(callerId).collection('notification_queue').add({
type: 'invite_created',
inviteCode: code,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
read: true,
});
}
catch (err) {
// Audit write is best-effort; do not fail the invite if it errors.
console.warn(`[createInviteCallable] audit log failed for ${callerId}:`, err);
}
console.log(`[createInviteCallable] ${callerId} created invite ${code}; expires ${expiresAt.toDate().toISOString()}`);
return { code, expiresAt };
});
//# sourceMappingURL=createInviteCallable.js.map