Compare commits

...

3 commits

Author SHA1 Message Date
lila
0118798e36 feat: guest play — allow singleplayer quiz without auth
- Add optionalAuth middleware: attaches session when present,
  never blocks (guests pass through)
- Make game endpoints (start/answer) accept optional auth
- GameSessionStore.userId: string → string | null
- Rate limiter falls back to IP for unauthenticated users
- Frontend: remove /play route guard, show 'Create account' CTA
  on score screen for guests
- Add tests for guest session creation, answer submission,
  and cross-user session isolation
2026-05-31 21:28:08 +02:00
lila
d55a1ed648 wip 2026-05-30 03:47:59 +02:00
lila
37f6a55798 updating docs 2026-05-30 03:47:52 +02:00
15 changed files with 495 additions and 42 deletions

1
.gitignore vendored
View file

@ -19,3 +19,4 @@ data-pipeline/stage-4-merge/output/
data-pipeline/db/pipeline.db data-pipeline/db/pipeline.db
data-pipeline/reports/ data-pipeline/reports/
data-pipeline/.env data-pipeline/.env
.aider*

View file

@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest"; import request from "supertest";
import type { GameSession, AnswerResult } from "@lila/shared"; import type { GameSession, AnswerResult } from "@lila/shared";
import { auth } from "../lib/auth.js";
type SuccessResponse<T> = { success: true; data: T }; type SuccessResponse<T> = { success: true; data: T };
type ErrorResponse = { success: false; error: string }; type ErrorResponse = { success: false; error: string };
@ -48,6 +49,36 @@ vi.mock("better-auth/node", () => ({
toNodeHandler: vi.fn().mockReturnValue(vi.fn()), toNodeHandler: vi.fn().mockReturnValue(vi.fn()),
})); }));
const mockGetSession = vi.mocked(auth.api.getSession);
function setAuthenticatedUser(userId: string) {
mockGetSession.mockResolvedValue({
session: {
id: "session-1",
userId,
token: "fake-token",
expiresAt: new Date(Date.now() + 1000 * 60 * 60),
createdAt: new Date(),
updatedAt: new Date(),
ipAddress: null,
userAgent: null,
},
user: {
id: userId,
name: "Test User",
email: "test@test.com",
emailVerified: false,
image: null,
createdAt: new Date(),
updatedAt: new Date(),
},
});
}
function setGuestUser() {
mockGetSession.mockResolvedValue(null);
}
import { getGameTerms, getDistractors } from "@lila/db"; import { getGameTerms, getDistractors } from "@lila/db";
import { createApp } from "../app.js"; import { createApp } from "../app.js";
@ -76,6 +107,7 @@ const fakeTerms = [
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
setAuthenticatedUser("user-1"); // default: authenticated
mockGetGameTerms.mockResolvedValue(fakeTerms); mockGetGameTerms.mockResolvedValue(fakeTerms);
mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]);
}); });
@ -221,3 +253,87 @@ describe("POST /api/v1/game/answer", () => {
expect(body.success).toBe(false); expect(body.success).toBe(false);
}); });
}); });
describe("POST /api/v1/game/start — guest", () => {
beforeEach(() => {
setGuestUser();
});
it("returns 200 for a guest with valid game settings", async () => {
const res = await request(app).post("/api/v1/game/start").send(validBody);
const body = res.body as GameStartResponse;
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.sessionId).toBeDefined();
expect(body.data.questions).toHaveLength(3);
});
it("creates a session without userId for guests", async () => {
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startBody.data;
// Guest can answer — no 404 from userId mismatch
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: questions[0]!.questionId,
selectedOptionId: 0,
});
expect(res.status).toBe(200);
});
});
describe("POST /api/v1/game/answer — guest", () => {
beforeEach(() => {
setGuestUser();
});
it("allows a guest to submit an answer", async () => {
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startBody.data;
const question = questions[0]!;
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: question.questionId,
selectedOptionId: 0,
});
const body = res.body as GameAnswerResponse;
expect(res.status).toBe(200);
expect(body.success).toBe(true);
expect(body.data.questionId).toBe(question.questionId);
});
it("returns 404 when a guest tries to answer an authenticated user's session", async () => {
// First: create session as authenticated user
setAuthenticatedUser("user-1");
const startRes = await request(app)
.post("/api/v1/game/start")
.send(validBody);
const startBody = startRes.body as GameStartResponse;
const { sessionId, questions } = startBody.data;
// Then: try to answer as guest
setGuestUser();
const res = await request(app)
.post("/api/v1/game/answer")
.send({
sessionId,
questionId: questions[0]!.questionId,
selectedOptionId: 0,
});
const body = res.body as ErrorResponse;
expect(res.status).toBe(404);
expect(body.success).toBe(false);
expect(body.error).toContain("Game session not found");
});
});

View file

@ -16,10 +16,11 @@ export const createGameController = (store: GameSessionStore) => ({
if (!gameSettings.success) { if (!gameSettings.success) {
throw new ValidationError("Invalid game settings"); throw new ValidationError("Invalid game settings");
} }
const userId = req.session?.user.id ?? null;
const gameQuestions = await createGameSession( const gameQuestions = await createGameSession(
gameSettings.data, gameSettings.data,
store, store,
req.session.user.id, userId,
); );
res.json({ success: true, data: gameQuestions }); res.json({ success: true, data: gameQuestions });
} catch (error) { } catch (error) {
@ -37,11 +38,8 @@ export const createGameController = (store: GameSessionStore) => ({
if (!submission.success) { if (!submission.success) {
throw new ValidationError("Invalid answer submission"); throw new ValidationError("Invalid answer submission");
} }
const result = await evaluateAnswer( const userId = req.session?.user.id ?? null;
submission.data, const result = await evaluateAnswer(submission.data, store, userId);
store,
req.session.user.id,
);
res.json({ success: true, data: result }); res.json({ success: true, data: result });
} catch (error) { } catch (error) {
next(error); next(error);

View file

@ -1,6 +1,6 @@
export type GameSessionData = { export type GameSessionData = {
answers: Map<string, { correctOptionId: number }>; answers: Map<string, { correctOptionId: number }>;
userId: string; userId: string | null;
}; };
export interface GameSessionStore { export interface GameSessionStore {

View file

@ -0,0 +1,103 @@
import express from "express";
import request from "supertest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, User } from "better-auth";
vi.mock("../lib/auth.js", () => ({ auth: { api: { getSession: vi.fn() } } }));
vi.mock("better-auth/node", () => ({
fromNodeHeaders: vi.fn().mockReturnValue({}),
}));
import { auth } from "../lib/auth.js";
import { requireAuth, optionalAuth } from "./authMiddleware.js";
const mockGetSession = vi.mocked(auth.api.getSession);
function createOptionalAuthApp() {
const app = express();
app.use(optionalAuth);
app.get("/test", (req, res) => {
res
.status(200)
.json({
hasSession: !!req.session,
userId: req.session?.user?.id ?? null,
});
});
return app;
}
describe("optionalAuth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows the request through when no session exists (guest)", async () => {
mockGetSession.mockResolvedValue(null);
const app = createOptionalAuthApp();
const res = await request(app).get("/test");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSession: false, userId: null });
});
it("attaches session to req when user is authenticated", async () => {
mockGetSession.mockResolvedValue({
session: { id: "session-1" } as Session,
user: { id: "user-1" } as User,
});
const app = createOptionalAuthApp();
const res = await request(app).get("/test");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSession: true, userId: "user-1" });
});
it("allows the request through even when getSession throws", async () => {
mockGetSession.mockRejectedValue(new Error("auth service down"));
const app = createOptionalAuthApp();
const res = await request(app).get("/test");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSession: false, userId: null });
});
});
describe("requireAuth", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns 401 when no session exists", async () => {
mockGetSession.mockResolvedValue(null);
const app = express();
app.use(requireAuth);
app.get("/test", (_req, res) => res.status(200).json({ ok: true }));
const res = await request(app).get("/test");
expect(res.status).toBe(401);
expect(res.body).toEqual({ success: false, error: "Unauthorized" });
});
it("allows the request through when session exists", async () => {
mockGetSession.mockResolvedValue({
session: { id: "session-1" } as Session,
user: { id: "user-1" } as User,
});
const app = express();
app.use(requireAuth);
app.get("/test", (req, res) => {
res.status(200).json({ userId: req.session?.user?.id });
});
const res = await request(app).get("/test");
expect(res.status).toBe(200);
expect(res.body).toEqual({ userId: "user-1" });
});
});

View file

@ -20,3 +20,23 @@ export const requireAuth = async (
next(); next();
}; };
export const optionalAuth = async (
req: Request,
_res: Response,
next: NextFunction,
) => {
try {
const session = await auth.api.getSession({
headers: fromNodeHeaders(req.headers),
});
if (session) {
req.session = session;
}
} catch (err) {
console.warn("Auth check failed, continuing as guest:", err);
}
next();
};

View file

@ -1,4 +1,4 @@
import rateLimit from "express-rate-limit"; import rateLimit, { ipKeyGenerator } from "express-rate-limit";
import type { Request } from "express"; import type { Request } from "express";
// TODO: When Valkey is wired up, swap the default in-memory store for // TODO: When Valkey is wired up, swap the default in-memory store for
@ -33,7 +33,8 @@ export const gameLimiter = rateLimit({
limit: 150, limit: 150,
standardHeaders: "draft-8", standardHeaders: "draft-8",
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req: Request) => req.session!.user.id, keyGenerator: (req: Request) =>
req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"),
message: { message: {
success: false, success: false,
error: "Too many requests, please try again later.", error: "Too many requests, please try again later.",
@ -45,7 +46,8 @@ export const lobbyLimiter = rateLimit({
limit: 20, limit: 20,
standardHeaders: "draft-8", standardHeaders: "draft-8",
legacyHeaders: false, legacyHeaders: false,
keyGenerator: (req: Request) => req.session!.user.id, keyGenerator: (req: Request) =>
req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"),
message: { message: {
success: false, success: false,
error: "Too many requests, please try again later.", error: "Too many requests, please try again later.",

View file

@ -1,7 +1,7 @@
import express from "express"; import express from "express";
import type { Router } from "express"; import type { Router } from "express";
import { createGameController } from "../controllers/gameController.js"; import { createGameController } from "../controllers/gameController.js";
import { requireAuth } from "../middleware/authMiddleware.js"; import { optionalAuth } from "../middleware/authMiddleware.js";
import { gameLimiter } from "../middleware/rateLimiters.js"; import { gameLimiter } from "../middleware/rateLimiters.js";
import type { GameSessionStore } from "../gameSessionStore/index.js"; import type { GameSessionStore } from "../gameSessionStore/index.js";
@ -9,7 +9,7 @@ export const createGameRouter = (store: GameSessionStore): Router => {
const router = express.Router(); const router = express.Router();
const controller = createGameController(store); const controller = createGameController(store);
router.use(requireAuth); router.use(optionalAuth);
router.use(gameLimiter); router.use(gameLimiter);
router.post("/start", controller.createGame as express.RequestHandler); router.post("/start", controller.createGame as express.RequestHandler);

View file

@ -332,3 +332,89 @@ describe("evaluateAnswer", () => {
).rejects.toMatchObject({ statusCode: 422 }); ).rejects.toMatchObject({ statusCode: 422 });
}); });
}); });
// Add to existing gameService.test.ts
describe("createGameSession — guest", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
store = new InMemoryGameSessionStore();
});
it("creates a session with userId null for guests", async () => {
const session = await createGameSession(validRequest, store, null);
expect(session.sessionId).toBeDefined();
expect(session.questions).toHaveLength(3);
});
it("stores userId as null in the session store", async () => {
const session = await createGameSession(validRequest, store, null);
const stored = await store.get(session.sessionId);
expect(stored).not.toBeNull();
expect(stored!.userId).toBeNull();
});
});
describe("evaluateAnswer — guest", () => {
let store: InMemoryGameSessionStore;
beforeEach(() => {
store = new InMemoryGameSessionStore();
});
it("allows a guest to answer their own session", async () => {
const session = await createGameSession(validRequest, store, null);
const question = session.questions[0]!;
const correctText = fakeTerms[0]!.targetText;
const correctOption = question.options.find((o) => o.text === correctText)!;
const result = await evaluateAnswer(
{
sessionId: session.sessionId,
questionId: question.questionId,
selectedOptionId: correctOption.optionId,
},
store,
null,
);
expect(result.isCorrect).toBe(true);
});
it("throws NotFoundError when guest tries to answer an authenticated session", async () => {
const authSession = await createGameSession(validRequest, store, "user-1");
const question = authSession.questions[0]!;
await expect(
evaluateAnswer(
{
sessionId: authSession.sessionId,
questionId: question.questionId,
selectedOptionId: 0,
},
store,
null,
),
).rejects.toThrow("Game session not found");
});
it("throws NotFoundError when authenticated user tries to answer a guest session", async () => {
const guestSession = await createGameSession(validRequest, store, null);
const question = guestSession.questions[0]!;
await expect(
evaluateAnswer(
{
sessionId: guestSession.sessionId,
questionId: question.questionId,
selectedOptionId: 0,
},
store,
"user-1",
),
).rejects.toThrow("Game session not found");
});
});

View file

@ -19,7 +19,7 @@ import { shuffleArray } from "../lib/utils.js";
export const createGameSession = async ( export const createGameSession = async (
request: GameRequest, request: GameRequest,
store: GameSessionStore, store: GameSessionStore,
userId: string, userId: string | null,
): Promise<GameSession> => { ): Promise<GameSession> => {
const terms = await getGameTerms( const terms = await getGameTerms(
request.source_language, request.source_language,
@ -87,11 +87,15 @@ export const createGameSession = async (
export const evaluateAnswer = async ( export const evaluateAnswer = async (
submission: AnswerSubmission, submission: AnswerSubmission,
store: GameSessionStore, store: GameSessionStore,
userId: string, userId: string | null,
): Promise<AnswerResult> => { ): Promise<AnswerResult> => {
const session = await store.get(submission.sessionId); const session = await store.get(submission.sessionId);
if (!session || session.userId !== userId) { if (!session) {
throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
}
if (session.userId !== userId) {
throw new NotFoundError(`Game session not found: ${submission.sessionId}`); throw new NotFoundError(`Game session not found: ${submission.sessionId}`);
} }

View file

@ -18,3 +18,7 @@ declare module "ws" {
export type AuthenticatedRequest = Request & { export type AuthenticatedRequest = Request & {
session: { session: Session; user: User }; session: { session: Session; user: User };
}; };
export type GuestRequest = Request & {
session?: { session: Session; user: User };
};

View file

@ -1,9 +1,17 @@
import type { AnswerResult } from "@lila/shared"; import type { AnswerResult } from "@lila/shared";
import { ConfettiBurst } from "../ui/ConfettiBurst"; import { ConfettiBurst } from "../ui/ConfettiBurst";
type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void }; type ScoreScreenProps = {
results: AnswerResult[];
onPlayAgain: () => void;
showAuthPrompt?: boolean;
};
export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { export const ScoreScreen = ({
results,
onPlayAgain,
showAuthPrompt = false,
}: ScoreScreenProps) => {
const score = results.filter((r) => r.isCorrect).length; const score = results.filter((r) => r.isCorrect).length;
const total = results.length; const total = results.length;
const percentage = Math.round((score / total) * 100); const percentage = Math.round((score / total) * 100);
@ -58,12 +66,34 @@ export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => {
))} ))}
</div> </div>
<button {showAuthPrompt && (
onClick={onPlayAgain} <div className="w-full rounded-2xl border border-(--color-primary-light) bg-white/60 dark:bg-black/10 backdrop-blur p-6 text-center">
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer" <p className="text-sm text-(--color-text-muted) mb-3">
> Want to save your progress and compete with friends?
Play again </p>
</button> <a
href="/?modal=auth&redirect=/play"
className="inline-block w-full py-3 rounded-xl text-base font-bold bg-linear-to-r from-pink-400 to-purple-500 text-white shadow-sm hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200"
>
Create an account
</a>
<button
onClick={onPlayAgain}
className="mt-3 text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors cursor-pointer"
>
Continue as guest
</button>
</div>
)}
{!showAuthPrompt && (
<button
onClick={onPlayAgain}
className="w-full py-3 px-10 rounded-2xl text-lg font-black bg-(--color-primary) text-white shadow-sm hover:shadow-md hover:bg-(--color-primary-dark) hover:-translate-y-0.5 active:translate-y-0 transition-all duration-200 cursor-pointer"
>
Play again
</button>
)}
</div> </div>
); );
}; };

View file

@ -1,5 +1,5 @@
import { createFileRoute, redirect } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useState, useCallback } from "react"; import { useState, useCallback, useEffect } from "react";
import type { GameSession, GameRequest, AnswerResult } from "@lila/shared"; import type { GameSession, GameRequest, AnswerResult } from "@lila/shared";
import { QuestionCard } from "../components/game/QuestionCard"; import { QuestionCard } from "../components/game/QuestionCard";
import { ScoreScreen } from "../components/game/ScoreScreen"; import { ScoreScreen } from "../components/game/ScoreScreen";
@ -18,6 +18,13 @@ function Play() {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [results, setResults] = useState<AnswerResult[]>([]); const [results, setResults] = useState<AnswerResult[]>([]);
const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null); const [currentResult, setCurrentResult] = useState<AnswerResult | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
void authClient.getSession().then(({ data }) => {
setIsAuthenticated(!!data);
});
}, []);
const startGame = useCallback(async (settings: GameRequest) => { const startGame = useCallback(async (settings: GameRequest) => {
setIsLoading(true); setIsLoading(true);
@ -100,7 +107,11 @@ function Play() {
if (currentQuestionIndex >= gameSession.questions.length) { if (currentQuestionIndex >= gameSession.questions.length) {
return ( return (
<div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6"> <div className="min-h-screen bg-linear-to-b from-purple-100 to-pink-50 flex items-center justify-center p-6">
<ScoreScreen results={results} onPlayAgain={resetToSetup} /> <ScoreScreen
results={results}
onPlayAgain={resetToSetup}
showAuthPrompt={isAuthenticated === false}
/>
</div> </div>
); );
} }
@ -129,10 +140,4 @@ function Play() {
export const Route = createFileRoute("/play")({ export const Route = createFileRoute("/play")({
component: Play, component: Play,
errorComponent: RouteError, errorComponent: RouteError,
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
}
},
}); });

View file

@ -34,6 +34,7 @@ Example: "Implement guest play flow so users can try a 3-round quiz without crea
[List files you've identified. The LLM may ask for additional ones.] [List files you've identified. The LLM may ask for additional ones.]
Example: Example:
- `apps/api/src/controllers/gameController.ts` — needs guest variant - `apps/api/src/controllers/gameController.ts` — needs guest variant
- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path - `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path
- `packages/shared/src/schemas/game.ts` — needs GuestGameRequestSchema - `packages/shared/src/schemas/game.ts` — needs GuestGameRequestSchema
@ -43,20 +44,24 @@ Example:
## Constraints & Requirements ## Constraints & Requirements
**Must have:** **Must have:**
- [ ] - [ ]
- [ ] - [ ]
**Nice to have:** **Nice to have:**
- [ ] - [ ]
- [ ] - [ ]
**Must NOT break:** **Must NOT break:**
- [ ] Existing auth flow (logged-in users still work normally) - [ ] Existing auth flow (logged-in users still work normally)
- [ ] WebSocket protocol (if applicable) - [ ] WebSocket protocol (if applicable)
- [ ] Database schema (additive changes only unless migration planned) - [ ] Database schema (additive changes only unless migration planned)
- [ ] Zod schemas in `packages/shared` (no silent drift) - [ ] Zod schemas in `packages/shared` (no silent drift)
**Known blockers or open questions:** **Known blockers or open questions:**
- [ ] - [ ]
--- ---
@ -80,17 +85,18 @@ After the task is complete, ask the LLM:
The LLM should check: The LLM should check:
| File | Check if... | | File | Check if... |
|------|-------------| | ---------------------------------- | ----------------------------------------------------------------------- |
| `documentation/STATUS.md` | Task changes what's working or what's blocked | | `documentation/STATUS.md` | Task changes what's working or what's blocked |
| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one | | `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one |
| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences | | `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences |
| `documentation/ARCHITECTURE.md` | Task changes monorepo structure, data flow, or layer boundaries | | `documentation/ARCHITECTURE.md` | Task changes monorepo structure, data flow, or layer boundaries |
| `documentation/ai-context/*.md` | Task changes schemas, endpoints, protocol, or pipeline stages | | `documentation/ai-context/*.md` | Task changes schemas, endpoints, protocol, or pipeline stages |
| `packages/shared/src/schemas/*.ts` | Task changes request/response shapes or WS message types | | `packages/shared/src/schemas/*.ts` | Task changes request/response shapes or WS message types |
| `README.md` | Task changes quickstart steps, stack, or current status | | `README.md` | Task changes quickstart steps, stack, or current status |
**Expected output format:** **Expected output format:**
``` ```
- FILE: [filename] — REASON: [what changed and why the doc needs updating] - FILE: [filename] — REASON: [what changed and why the doc needs updating]
``` ```

View file

@ -0,0 +1,78 @@
# 99 — Current Task
## Task Description
Implement guest play flow so users can try a 3-round singleplayer quiz without creating an account. After completing the quiz, optionally prompt them to sign up to save progress.
## Context
**Which parts of the codebase does this touch?**
- [x] Frontend (`apps/web/`)
- [x] Backend API (`apps/api/`)
- [ ] Database schema (`packages/db/`) — no schema changes needed
- [x] Shared schemas (`packages/shared/`) — may need GuestGameRequestSchema
- [ ] WebSocket protocol (`apps/api/src/ws/`) — not touched (guest play is singleplayer only)
- [ ] Data pipeline (`data-pipeline/`)
- [ ] Infrastructure / deployment (`docker-compose.yml`, Caddyfile, etc.)
- [x] Documentation
**Relevant files I already know about:**
- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path
- `apps/api/src/controllers/gameController.ts` — needs guest variant of start/answer
- `apps/api/src/services/gameService.ts` — may need guest session logic
- `apps/api/src/routes/gameRouter.ts` — route definitions
- `apps/web/src/routes/play.tsx` — singleplayer route
- `apps/web/src/components/game/GameSetup.tsx` — start quiz UI
- `packages/shared/src/schemas/game.ts` — request/response schemas
- `apps/web/src/components/auth/AuthModal.tsx` — post-game auth prompt
## Constraints & Requirements
**Must have:**
- [ ] Guest users can start and complete a singleplayer quiz (3 or 10 rounds)
- [ ] No login required to reach `/play` or call `POST /api/v1/game/start`
- [ ] Server-side answer evaluation still works (correct answer never sent to frontend)
- [ ] Guest sessions are ephemeral (no database storage of guest progress)
- [ ] After quiz completion, show a friendly "Save your progress?" prompt with auth options
**Nice to have:**
- [ ] Guest sessions stored in-memory with a TTL (e.g., 24h) so refreshing the page doesn't lose the current quiz
- [ ] Post-game prompt includes a "Continue as guest" option to play again without signing up
**Must NOT break:**
- [x] Existing auth flow (logged-in users still work normally)
- [x] WebSocket protocol (if applicable)
- [x] Database schema (additive changes only unless migration planned)
- [x] Zod schemas in `packages/shared` (no silent drift)
**Known blockers or open questions:**
- [ ] Should guest sessions use the same `GameSessionStore` interface with a guest flag, or a separate store?
- [ ] Should the post-game auth prompt be a modal or a redirect to a dedicated page?
## Definition of Done
- [ ] Code implemented and tested
- [ ] No TypeScript errors (`pnpm typecheck` passes)
- [ ] Tests pass (`pnpm test`)
- [ ] Manual verification in dev environment (both logged-in and guest flows)
- [ ] Commit message follows convention
- [ ] Feature branch merged to main
## Post-Work Checklist
After the task is complete, ask the LLM:
&gt; "Review the post-work checklist in prompts/meta.md. Which documentation files need updates based on what we just changed?"
Expected doc updates:
- `documentation/STATUS.md` — Guest play is now live
- `documentation/ai-context/03-api-contract.md` — New guest endpoint or schema changes
- `packages/shared/src/schemas/game.ts` — If GuestGameRequestSchema added
- `README.md` — Quickstart may mention guest play