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
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user