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 73522d7e09
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 ## 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 Fourteen reports from the second multiplayer playtest of v0.8.0.10, the WHISTLE-6945 table. Eleven are
+28
View File
@@ -36,6 +36,34 @@ and `<ip>:<port>` 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 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. 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/<id>/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 Real accounts can be layered on later without touching the rules engine, which is exactly why
[`overview.md`](overview.md) keeps that boundary sharp. [`overview.md`](overview.md) keeps that boundary sharp.
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "station-master", "name": "station-master",
"version": "0.8.0.11", "version": "0.8.0.12",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "Station Master — a railroad operations game", "description": "Station Master — a railroad operations game",
+83
View File
@@ -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<string, { token: string; gameId: string; expiresAt: number }>();
/** 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;
},
};
}
+87 -2
View File
@@ -37,6 +37,7 @@ import {
writeLobby, writeLobby,
writeSessions, writeSessions,
} from './persistence.ts'; } from './persistence.ts';
import { createClaimStore } from './claims.ts';
import { createSession } from './session.ts'; import { createSession } from './session.ts';
import type { GameSession, Push } from './session.ts'; import type { GameSession, Push } from './session.ts';
import { import {
@@ -170,6 +171,15 @@ export function startServer(opts: ServerOptions): void {
const games = opts.initialGames; const games = opts.initialGames;
const lobbies = opts.initialLobbies; const lobbies = opts.initialLobbies;
const sessions = opts.initialSessions; 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<string, string>(); // gameCode -> gameId, for /api/lobby/join const gameCodes = new Map<string, string>(); // gameCode -> gameId, for /api/lobby/join
for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId); for (const [gameId, lobby] of lobbies) gameCodes.set(lobby.gameCode, gameId);
@@ -322,6 +332,17 @@ export function startServer(opts: ServerOptions): void {
gameId, gameId,
gameCode: codes.get(gameId) ?? null, gameCode: codes.get(gameId) ?? null,
state: 'running' as const, 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(), ...g.summary(),
})); }));
// A lobby has no game to summarize yet — it is reported as what it is, so an // 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; return;
} }
const match = /^\/api\/games\/([^/]+)(\/save)?$/.exec(url.pathname); const match = /^\/api\/games\/([^/]+)(\/save|\/claim)?$/.exec(url.pathname);
const gameId = match?.[1]; 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) { if (!gameId) {
sendJson(res, 404, { error: 'no such route' }); sendJson(res, 404, { error: 'no such route' });
return; 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); const session = games.get(gameId);
if (!session) { if (!session) {
sendJson(res, 404, { error: 'no such game' }); sendJson(res, 404, { error: 'no such game' });
@@ -656,6 +711,36 @@ export function startServer(opts: ServerOptions): void {
return; return;
} }
/**
* SPEND A SEAT RECOVERY CODE — Gitea#33, the other half of `/api/games/<id>/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 * 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"). * just save it as a JSON file in my Downloads folder").
+2 -1
View File
@@ -107,7 +107,8 @@ function explain(code: unknown, fallback: string): string {
return messages[key] ?? (key !== '' ? key : fallback); return messages[key] ?? (key !== '' ? key : fallback);
} }
async function postJson(path: string, body: unknown): Promise<{ status: number; body: Record<string, unknown> }> { /** 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<string, unknown> }> {
const res = await fetch(path, { const res = await fetch(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
+58 -1
View File
@@ -27,7 +27,7 @@ import type { PlayerIndex } from '../engine/state.ts';
import type { PublicDistrict } from '../sim/view.ts'; import type { PublicDistrict } from '../sim/view.ts';
import { actorOnScreen, createStepQueue } from './step-queue.ts'; import { actorOnScreen, createStepQueue } from './step-queue.ts';
import { PACE_LEVELS } from '../sim/pacing.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 type { LobbyReady } from './lobby.ts';
import { import {
closestPreset, closestPreset,
@@ -1126,9 +1126,66 @@ function abandonRemote(): void {
* into a remembered multiplayer game, straight into solitaire (the zero-friction default, D11 — the * 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. * 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 { function start(): void {
const params = new URLSearchParams(location.search); 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. * ASKING FOR THE LOBBY BEATS RESUMING A GAME.
* *
+68
View File
@@ -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);
});
});
+24
View File
@@ -4228,6 +4228,30 @@ describe('the lobby screen', () => {
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined => const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
groups[name]!.find((r) => r.checked)?.value; 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 () => { 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 // Somebody who was handed a code used to have to scroll past the entire create form to find the
// box to type it into. // box to type it into.