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
+60
View File
@@ -19,6 +19,66 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
---
## 0.8.0.12 — 2026-09-16
A player who has lost their browser storage can be put back in their seat (Gitea#33). No rule
changed: `git diff v0.8.0.11..v0.8.0.12 -- src/engine/` is empty, so games in progress resume.
### The failure this fixes, and the four things it was not
Reported from the table after the 0.8.0.11 update: of two humans in one game, the host reloaded
straight back into it and the player who had JOINED found an empty lobby — no join secret, no display
name, no game code. Their seat was never lost. `sessions.json` for that game held both seats, and the
server logged it resuming with 80 intents replayed.
Four explanations were ruled out with evidence before any code was written, and two of them were
theories of mine that had to be retracted:
- **Not the update.** `git diff v0.8.0.10..v0.8.0.11 -- src/web/` contains no storage change at all;
both tags declare identical `SECRET_KEY` and `NAME_KEY`.
- **Not a create-vs-join asymmetry in the client.** `lb-secret` and `lb-name` sit above both doors in
`play.html`, so a joiner writes the same three keys a host does.
- **Not the server forgetting joiners.** Create calls `persistSession` and so does join; it writes
every session for the game. Two-seat session files plainly work.
- **Not a second origin.** Both players used the identical URL.
What is left is the thing §1 has always said: the token lives in one browser's `localStorage`, scoped
to the origin. A cleared profile, a private window or a different browser ends the seat while the game
runs on without it. Nothing in the client can detect that — origin isolation is the point — and
nothing in it can repair it either.
### A recovery link carries a code, never the token
`lobby-and-sessions.md` §1: *"Keep it out of URLs so it is not shoulder-surfed or pasted into a
chat."* A recovery link is precisely what gets pasted into a chat, so the URL carries a **single-use
code that expires in 30 minutes** and the page trades it for the real token over a POST, then strips
it from the address bar. A spent code is worth nothing; a token in a chat log is the seat for the rest
of the game.
```
POST /api/games/<id>/claim { player } → { code, expiresAt, … } admin secret
POST /api/claim { code } → { token, gameId, player, gameCode }
```
**Minting is administrative; spending is not.** Deciding that a particular person has lost a
particular seat is a judgement no route can make safely — anyone able to mint their own code could
take any chair at the table. Spending needs no secret because the player following the link is the one
person in the story who holds none: the code *is* the authorisation, unguessable and one-time, which
is the same shape as the token it returns.
`server/claims.ts` is a pure store — no clock, no sockets, no disk — so its rules are actually tested
rather than asserted: single use, lazy expiry, and one identical answer for unknown, spent and expired
codes so it cannot be probed. The codes are held in memory on purpose. They are minted on demand and
spent within minutes with the administrator present, so a restart dropping them is the right failure;
persisting them would put a credential-equivalent on disk to solve a problem measured in seconds.
### The administrator picks a seat, not a string
The admin game listing now reports `seatedPlayers` — the seats a HUMAN holds a token for, read from
the server's session map rather than guessed by matching "Bot 1" against a display name. That is what
lets the StartOS side offer real players to choose from instead of chairs no token was ever issued
for.
## 0.8.0.11 — 2026-09-16
Fourteen reports from the second multiplayer playtest of v0.8.0.10, the WHISTLE-6945 table. Eleven are