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:
Jesse.Markowitz
2026-09-16 20:16:24 -04:00
co-authored by Claude Opus 5
parent 9a9e50b3c6
commit 7c9ef8797d
9 changed files with 411 additions and 5 deletions
+58 -1
View File
@@ -27,7 +27,7 @@ import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts';
import { actorOnScreen, createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.ts';
import { notice, prefillCode, runLobby } from './lobby.ts';
import { notice, postJson, prefillCode, runLobby } from './lobby.ts';
import type { LobbyReady } from './lobby.ts';
import {
closestPreset,
@@ -1126,9 +1126,66 @@ function abandonRemote(): void {
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the
* common case and the only one a bare page load has ever needed a decision for), or the lobby.
*/
/**
* A SEAT RECOVERY LINK — Gitea#33.
*
* The token is the only identity this game has, and it lives in one browser's `localStorage`. Lose
* that and the seat is unreachable: nothing else on the server will accept a claim to it. This is the
* supported way back — an administrator mints a short-lived, single-use code (`server/claims.ts`) and
* the player opens a link carrying it.
*
* THE LINK CARRIES A CODE, NEVER THE TOKEN. `lobby-and-sessions.md` §1 says to keep the token out of
* URLs so it is not shoulder-surfed or pasted into a chat — and a recovery link is precisely the sort
* of thing that ends up in a chat. So the code is traded for the token here, over the connection the
* page was going to open anyway, and is dead the moment it is spent.
*
* THE CODE IS STRIPPED FROM THE URL EITHER WAY, so a reload does not re-spend a code that is already
* gone and the address bar stops carrying a credential-shaped string. `replaceState` rather than
* assigning `location.search`, which everywhere else on this page means "navigate" — it reloads, and
* reloading is exactly what must not happen to the session we have just been handed. Guarded like
* `requestAnimationFrame` and `performance` are, because the static build is imported head-first by
* `test/web.test.ts` against a DOM stub that provides neither.
*/
async function claimSeat(code: string): Promise<void> {
showScreen('lobby');
const { status, body } = await postJson('/api/claim', { code });
if (typeof history !== 'undefined' && typeof history.replaceState === 'function') {
history.replaceState(null, '', location.pathname);
}
if (status !== 200) {
runLobby(lobbyHandlers);
notice(
'That restore link has already been used, or it has expired. Ask whoever runs the server for a ' +
'fresh one — each link works once.',
);
return;
}
// `beginRemote` writes the seat into this browser's storage itself, which is the whole point of
// the exercise: the next ordinary reload finds it and goes straight back into the game.
beginRemote(
{
token: body['token'] as string,
gameId: body['gameId'] as string,
gameCode: (body['gameCode'] as string | undefined) ?? '',
seat: body['player'] as PlayerIndex,
},
true,
);
}
function start(): void {
const params = new URLSearchParams(location.search);
/**
* A RECOVERY LINK OUTRANKS EVERYTHING, including a game this browser already remembers: someone
* arriving on one is being handed a seat deliberately, and that is never the load to second-guess.
*/
const claimCode = params.get('claim');
if (claimCode !== null && claimCode !== '') {
void claimSeat(claimCode);
return;
}
/**
* ASKING FOR THE LOBBY BEATS RESUMING A GAME.
*