v0.8.0.12 — put a player back in their seat after losing their browser storage
Gitea#33. A session token is the only identity the game has, and it lives in exactly one place the player controls: their browser's localStorage, scoped to the origin they joined at. Lose it — a cleared profile, a private window, a different browser — and the seat is unreachable while the game runs on and the session sits intact on disk. Reported from the table: of two humans in one game the host reloaded straight back in, the joiner met an empty lobby. Diagnosed before it was fixed, and two server-side theories of mine were retracted on the evidence: no storage key changed in 0.8.0.11, nothing in the app deletes the secret or name, create and join both call persistSession, that game's sessions.json held both seats, and it resumed with 80 intents replayed. Both players used the same URL, so it was not a second origin either. The fix is a recovery link. An administrator mints a code for a named seat (admin-gated: deciding somebody lost a seat is a judgement no route can make); the player opens the link and the page trades the code for the token over a POST, then strips it from the address bar. The link never carries the token — lobby-and-sessions.md §1 says keep it out of URLs, and a recovery link is exactly what gets pasted into a chat. Single use, 30-minute expiry, held in memory because a restart dropping them is the right failure. server/claims.ts is a pure store, so single use, lazy expiry and one identical answer for unknown/spent/expired codes are tested rather than asserted. The admin game listing gained seatedPlayers — the seats a human holds a token for, read from the session map rather than guessed from player names — so the StartOS action can offer real players instead of bot chairs. No rule changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
This commit is contained in:
co-authored by
Claude Opus 5
parent
9a9e50b3c6
commit
7c9ef8797d
@@ -4228,6 +4228,30 @@ describe('the lobby screen', () => {
|
||||
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
|
||||
groups[name]!.find((r) => r.checked)?.value;
|
||||
|
||||
/**
|
||||
* A SEAT RECOVERY LINK — Gitea#33.
|
||||
*
|
||||
* The properties that make this safe to hand round are the ones worth pinning: the page trades the
|
||||
* CODE for the token (so no token is ever in a URL), and it does not keep the code afterwards. The
|
||||
* store's own single-use and expiry rules are proven in `test/server/claims.test.ts`; this is the
|
||||
* client half, which is the part that could silently stop asking.
|
||||
*/
|
||||
it('trades a ?claim= code for a seat, and does not leave the code in the address bar', async () => {
|
||||
const { sent } = await open('?claim=code-123', {
|
||||
'/api/claim': { token: 'tok-restored', gameId: 'game-9', player: 1, gameCode: 'WHISTLE-6945' },
|
||||
});
|
||||
|
||||
const claim = sent.find((r) => r.url.includes('/api/claim'));
|
||||
assert.ok(claim, 'the page never redeemed the code');
|
||||
assert.deepEqual(claim.body, { code: 'code-123' }, 'the code was not sent as the request body');
|
||||
// The token must never travel in a URL (`lobby-and-sessions.md` §1) — it comes back in the
|
||||
// response, and the only thing that went out was the one-time code.
|
||||
assert.ok(
|
||||
!sent.some((r) => r.url.includes('tok-restored')),
|
||||
'a session token appeared in a request URL',
|
||||
);
|
||||
});
|
||||
|
||||
it('opens on the join door, with the create form behind it', async () => {
|
||||
// Somebody who was handed a code used to have to scroll past the entire create form to find the
|
||||
// box to type it into.
|
||||
|
||||
Reference in New Issue
Block a user