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:
co-authored by
Claude Opus 5
parent
9a9e50b3c6
commit
7c9ef8797d
+87
-2
@@ -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<string, string>(); // 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/<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
|
||||
* just save it as a JSON file in my Downloads folder").
|
||||
|
||||
Reference in New Issue
Block a user