Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c9ef8797d |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user