diff --git a/CHANGELOG.md b/CHANGELOG.md index 5911e53..8951016 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,66 @@ page as `v0.1.0 · · `, 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//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 diff --git a/docs/architecture/lobby-and-sessions.md b/docs/architecture/lobby-and-sessions.md index a5d9835..f5be297 100644 --- a/docs/architecture/lobby-and-sessions.md +++ b/docs/architecture/lobby-and-sessions.md @@ -36,6 +36,34 @@ and `:` are both expected — and browser storage is scoped to the ori at one address must come back to that address, or they are a stranger with no token. Say so in the UI at join time rather than letting someone discover it when they cannot get back in. +**A lost token is recoverable, administratively** (Gitea#33). Everything above makes the token the +single point of failure: it lives in one browser's storage, and a cleared profile, a private window or +a different browser ends the seat with the game still running and the session still on disk. Seen at a +real table — the returning player met an empty lobby while their token sat intact in `sessions.json`, +and the only way back was an administrator reading the file off the volume and the player pasting it +into a devtools console. + +So there is a supported path, in two halves that are gated differently on purpose: + +``` +POST /api/games//claim { player } → { code, expiresAt, … } admin secret +POST /api/claim { code } → { token, gameId, player, gameCode } +``` + +**The link carries the code, never the token** — which is the rule three paragraphs up, applied. A +recovery link is exactly the sort of thing that gets pasted into a chat, so what travels in the URL is +single-use and expires in thirty minutes (`server/claims.ts`), and the page trades it for the real +token over a POST as it loads (`?claim=` in `web/main.ts`, which strips it from the address bar either +way). A leaked code is worthless once spent; a leaked token is the seat for the rest of the game. + +**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, and it is the same shape +(unguessable, one-time) as the token it hands back. The codes are held in memory: they are minted on +demand and spent within minutes, so a restart dropping them is the right failure, and persisting them +would put a credential-equivalent on the volume to solve a problem measured in seconds. + Real accounts can be layered on later without touching the rules engine, which is exactly why [`overview.md`](overview.md) keeps that boundary sharp. diff --git a/package.json b/package.json index fbba05d..50d2f77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "station-master", - "version": "0.8.0.11", + "version": "0.8.0.12", "private": true, "type": "module", "description": "Station Master — a railroad operations game", diff --git a/src/server/claims.ts b/src/server/claims.ts new file mode 100644 index 0000000..06b918e --- /dev/null +++ b/src/server/claims.ts @@ -0,0 +1,83 @@ +/** + * SEAT RECOVERY CODES — Gitea#33. + * + * A session token is the only identity the game has (`lobby-and-sessions.md` §1) and it lives in + * exactly one place the player controls: their browser's `localStorage`, scoped to the origin they + * joined at. Lose that — a different browser, a cleared profile, a private window — and the seat is + * unreachable, because there is nothing else on the server that will accept a claim to it. Seen at a + * real table on 2026-09-16: the joining player came back to an empty lobby while their token sat + * intact in `sessions.json`, and the only way in was an administrator reading the file off the data + * volume and the player pasting it into a devtools console. + * + * THE CODE IS NOT THE TOKEN, AND THAT IS THE WHOLE POINT. §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 exactly the kind of thing + * that gets pasted into a chat. So an administrator mints a SHORT-LIVED, SINGLE-USE code, the player + * opens a link carrying that, and the page trades it for the real token over the same connection it + * would have used anyway. A code that leaks after it is spent is worth nothing; a token that leaks is + * worth the seat for the rest of the game. + * + * PURE ON PURPOSE, like `lobby.ts` beside it: no sockets, no filesystem, no clock of its own. `now` + * is passed in so expiry is testable without faking timers, which is the only reason this file can be + * tested at all — nothing in this repo stands an HTTP server up to make requests against it. + * + * IN MEMORY, NOT ON DISK, which is a deliberate limit rather than an oversight. A restart drops every + * outstanding code, and that is the right failure: the codes are minted on demand and spent within + * minutes, the administrator is by definition present, and persisting them would put a credential- + * equivalent on the volume to solve a problem measured in seconds. + */ + +import { randomUUID } from 'node:crypto'; + +/** + * Long enough to walk to the other room and read it out; short enough that a link left in a chat + * window is useless by the time anyone scrolls back to it. + */ +export const CLAIM_TTL_MS = 30 * 60 * 1000; + +export type ClaimStore = { + /** Mint a code for one seat's token. Returns the code and when it stops working. */ + mint(token: string, gameId: string, now: number, ttlMs?: number): { code: string; expiresAt: number }; + /** + * Spend a code. Returns the seat it names, or null when the code is unknown, already spent or + * expired — deliberately one answer for all three, so a caller cannot probe which it was. + */ + redeem(code: string, now: number): { token: string; gameId: string } | null; + /** Outstanding, unexpired codes. For tests and for anything that wants to report the store's size. */ + outstanding(now: number): number; +}; + +export function createClaimStore(): ClaimStore { + const claims = new Map(); + + /** Expiry is lazy: there is no timer to own, start, stop or leak across a server's lifetime. */ + const prune = (now: number): void => { + for (const [code, claim] of claims) if (claim.expiresAt <= now) claims.delete(code); + }; + + return { + mint(token, gameId, now, ttlMs = CLAIM_TTL_MS) { + prune(now); + // The same primitive the session tokens themselves use (`lobby.ts`), for the same reason: it + // has to be unguessable, and inventing a second scheme here would be inventing a weaker one. + const code = randomUUID(); + const expiresAt = now + ttlMs; + claims.set(code, { token, gameId, expiresAt }); + return { code, expiresAt }; + }, + + redeem(code, now) { + prune(now); + const claim = claims.get(code); + if (!claim) return null; + // SINGLE USE. Deleted before the caller can do anything with it, so two browsers racing on the + // same link cannot both be seated — and a link that stays in someone's history is spent. + claims.delete(code); + return { token: claim.token, gameId: claim.gameId }; + }, + + outstanding(now) { + prune(now); + return claims.size; + }, + }; +} diff --git a/src/server/http.ts b/src/server/http.ts index 807cb38..895a10c 100644 --- a/src/server/http.ts +++ b/src/server/http.ts @@ -37,6 +37,7 @@ import { writeLobby, writeSessions, } from './persistence.ts'; +import { createClaimStore } from './claims.ts'; import { createSession } from './session.ts'; import type { GameSession, Push } from './session.ts'; import { @@ -170,6 +171,15 @@ export function startServer(opts: ServerOptions): void { const games = opts.initialGames; const lobbies = opts.initialLobbies; const sessions = opts.initialSessions; + /** + * Outstanding seat recovery codes — Gitea#33, `claims.ts`. + * + * In memory and not on the volume, deliberately: a code is minted on demand and spent within + * minutes with the administrator standing right there, so a restart dropping them all is the right + * failure. Persisting them would put a credential-equivalent on disk to solve a problem measured + * in seconds. + */ + const claims = createClaimStore(); const gameCodes = new Map(); // gameCode -> gameId, for /api/lobby/join for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId); @@ -322,6 +332,17 @@ export function startServer(opts: ServerOptions): void { gameId, gameCode: codes.get(gameId) ?? null, state: 'running' as const, + /** + * WHICH SEATS A PERSON IS SITTING IN — Gitea#33. + * + * `playerNames` cannot answer it: a bot's name is just a name, and telling the two apart + * by matching "Bot 1" would be guessing at a label. `sessions` holds humans and only + * humans, so this is the fact rather than an inference — and it is what lets the seat + * recovery action offer real players instead of chairs no token was ever issued for. + */ + seatedPlayers: [...sessions.values()] + .filter((s) => s.gameId === gameId) + .map((s) => s.player), ...g.summary(), })); // A lobby has no game to summarize yet — it is reported as what it is, so an @@ -340,14 +361,48 @@ export function startServer(opts: ServerOptions): void { return; } - const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname); + const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname); const gameId = match?.[1]; + // Compared explicitly rather than tested for truthiness: with two suffixes in the group, a + // bare `match?.[2]` would let a GET on `/claim` fall into the `/save` branch below. + const suffix = match?.[2]; if (!gameId) { sendJson(res, 404, { error: 'no such route' }); return; } - if (match?.[2] && req.method === 'GET') { + /** + * MINT A SEAT RECOVERY CODE FOR ONE PLAYER — Gitea#33. + * + * The token is the only identity this game has and it lives in one browser's `localStorage`; + * lose it and the seat is unreachable, because nothing else here will accept a claim to it. + * This is the supported way back, and it is administrative on purpose: whoever runs the + * server decides that a particular player has lost their seat, which is a judgement no + * automated route can make safely. + * + * IT HANDS BACK A CODE, NOT THE TOKEN. §1 says keep the token out of URLs, and the code is + * going into one. Short-lived and single-use (`claims.ts`), so a link left in a chat window + * is worth nothing by the time anyone finds it. + */ + if (suffix === '/claim' && req.method === 'POST') { + const body = (await readJson(req)) as { player?: number }; + const ps = [...sessions.values()].find((s) => s.gameId === gameId && s.player === body.player); + if (!ps) { + sendJson(res, 404, { error: 'no such seat' }); + return; + } + const { code, expiresAt } = claims.mint(ps.token, gameId, Date.now()); + sendJson(res, 200, { + code, + expiresAt, + player: ps.player, + displayName: ps.displayName, + gameCode: codes.get(gameId) ?? null, + }); + return; + } + + if (suffix === '/save' && req.method === 'GET') { const session = games.get(gameId); if (!session) { sendJson(res, 404, { error: 'no such game' }); @@ -656,6 +711,36 @@ export function startServer(opts: ServerOptions): void { return; } + /** + * SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games//claim`. + * + * NOT GATED BY THE ADMIN SECRET, and it must not be: the player following the link is the one + * person in this story who holds no secret at all. The code IS the authorisation — unguessable, + * single-use and short-lived — which is the same shape as the session token it hands back, and + * why minting one is the administrative act rather than spending one. + * + * The token travels in the response BODY of a POST, never in a URL (`lobby-and-sessions.md` + * §1). One answer for unknown, spent and expired codes, so this cannot be used to probe which. + */ + if (url.pathname === '/api/claim' && req.method === 'POST') { + const body = (await readJson(req)) as { code?: string }; + const claimed = typeof body.code === 'string' ? claims.redeem(body.code, Date.now()) : null; + const ps = claimed ? sessions.get(claimed.token) : undefined; + const live = ps ? games.get(ps.gameId) : undefined; + if (!claimed || !ps || !live) { + sendJson(res, 404, { error: 'no such claim' }); + return; + } + const codes = new Map((await readIndex(opts.dataDir)).map((e) => [e.gameId, e.gameCode])); + sendJson(res, 200, { + token: ps.token, + gameId: ps.gameId, + player: ps.player, + gameCode: codes.get(ps.gameId) ?? '', + }); + return; + } + /** * THIS SEAT'S OWN GAME, AS A SAVE (playtest, 2026-09-15: "most of the time, I want to go ahead and * just save it as a JSON file in my Downloads folder"). diff --git a/src/web/lobby.ts b/src/web/lobby.ts index 5536548..e1bf4ed 100644 --- a/src/web/lobby.ts +++ b/src/web/lobby.ts @@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string { return messages[key] ?? (key !== '' ? key : fallback); } -async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record }> { +/** Exported for `main.ts`'s seat-recovery path (Gitea#33), so there is one JSON POST on this page. */ +export async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record }> { const res = await fetch(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/web/main.ts b/src/web/main.ts index e6574c8..612b1a3 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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 { + 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. * diff --git a/test/server/claims.test.ts b/test/server/claims.test.ts new file mode 100644 index 0000000..8382646 --- /dev/null +++ b/test/server/claims.test.ts @@ -0,0 +1,68 @@ +/** + * Seat recovery codes — Gitea#33. + * + * The properties worth pinning are the ones that make a code safe to put in a link: it is spendable + * exactly once, it stops working on its own, and a bad code is indistinguishable from a spent one. + * `now` is a parameter rather than a clock, so expiry is tested without faking timers. + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { CLAIM_TTL_MS, createClaimStore } from '../../src/server/claims.ts'; + +describe('seat recovery codes', () => { + it('mints a code that names the seat it was minted for', () => { + const claims = createClaimStore(); + const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 1000); + assert.equal(expiresAt, 1000 + CLAIM_TTL_MS); + assert.deepEqual(claims.redeem(code, 1000), { token: 'tok-abc', gameId: 'game-1' }); + }); + + it('spends a code exactly once — a link in a chat log is worth nothing afterwards', () => { + const claims = createClaimStore(); + const { code } = claims.mint('tok-abc', 'game-1', 0); + assert.ok(claims.redeem(code, 1)); + assert.equal(claims.redeem(code, 2), null, 'the same code was accepted twice'); + }); + + it('stops working once its time is up, without anything having to sweep it', () => { + const claims = createClaimStore(); + const { code } = claims.mint('tok-abc', 'game-1', 0); + assert.equal(claims.redeem(code, CLAIM_TTL_MS - 1)?.token, 'tok-abc', 'expired early'); + const again = claims.mint('tok-abc', 'game-1', 0).code; + assert.equal(claims.redeem(again, CLAIM_TTL_MS), null, 'a code outlived its expiry'); + }); + + it('answers the same way for unknown, spent and expired codes', () => { + const claims = createClaimStore(); + const { code } = claims.mint('tok-abc', 'game-1', 0); + claims.redeem(code, 1); + const expired = claims.mint('tok-abc', 'game-1', 0).code; + + assert.equal(claims.redeem('never-existed', 1), null); + assert.equal(claims.redeem(code, 1), null); + assert.equal(claims.redeem(expired, CLAIM_TTL_MS + 1), null); + }); + + it('gives every mint its own code', () => { + const claims = createClaimStore(); + const codes = new Set([0, 1, 2, 3, 4].map(() => claims.mint('tok-abc', 'game-1', 0).code)); + assert.equal(codes.size, 5, 'two mints produced the same code'); + }); + + it('forgets expired codes rather than accumulating them', () => { + const claims = createClaimStore(); + claims.mint('tok-a', 'game-1', 0); + claims.mint('tok-b', 'game-1', 0); + assert.equal(claims.outstanding(0), 2); + assert.equal(claims.outstanding(CLAIM_TTL_MS), 0, 'expired codes were still being held'); + }); + + it('keeps a short-lived code short-lived when asked for one', () => { + const claims = createClaimStore(); + const { code, expiresAt } = claims.mint('tok-abc', 'game-1', 500, 60_000); + assert.equal(expiresAt, 60_500); + assert.equal(claims.redeem(code, 60_500), null); + }); +}); diff --git a/test/web.test.ts b/test/web.test.ts index fe12b21..579d562 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -4228,6 +4228,30 @@ describe('the lobby screen', () => { const chosen = (groups: Record, 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.