fix: soft-reset UX + stale-cookie handling + leaderboard 'is_you' by id

Three coupled fixes from the first manual test pass:

1. Stale signed cookie no longer 500s. `rooms.me()` now raises KeyError
   when the participant row is gone (the previous code deref'd None and
   threw TypeError, caught by quiz.js's catch-all as 'link expired').
   `/api/session/{sid}/me` translates KeyError into 401 + delete_cookie,
   so the client falls back to the join form cleanly.
   Returning a JSONResponse directly because `raise HTTPException`
   discards Response.delete_cookie mutations (FastAPI middleware
   composes a fresh response on exception).

2. Reset is now a soft restart from the student's perspective. Before
   closing each student WS in `RoomManager.reset`, the server now sends
   a `{"type": "session_reset"}` message. The student SPA tears down
   local state and re-runs boot(); /me returns 401 (now that the
   participant is gone) and the join form renders without the user
   having to manually reload. The WS close handler suppresses its
   "Disconnected" screen during a reset to avoid a flash.

3. "You" highlight on the student leaderboard is now matched by id, not
   by name. `RoomManager.leaderboard()` accepts an optional
   `you_student_id` and stamps `is_you: true` on the matching entry only.
   No other students' ids leak over the wire (we still don't include
   `student_id` in the public top5 payload). quiz.js's renderBoard
   prefers `r.is_you` when any row is marked, falling back to name match
   for backward compatibility.

41/41 tests pass. Two new tests cover (a) the 401 + cookie-clear path
after reset and (b) `is_you` marking only the requesting student.
This commit is contained in:
ameer
2026-05-02 22:40:52 +08:00
parent b40f05220c
commit cfbda260fa
4 changed files with 121 additions and 13 deletions

View File

@@ -6,7 +6,7 @@ from pathlib import Path
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from app import auth
from app.config import Settings
@@ -70,7 +70,17 @@ def router(settings: Settings, rooms: RoomManager) -> APIRouter:
identity = auth.get_student_identity(settings, request, sid)
if not identity:
raise HTTPException(status_code=401, detail="Student cookie required")
return await rooms.me(sid, identity["student_id"])
try:
return await rooms.me(sid, identity["student_id"])
except KeyError:
# Cookie's student_id is no longer in the DB (e.g. session reset
# or DB rebuilt while the cookie persisted). Send 401 with the
# cookie cleared so the client renders the join form. We build
# the JSONResponse directly because raising HTTPException would
# bypass the cookie mutation.
resp = JSONResponse({"detail": "Re-join required"}, status_code=401)
resp.delete_cookie(auth.STUDENT_COOKIE, path="/")
return resp
@api.get("/api/session/{sid}/stats")
async def stats(sid: str, request: Request, question_idx: int | None = None):