diff --git a/apps/api/src/gameSessionStore/GameSessionStore.ts b/apps/api/src/gameSessionStore/GameSessionStore.ts index 766f26c..55a8bb8 100644 --- a/apps/api/src/gameSessionStore/GameSessionStore.ts +++ b/apps/api/src/gameSessionStore/GameSessionStore.ts @@ -1,7 +1,11 @@ export type GameSessionData = { answers: Map }; export interface GameSessionStore { - create(sessionId: string, data: GameSessionData): Promise; + create( + sessionId: string, + data: GameSessionData, + ttlMs: number, + ): Promise; get(sessionId: string): Promise; delete(sessionId: string): Promise; } diff --git a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts new file mode 100644 index 0000000..9d8f355 --- /dev/null +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { InMemoryGameSessionStore } from "./InMemoryGameSessionStore.js"; + +describe("InMemoryGameSessionStore", () => { + let store: InMemoryGameSessionStore; + + beforeEach(() => { + store = new InMemoryGameSessionStore(); + }); + + it("returns null for a non-existent session", async () => { + const result = await store.get("00000000-0000-0000-0000-000000000000"); + expect(result).toBeNull(); + }); + + it("returns session data after creation", async () => { + const data = { answers: new Map([["q1", 2]]) }; + await store.create("session-1", data, 60_000); + const result = await store.get("session-1"); + expect(result).toEqual(data); + }); + + it("returns null after the session is deleted", async () => { + const data = { answers: new Map([["q1", 2]]) }; + await store.create("session-1", data, 60_000); + await store.delete("session-1"); + const result = await store.get("session-1"); + expect(result).toBeNull(); + }); + + it("returns null after TTL expires", async () => { + const data = { answers: new Map([["q1", 2]]) }; + await store.create("session-1", data, 1); + await new Promise((resolve) => setTimeout(resolve, 10)); + const result = await store.get("session-1"); + expect(result).toBeNull(); + }); + + it("returns session data before TTL expires", async () => { + const data = { answers: new Map([["q1", 2]]) }; + await store.create("session-1", data, 60_000); + const result = await store.get("session-1"); + expect(result).not.toBeNull(); + }); +}); diff --git a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts index f29ca59..8ec2265 100644 --- a/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts +++ b/apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts @@ -1,15 +1,27 @@ import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js"; -export class InMemoryGameSessionStore implements GameSessionStore { - private sessions = new Map(); +type SessionEntry = { data: GameSessionData; expiresAt: number }; - create(sessionId: string, data: GameSessionData): Promise { - this.sessions.set(sessionId, data); +export class InMemoryGameSessionStore implements GameSessionStore { + private sessions = new Map(); + + create( + sessionId: string, + data: GameSessionData, + ttlMs: number, + ): Promise { + this.sessions.set(sessionId, { data, expiresAt: Date.now() + ttlMs }); return Promise.resolve(); } get(sessionId: string): Promise { - return Promise.resolve(this.sessions.get(sessionId) ?? null); + const entry = this.sessions.get(sessionId); + if (!entry) return Promise.resolve(null); + if (Date.now() > entry.expiresAt) { + this.sessions.delete(sessionId); + return Promise.resolve(null); + } + return Promise.resolve(entry.data); } delete(sessionId: string): Promise { diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 9299b06..48dfbcf 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -219,4 +219,55 @@ describe("evaluateAnswer", () => { "Question not found", ); }); + + it("throws NotFoundError when the same question is submitted twice", async () => { + const session = await createGameSession(validRequest, store); + const question = session.questions[0]!; + + await evaluateAnswer( + { + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + ); + + await expect( + evaluateAnswer( + { + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + ), + ).rejects.toThrow("Question not found"); + }); + + it("deletes the session after the last question is answered", async () => { + const session = await createGameSession(validRequest, store); + + for (const question of session.questions) { + await evaluateAnswer( + { + sessionId: session.sessionId, + questionId: question.questionId, + selectedOptionId: 0, + }, + store, + ); + } + + await expect( + evaluateAnswer( + { + sessionId: session.sessionId, + questionId: session.questions[0]!.questionId, + selectedOptionId: 0, + }, + store, + ), + ).rejects.toThrow("Game session not found"); + }); }); diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index 4611bec..4c6458f 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -59,7 +59,7 @@ export const createGameSession = async ( ); const sessionId = randomUUID(); - await store.create(sessionId, { answers: answerKey }); + await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000); return { sessionId, questions }; }; @@ -80,6 +80,12 @@ export const evaluateAnswer = async ( throw new NotFoundError(`Question not found: ${submission.questionId}`); } + session.answers.delete(submission.questionId); + + if (session.answers.size === 0) { + await store.delete(submission.sessionId); + } + return { questionId: submission.questionId, isCorrect: submission.selectedOptionId === correctOptionId, diff --git a/documentation/tickets/t00005.md b/documentation/tickets/t00005.md new file mode 100644 index 0000000..baf5e2b --- /dev/null +++ b/documentation/tickets/t00005.md @@ -0,0 +1,93 @@ +# ADR: Session lifecycle — TTL and replay protection + +## Status + +Accepted + +## Date + +2026-04-28 + +## Context + +`InMemoryGameSessionStore` had no TTL and no cleanup mechanism. Every session created stayed in memory until the process restarted. Additionally, `evaluateAnswer` never removed a question from the answer key after evaluating it, meaning the same question could be submitted multiple times and receive a valid result each time — a potential exploit in multiplayer and a correctness bug in singleplayer. + +## Decision + +Add a `ttlMs` parameter to `GameSessionStore.create()` so both the in-memory and future Valkey implementations handle expiry consistently. Delete questions from the answer key after evaluation. Delete the session when the last question is answered. + +## Options considered + +### Option A — Delete on last answer only + +Simple. Covers replay protection and normal session completion. Abandoned sessions (player starts game, never finishes) still leak memory. + +### Option B — Delete on last answer + TTL on the interface ✅ + +Delete on answer covers normal flow. TTL covers abandoned sessions. TTL on the interface means `ValKeyGameSessionStore` can use Redis-native `EXPIRE` without any interface changes during migration. + +Chosen because it closes the memory leak entirely and makes the Valkey migration a zero-interface-change operation. + +### Option C — TTL hardcoded inside InMemoryGameSessionStore only + +Simpler short-term. But the interface wouldn't carry the TTL parameter, so `ValKeyGameSessionStore` would need a different mechanism — inconsistency between implementations. + +## Consequences + +- Sessions expire after 30 minutes of inactivity regardless of completion state +- Submitting the same question twice throws `NotFoundError` on the second attempt +- Sessions are deleted automatically when the last question is answered +- `GameSessionStore.create()` now requires a `ttlMs` argument — any future implementation must honour it +- `ValKeyGameSessionStore` can implement TTL via Redis `EXPIRE` with no interface changes +- `InMemoryGameSessionStore` stores `{ data, expiresAt }` entries instead of raw `GameSessionData` — expiry is checked lazily on `get()` + +## Affected files + +- `apps/api/src/gameSessionStore/GameSessionStore.ts` — `ttlMs` added to `create` +- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.ts` — TTL implementation +- `apps/api/src/gameSessionStore/InMemoryGameSessionStore.test.ts` — new test file +- `apps/api/src/services/gameService.ts` — passes TTL to `store.create`, deletes question after evaluation, deletes session when empty +- `apps/api/src/services/gameService.test.ts` — replay protection and session cleanup tests added + +## References + +- [Redis EXPIRE command](https://redis.io/commands/expire/) + +--- + +## Setup guide / implementation notes + +1. `GameSessionStore.ts` — add `ttlMs` to `create`: + + ```ts + create(sessionId: string, data: GameSessionData, ttlMs: number): Promise; + ``` + +2. `InMemoryGameSessionStore.ts` — wrap stored data with expiry: + + ```ts + type SessionEntry = { data: GameSessionData; expiresAt: number }; + ``` + + Check expiry on `get()`, delete expired entries lazily. + +3. `gameService.ts` — pass TTL when creating session: + + ```ts + await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000); + ``` + + After evaluating an answer: + + ```ts + session.answers.delete(submission.questionId); + if (session.answers.size === 0) { + await store.delete(submission.sessionId); + } + ``` + +4. When implementing `ValKeyGameSessionStore`, pass `ttlMs` to Redis `EXPIRE`: + + ```ts + await valkey.set(sessionId, serialize(data), "EX", Math.ceil(ttlMs / 1000)); + ```