From efb31667fa21fba72a4912d83a496e84dae89925 Mon Sep 17 00:00:00 2001 From: gelbh Date: Fri, 24 Jul 2026 22:03:47 +0100 Subject: [PATCH 1/3] fix(session): reclaim orphan session codes on create --- .../firestoreAnnotations.reclaim.test.ts | 20 +++++++++ .../firestore/firestoreAnnotations.ts | 41 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 src/services/firestore/firestoreAnnotations.reclaim.test.ts diff --git a/src/services/firestore/firestoreAnnotations.reclaim.test.ts b/src/services/firestore/firestoreAnnotations.reclaim.test.ts new file mode 100644 index 000000000..588327531 --- /dev/null +++ b/src/services/firestore/firestoreAnnotations.reclaim.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { isReclaimableSessionForCode } from "./firestoreAnnotations"; + +describe("isReclaimableSessionForCode", () => { + it("reclaims missing sessions", () => { + expect(isReclaimableSessionForCode(null)).toBe(true); + expect(isReclaimableSessionForCode(undefined)).toBe(true); + }); + + it("reclaims ended sessions", () => { + expect(isReclaimableSessionForCode({ status: "ended" })).toBe(true); + expect( + isReclaimableSessionForCode({ endedAt: "2026-01-01T00:00:00.000Z" }), + ).toBe(true); + }); + + it("does not reclaim live active sessions", () => { + expect(isReclaimableSessionForCode({ status: "active" })).toBe(false); + }); +}); diff --git a/src/services/firestore/firestoreAnnotations.ts b/src/services/firestore/firestoreAnnotations.ts index 9a06f90aa..19c2934ad 100644 --- a/src/services/firestore/firestoreAnnotations.ts +++ b/src/services/firestore/firestoreAnnotations.ts @@ -3,6 +3,7 @@ import { arrayRemove, arrayUnion, collection, + deleteDoc, deleteField, doc, getDoc, @@ -66,6 +67,19 @@ function sessionsCollection() { function sessionCodeDoc(code: string) { return doc(getFirestoreDb(), "sessionCodes", code); } + +/** True when a sessionCodes doc may be deleted and the code reused. */ +export function isReclaimableSessionForCode( + sessionData: Record | null | undefined, +): boolean { + if (sessionData == null) { + return true; + } + + return ( + sessionData.status === "ended" || typeof sessionData.endedAt === "string" + ); +} function annotationsCollection(sessionId: string) { return collection(getFirestoreDb(), "sessions", sessionId, "annotations"); } @@ -259,6 +273,29 @@ export async function createRemoteSession( break; } + const codeData = existing.data() as Record; + const existingSessionId = + typeof codeData.sessionId === "string" ? codeData.sessionId : null; + + if (existingSessionId) { + const sessionSnap = await getDoc( + doc(sessionsCollection(), existingSessionId), + ); + if ( + isReclaimableSessionForCode( + sessionSnap.exists() + ? (sessionSnap.data() as Record) + : null, + ) + ) { + await deleteDoc(sessionCodeDoc(code)); + break; + } + } else { + await deleteDoc(sessionCodeDoc(code)); + break; + } + code = generateSessionCode(); attempts += 1; } @@ -744,9 +781,7 @@ export async function endRemoteSession(sessionId: string): Promise { }); if (session?.code) { - await updateDoc(sessionCodeDoc(session.code), { - status: "ended", - }); + await deleteDoc(sessionCodeDoc(session.code)); } } From c3f773da26a1c47dc47f64a930f66a7c1c18c360 Mon Sep 17 00:00:00 2001 From: gelbh Date: Fri, 24 Jul 2026 22:06:31 +0100 Subject: [PATCH 2/3] fix(session): allow orphan sessionCodes delete in rules --- firestore.rules | 11 +++++++- .../firestore/firestoreAnnotations.ts | 27 ++++--------------- 2 files changed, 15 insertions(+), 23 deletions(-) diff --git a/firestore.rules b/firestore.rules index 21a85cd12..cd0521e1f 100644 --- a/firestore.rules +++ b/firestore.rules @@ -775,7 +775,16 @@ service cloud.firestore { && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['status']) && request.resource.data.status == 'ended'; - allow delete: if false; + // Host end path, or reclaim when the linked session is missing / ended. + allow delete: if isSignedIn() + && code.size() == 4 + && resource.data.sessionId is string + && ( + resource.data.hostUid == request.auth.uid + || !exists(/databases/$(database)/documents/sessions/$(resource.data.sessionId)) + || get(/databases/$(database)/documents/sessions/$(resource.data.sessionId)).data.status == 'ended' + || get(/databases/$(database)/documents/sessions/$(resource.data.sessionId)).data.endedAt is string + ); } match /_rateLimits/{docId} { diff --git a/src/services/firestore/firestoreAnnotations.ts b/src/services/firestore/firestoreAnnotations.ts index 19c2934ad..22ba59873 100644 --- a/src/services/firestore/firestoreAnnotations.ts +++ b/src/services/firestore/firestoreAnnotations.ts @@ -273,31 +273,14 @@ export async function createRemoteSession( break; } - const codeData = existing.data() as Record; - const existingSessionId = - typeof codeData.sessionId === "string" ? codeData.sessionId : null; - - if (existingSessionId) { - const sessionSnap = await getDoc( - doc(sessionsCollection(), existingSessionId), - ); - if ( - isReclaimableSessionForCode( - sessionSnap.exists() - ? (sessionSnap.data() as Record) - : null, - ) - ) { - await deleteDoc(sessionCodeDoc(code)); - break; - } - } else { + try { + // Rules allow delete only for host, missing session, or ended session. await deleteDoc(sessionCodeDoc(code)); break; + } catch { + code = generateSessionCode(); + attempts += 1; } - - code = generateSessionCode(); - attempts += 1; } const sessionRef = doc(sessionsCollection()); From 420cec4827efeeba75ffce8769295e3000984d7c Mon Sep 17 00:00:00 2001 From: gelbh Date: Fri, 24 Jul 2026 22:13:30 +0100 Subject: [PATCH 3/3] test(session): expect missing code after endRemoteSession delete --- src/services/firestore/firestoreAnnotations.emulator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/firestore/firestoreAnnotations.emulator.test.ts b/src/services/firestore/firestoreAnnotations.emulator.test.ts index e55843143..d6bb80edf 100644 --- a/src/services/firestore/firestoreAnnotations.emulator.test.ts +++ b/src/services/firestore/firestoreAnnotations.emulator.test.ts @@ -135,14 +135,14 @@ describe("firestoreAnnotations emulator", () => { unsubscribe(); }); - it("marks ended sessions as ended on lookup", async () => { + it("deletes join code when ending a session", async () => { const { uid } = await connectEmulatorsForTests(); const session = await createRemoteSession(DUBLIN_CITY_GAME_AREA, uid); const sessionCode = session.code; await endRemoteSession(session.id); const lookup = await lookupRemoteSessionByCode(sessionCode); - expect(lookup.status).toBe("ended"); + expect(lookup.status).toBe("missing"); const fetched = await getRemoteSessionById(session.id); expect(fetched?.endedAt).toBeTruthy();