Files
station-master/test/server/claims.test.ts
T
Jesse.MarkowitzandClaude Opus 5 7c9ef8797d 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
2026-09-16 20:16:24 -04:00

69 lines
2.9 KiB
TypeScript

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