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.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(); 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..22ba59873 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,8 +273,14 @@ export async function createRemoteSession( break; } - code = generateSessionCode(); - attempts += 1; + try { + // Rules allow delete only for host, missing session, or ended session. + await deleteDoc(sessionCodeDoc(code)); + break; + } catch { + code = generateSessionCode(); + attempts += 1; + } } const sessionRef = doc(sessionsCollection()); @@ -744,9 +764,7 @@ export async function endRemoteSession(sessionId: string): Promise { }); if (session?.code) { - await updateDoc(sessionCodeDoc(session.code), { - status: "ended", - }); + await deleteDoc(sessionCodeDoc(session.code)); } }