/** * 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; }, }; }