Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 | 2x 2x 2x 2x 2x 1x 2x 2x 13x 13x 11x 1x 10x 6x 6x 8x 7x 1x 6x 1x 5x 5x 1x 4x 8x 4x 2x 13x 10x 10x 8x 8x 8x 4x 4x 1x 3x 1x 2x 2x 2x 2x 2x | import { getApps, initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
import { HttpsError, onCall } from "firebase-functions/https";
import { isAnonymous } from "./authGuard";
if (getApps().length === 0) {
initializeApp();
}
const db = getFirestore();
const REGION = "europe-west2";
type InviteSnap = { exists: boolean; get(key: string): unknown };
/** Require an authenticated caller who has connected a recoverable provider. */
function requireConnectedUid(auth: unknown): string {
const uid = (auth as { uid?: string } | undefined)?.uid;
if (!uid) throw new HttpsError("unauthenticated", "Sign in first.");
if (isAnonymous(auth as never)) {
throw new HttpsError("failed-precondition", "Connect your account before pairing.");
}
return uid;
}
function isExpired(expiresAt: unknown): boolean {
const ts = expiresAt as { toMillis?: () => number } | undefined;
return !!ts?.toMillis && ts.toMillis() < Date.now();
}
/** Validate the invite and return the inviter's uid, or throw a precise error. */
function validateInvite(snap: InviteSnap, uid: string): string {
if (!snap.exists) throw new HttpsError("not-found", "That code doesn't look right.");
if (snap.get("used") === true) {
throw new HttpsError("failed-precondition", "That code has already been used.");
}
if (isExpired(snap.get("expiresAt"))) {
throw new HttpsError("failed-precondition", "That code has expired.");
}
const inviterId = snap.get("inviterId") as string;
if (inviterId === uid)
throw new HttpsError("failed-precondition", "You can't pair with yourself.");
return inviterId;
}
/** A query for any couple a member already belongs to (monogamy check). */
function soloQuery(memberId: string) {
return db.collection("couples").where("members", "array-contains", memberId).limit(1);
}
function nameFrom(snap: { get(key: string): unknown }): string | null {
return (snap.get("displayName") as string | undefined) ?? null;
}
/**
* Pair the caller with the partner who minted `code`. Couples are created
* server-side only (security rules forbid client writes to `couples`), so
* membership can never be forged. A couple is two symmetric members.
*
* The caller must have connected a recoverable provider (no anonymous pairing) so
* every couple member is recoverable. The whole read-validate-claim-create runs in
* a single transaction, so concurrent redemptions (double-taps, two codes) can't
* double-use an invite or leave a member in two couples. Each display name is
* denormalized into the couple doc so a partner can be named without reading the
* other's private user doc.
*/
export const pairWithCode = onCall<{ code: string }>({ region: REGION }, async (request) => {
const uid = requireConnectedUid(request.auth);
const code = request.data.code?.trim().toUpperCase();
if (!code) throw new HttpsError("invalid-argument", "Missing invite code.");
const inviteRef = db.doc(`invites/${code}`);
const coupleId = await db.runTransaction(async (tx) => {
const inviterId = validateInvite(await tx.get(inviteRef), uid);
// All reads before any writes (Firestore transaction rule).
const [inviteeCouples, inviterCouples, inviterUser, inviteeUser] = await Promise.all([
tx.get(soloQuery(uid)),
tx.get(soloQuery(inviterId)),
tx.get(db.doc(`users/${inviterId}`)),
tx.get(db.doc(`users/${uid}`)),
]);
if (!inviteeCouples.empty) {
throw new HttpsError("failed-precondition", "You're already paired with a partner.");
}
if (!inviterCouples.empty) {
throw new HttpsError("failed-precondition", "Your partner is already paired.");
}
const coupleRef = db.collection("couples").doc();
tx.set(coupleRef, {
members: [inviterId, uid],
profiles: {
[inviterId]: { displayName: nameFrom(inviterUser) },
[uid]: { displayName: nameFrom(inviteeUser) },
},
createdAt: FieldValue.serverTimestamp(),
togetherSince: FieldValue.serverTimestamp(),
});
tx.update(inviteRef, { used: true, usedAt: FieldValue.serverTimestamp(), usedBy: uid });
return coupleRef.id;
});
return { coupleId };
});
|