feat: add TTL to GameSessionStore, replay protection and session cleanup to evaluateAnswer
This commit is contained in:
parent
54705943fa
commit
fdeb769640
6 changed files with 218 additions and 7 deletions
|
|
@ -1,7 +1,11 @@
|
||||||
export type GameSessionData = { answers: Map<string, number> };
|
export type GameSessionData = { answers: Map<string, number> };
|
||||||
|
|
||||||
export interface GameSessionStore {
|
export interface GameSessionStore {
|
||||||
create(sessionId: string, data: GameSessionData): Promise<void>;
|
create(
|
||||||
|
sessionId: string,
|
||||||
|
data: GameSessionData,
|
||||||
|
ttlMs: number,
|
||||||
|
): Promise<void>;
|
||||||
get(sessionId: string): Promise<GameSessionData | null>;
|
get(sessionId: string): Promise<GameSessionData | null>;
|
||||||
delete(sessionId: string): Promise<void>;
|
delete(sessionId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,15 +1,27 @@
|
||||||
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
import type { GameSessionStore, GameSessionData } from "./GameSessionStore.js";
|
||||||
|
|
||||||
export class InMemoryGameSessionStore implements GameSessionStore {
|
type SessionEntry = { data: GameSessionData; expiresAt: number };
|
||||||
private sessions = new Map<string, GameSessionData>();
|
|
||||||
|
|
||||||
create(sessionId: string, data: GameSessionData): Promise<void> {
|
export class InMemoryGameSessionStore implements GameSessionStore {
|
||||||
this.sessions.set(sessionId, data);
|
private sessions = new Map<string, SessionEntry>();
|
||||||
|
|
||||||
|
create(
|
||||||
|
sessionId: string,
|
||||||
|
data: GameSessionData,
|
||||||
|
ttlMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
this.sessions.set(sessionId, { data, expiresAt: Date.now() + ttlMs });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
get(sessionId: string): Promise<GameSessionData | null> {
|
get(sessionId: string): Promise<GameSessionData | null> {
|
||||||
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<void> {
|
delete(sessionId: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -219,4 +219,55 @@ describe("evaluateAnswer", () => {
|
||||||
"Question not found",
|
"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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ export const createGameSession = async (
|
||||||
);
|
);
|
||||||
|
|
||||||
const sessionId = randomUUID();
|
const sessionId = randomUUID();
|
||||||
await store.create(sessionId, { answers: answerKey });
|
await store.create(sessionId, { answers: answerKey }, 30 * 60 * 1000);
|
||||||
|
|
||||||
return { sessionId, questions };
|
return { sessionId, questions };
|
||||||
};
|
};
|
||||||
|
|
@ -80,6 +80,12 @@ export const evaluateAnswer = async (
|
||||||
throw new NotFoundError(`Question not found: ${submission.questionId}`);
|
throw new NotFoundError(`Question not found: ${submission.questionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
session.answers.delete(submission.questionId);
|
||||||
|
|
||||||
|
if (session.answers.size === 0) {
|
||||||
|
await store.delete(submission.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
questionId: submission.questionId,
|
questionId: submission.questionId,
|
||||||
isCorrect: submission.selectedOptionId === correctOptionId,
|
isCorrect: submission.selectedOptionId === correctOptionId,
|
||||||
|
|
|
||||||
93
documentation/tickets/t00005.md
Normal file
93
documentation/tickets/t00005.md
Normal file
|
|
@ -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<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
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));
|
||||||
|
```
|
||||||
Loading…
Add table
Add a link
Reference in a new issue