Hashed seat tokens, ledgered inputs, refusable moves, disk-checked codes, host-only bots, and a drift script

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 12:07:53 -04:00
co-authored by Claude Fable 5.1
parent ddb18cb08a
commit c934bbe434
12 changed files with 173 additions and 47 deletions
+12 -5
View File
@@ -25,9 +25,13 @@ README.
ledger line, so it replays with the game, and the hall counts lines said
while you were away.
- **The ledger** is the append-only JSONL file that *is* the game: room,
seats, start (seed and rules revision), turns (inputs only), talk, over.
A room is its ledger replayed through a deterministic engine; nothing else
is saved. Ledgers survive deploys and restarts and are backed up nightly.
seats, start (seed and rules revision), each seat's submitted input, the
turn that resolves them, talk, over. A room is its ledger replayed
through a deterministic engine; nothing else is saved. A half-written
round survives a restart because every input is written as it arrives.
Ledgers survive deploys and restarts and are backed up nightly, so they
hold nothing secret: a seat line carries the SHA-256 of its token, never
the token, which goes once to the browser that earned it.
- **Report** in the masthead opens the slip: what happened, what you
expected, a screenshot if you have one. It is pinned to the room and the
round. **The keeper** replies; the reply appears under the report in the
@@ -68,8 +72,11 @@ a sandboxed systemd service, behind Caddy `handle` blocks that route `/api`
and `/ws` before the static files. Rooms in memory are evicted after a week
idle and come back from disk on the next visit. Rate limits sit on the doors
a stranger can knock on (rooms, seats, talk, the desk); a table of friends
never nears them. Seat tokens never leave the server except to the browser
that earned them; a shared link carries none.
never nears them. A seat token is minted once, sent once, and compared by
hash in constant time ever after; a shared link carries none. Room codes
are checked against the disk as well as memory, so an evicted room's code
is never reissued over its ledger. A game may refuse a move with a reason
(`validate`), and the player sees the reason.
## Operations
+21 -5
View File
@@ -44,9 +44,25 @@ Placeholders: `__SLUG__`, `__NAME__`, `__PORT__`, `__DOMAIN__` are filled by
`__SENTRY_SLACK_INTEGRATION__` and `__SLACK_CHANNEL_ID__` wait until those
things exist.
## Keeping the kit current
## What the kit is, and is not
When a game grows something every game should have (the gallery and the
reports desk were both born in one game and ported to the other), bring it
back here, with placeholders, so the next game inherits it. The kit is the
canonical copy of the shared parts; the games are its instances.
The model is copy and diverge. A new game starts as a copy of the template
and then goes its own way; the two games that came before it are not
instances of the template and were not rewritten to be. The kit holds the
reference copy of the shared parts and the conventions they follow, so a
third game starts from the best current version of everything.
Drift is meant to be visible, not prevented:
./drift.sh ../waving-hands # how far each shared file has wandered
`drift-map/<slug>.txt` pairs files when a game's layout differs from the
template's. When a game grows something every game should have (the
gallery and the reports desk were both born in one game and ported to the
other), bring it back here, with placeholders, so the next game inherits
it, and read the drift before assuming the kit has it.
The contract is simultaneous rounds: every seat that needs input submits,
then the round resolves; turn-at-a-time games fit as the case where one
seat needs input at a time. A game of single commands with out-of-turn
interruptions, Wiz-War's shape, does not fit and keeps its own server.
+2
View File
@@ -0,0 +1,2 @@
src/lib/net/client.ts src/lib/game/client.ts
src/lib/net/talk.ts src/lib/game/talk.ts
Executable
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Show how far a game's shared files have drifted from the kit's template.
# ./drift.sh <game-dir> [slug] e.g. ./drift.sh ../waving-hands waving-hands
# Files are paired by drift-map/<slug>.txt when a game's layout differs from
# the template's ("template/path game/path" per line); otherwise the same
# path is compared. Placeholders are filled with the slug before diffing, so
# only real divergence shows. Prints changed lines per file, then the total.
set -euo pipefail
GAME="${1:?usage: drift.sh <game-dir> [slug]}"
SLUG="${2:-$(basename "$(cd "$GAME" && pwd)")}"
KIT="$(cd "$(dirname "$0")" && pwd)"
MAP="$KIT/drift-map/$SLUG.txt"
SHARED="server/src/index.ts server/src/rooms.ts server/src/store.ts server/src/reports.ts server/src/ratelimit.ts
src/lib/net/client.ts src/lib/net/talk.ts src/lib/components/TableTalk.svelte src/lib/components/ReportSlip.svelte
deploy/deploy.sh deploy/setup-droplet.sh deploy/setup-server.sh deploy/Caddyfile.tmpl deploy/verify-ledgers.sh
deploy/replay-ledgers.ts deploy/pulse.sh deploy/visitors.sh deploy/sentry-slack-alert.sh deploy/pull-reports.sh deploy/report-reply.sh
deploy/__SLUG__.service deploy/__SLUG__.cron deploy/__SLUG__-backup.sh deploy/__SLUG__-rollup.sh"
total=0
for t in $SHARED; do
g="$t"
if [ -f "$MAP" ]; then
m=$(awk -v t="$t" '$1 == t { print $2 }' "$MAP")
[ -n "$m" ] && g="$m"
fi
g="${g//__SLUG__/$SLUG}"
if [ ! -f "$GAME/$g" ]; then printf '%-44s %s\n' "$t" "missing in game ($g)"; continue; fi
n=$(diff <(sed -e "s/__SLUG__/$SLUG/g" "$KIT/template/$t") "$GAME/$g" | grep -c '^[<>]' || true)
total=$((total + n))
printf '%-44s %4d lines differ\n' "$t" "$n"
done
echo "total: $total lines differ from the template"
+44
View File
@@ -0,0 +1,44 @@
// One-time migration: replace each seat line's raw token with its SHA-256,
// so no ledger on disk or in a backup holds a live seat key. Idempotent; a
// line already hashed is left alone. Run ON the droplet from the server
// directory (tsx is installed there):
// cd /opt/__SLUG__/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/__SLUG__/rooms
// Browsers keep their raw tokens; the server hashes what they send and
// compares, so nobody loses a seat.
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, renameSync, statSync, writeFileSync, chownSync } from 'node:fs';
import { join } from 'node:path';
const dir = process.argv[2];
if (!dir) {
console.error('usage: hash-tokens.ts <ledger-dir>');
process.exit(2);
}
let files = 0;
let lines = 0;
for (const file of readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
const path = join(dir, file);
const raw = readFileSync(path, 'utf8');
let changed = 0;
const out = raw
.split('\n')
.map((line) => {
if (!line.trim()) return line;
const entry = JSON.parse(line) as Record<string, unknown>;
if (entry.t !== 'seat' || typeof entry.token !== 'string') return line;
const { token, ...rest } = entry;
changed += 1;
return JSON.stringify({ ...rest, tokenHash: token ? createHash('sha256').update(token).digest('hex') : '' });
})
.join('\n');
if (!changed) continue;
const { uid, gid } = statSync(path);
writeFileSync(path + '.tmp', out);
chownSync(path + '.tmp', uid, gid);
renameSync(path + '.tmp', path);
files += 1;
lines += changed;
}
console.log(`${files} ledger${files === 1 ? '' : 's'} rewritten, ${lines} seat line${lines === 1 ? '' : 's'} hashed`);
+3 -4
View File
@@ -13,7 +13,6 @@ import type { SeatId } from '../src/lib/game/spec';
interface Seat {
id: SeatId;
name: string;
token: string;
bot: boolean;
}
@@ -52,9 +51,9 @@ async function main(): Promise<void> {
}
const outcome = state ? game.over(state) : null;
let verdict = state ? `${turns} turns, ${outcome ? 'over' : 'in play'}` : 'not started';
const human = seats.find((s) => !s.bot);
if (host && state && human) {
const res = await fetch(`${host}/api/rooms/${code}?token=${encodeURIComponent(human.token)}`);
if (host && state) {
// The gallery's view carries the round and the outcome, which is all the comparison needs.
const res = await fetch(`${host}/api/rooms/${code}`);
if (!res.ok) {
verdict += `, server ${res.status}`;
} else {
+5 -5
View File
@@ -3,7 +3,7 @@
//
// POST /api/rooms {name, size?} create a room and take seat A
// POST /api/rooms/:id/join {name} take the next seat
// POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the game begins)
// POST /api/rooms/:id/bot {token} the host seats a bot, before the game begins
// POST /api/rooms/:id/begin {token} the host begins with the players seated so far
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the game begins
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
@@ -171,8 +171,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
if (parts.length === 2 && req.method === 'POST') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many rooms opened from here just now; try again later.', 429);
const body = await readBody(req);
const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
const { room, seat, token } = rooms.create(String(body.name ?? ''), Number(body.size ?? game.minSeats));
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
}
const room = rooms.get(parts[2] ?? '');
@@ -187,8 +187,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const token = typeof body.token === 'string' && body.token ? body.token : undefined;
if (action === 'join') {
if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429);
const seat = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) });
const { seat, token: minted } = rooms.join(room, String(body.name ?? ''));
return send(res, 200, { seat: seat.id, token: minted, view: rooms.view(room, seat.id) });
}
if (action === 'bot') {
rooms.addBot(room, token);
+35 -22
View File
@@ -4,7 +4,7 @@
// then the round resolves at once, bots included. Nothing here knows the
// game beyond the GameSpec it is given.
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { SPECTATOR, type GameSpec, type SeatId } from '../../src/lib/game/spec';
import type { ChatLine, RoomView } from '../../src/lib/net/view';
import type { LedgerLine, Store } from './store';
@@ -17,10 +17,15 @@ export { SPECTATOR };
export interface Seat {
id: SeatId;
name: string;
token: string;
/** SHA-256 of the seat's token, hex; empty for a bot. */
tokenHash: string;
bot: boolean;
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export interface Room<State> {
id: string;
size: number;
@@ -122,28 +127,29 @@ export class Rooms<State, Input> {
return this.rooms.size;
}
/** The seat a token unlocks. */
/** The seat a token unlocks, compared by hash in constant time. */
seatOf(room: Room<State>, token: string | undefined): Seat {
const seat = room.seats.find((s) => !s.bot && s.token === token);
if (!token) throw new RoomError('That token opens no seat at this game.', 403);
const given = Buffer.from(hashToken(token), 'hex');
const seat = room.seats.find((s) => !s.bot && s.tokenHash.length === given.length * 2 && timingSafeEqual(Buffer.from(s.tokenHash, 'hex'), given));
if (!seat) throw new RoomError('That token opens no seat at this game.', 403);
return seat;
}
create(name: string, size: number): { room: Room<State>; seat: Seat } {
create(name: string, size: number): { room: Room<State>; seat: Seat; token: string } {
const { minSeats } = this.game;
if (!Number.isInteger(size) || size < minSeats || size > this.maxSeats) {
throw new RoomError(`A table seats ${minSeats} to ${this.maxSeats} players.`);
}
let id = newCode();
while (this.rooms.has(id)) id = newCode();
while (this.rooms.has(id) || this.store.exists(id)) id = newCode();
const room: Room<State> = { id, size, createdAt: Date.now(), seats: [], state: null, pending: {}, chat: [], seq: 0, updatedAt: Date.now() };
this.rooms.set(id, room);
this.commit(room, { t: 'room', id, seats: size, createdAt: room.createdAt });
const seat = this.sit(room, name, false);
return { room, seat };
return { room, ...this.sit(room, name, false) };
}
join(room: Room<State>, name: string): Seat {
join(room: Room<State>, name: string): { seat: Seat; token: string } {
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
return this.sit(room, name, false);
}
@@ -157,23 +163,27 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules });
}
/** The host seats a bot in an empty chair. */
addBot(room: Room<State>, token: string | undefined): Seat {
this.seatOf(room, token);
const host = this.seatOf(room, token);
if (host.id !== room.seats[0]?.id) throw new RoomError('Only the player who opened the table may seat a bot.', 403);
if (room.state) throw new RoomError('The game has begun; no more seats are taken.', 409);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = this.game.botNames.filter((n) => !taken.has(n.toLowerCase()));
const name = pool[Math.floor(Math.random() * pool.length)] ?? 'The Construct';
return this.sit(room, name, true);
return this.sit(room, name, true).seat;
}
private sit(room: Room<State>, rawName: string, bot: boolean): Seat {
/** Take the next free seat. The token is returned once and never stored; the ledger keeps its hash. */
private sit(room: Room<State>, rawName: string, bot: boolean): { seat: Seat; token: string } {
if (room.seats.length >= room.size) throw new RoomError('Every seat at this table is taken.', 409);
const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another player here already has that name.', 409);
const id = this.game.seatIds.find((s) => !room.seats.some((taken) => taken.id === s))!;
const seat: Seat = { id, name, token: bot ? '' : newId(18), bot };
this.commit(room, { t: 'seat', id: seat.id, name, token: seat.token, bot });
return seat;
const token = bot ? '' : newId(18);
const seat: Seat = { id, name, tokenHash: bot ? '' : hashToken(token), bot };
this.commit(room, { t: 'seat', id: seat.id, name, tokenHash: seat.tokenHash, bot });
return { seat, token };
}
/** The host sends a bot away before the game begins. People leave by not coming back. */
@@ -186,17 +196,17 @@ export class Rooms<State, Input> {
this.commit(room, { t: 'unseat', id: seat.id });
}
/** Record a seat's move; resolve the round once nobody else is awaited. */
/** Record a seat's move on the ledger, so a restart keeps it; resolve the round once nobody else is awaited. */
submit(room: Room<State>, token: string | undefined, raw: unknown): void {
const seat = this.seatOf(room, token);
if (!room.state) throw new RoomError('The game has not begun: seats are still empty.', 409);
if (this.game.over(room.state)) throw new RoomError('The game is over.', 409);
if (!this.waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
room.pending[seat.id] = this.game.cleanInput(raw);
room.updatedAt = Date.now();
room.seq += 1;
const input = this.game.cleanInput(raw);
const why = this.game.validate?.(room.state, seat.id, input);
if (why) throw new RoomError(why);
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input });
if (this.waitingOn(room).length === 0) this.resolve(room);
this.notify(room);
}
private resolve(room: Room<State>): void {
@@ -295,7 +305,10 @@ export class Rooms<State, Input> {
case 'over':
break;
case 'seat':
room.seats.push({ id: line.id, name: line.name, token: line.token, bot: line.bot });
room.seats.push({ id: line.id, name: line.name, tokenHash: line.tokenHash ?? (line.token ? hashToken(line.token) : ''), bot: line.bot });
break;
case 'input':
room.pending[line.id] = this.game.cleanInput(line.input);
break;
case 'unseat':
room.seats = room.seats.filter((s) => s.id !== line.id);
@@ -321,7 +334,7 @@ export class Rooms<State, Input> {
const room: Room<State> = { id, size: first.seats, createdAt: first.createdAt, seats: [], state: null, pending: {}, chat: [], seq: lines.length, updatedAt: first.createdAt };
for (const line of lines) {
this.apply(room, line);
if (line.t === 'turn') room.updatedAt = line.at;
if (line.t === 'turn' || line.t === 'input') room.updatedAt = line.at;
}
return room;
}
+9 -1
View File
@@ -8,7 +8,10 @@ import type { SeatId } from '../../src/lib/game/spec';
export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number }
| { t: 'seat'; id: SeatId; name: string; token: string; bot: boolean }
/** A seat holds only the hash of its token; the token itself goes once to the browser that earned it. Ledgers written before hashing carry `token` and are read once more. */
| { t: 'seat'; id: SeatId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** One seat's move for the round in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: SeatId; input: unknown }
/** A bot sent away by the host before the game began; its seat id is free again. */
| { t: 'unseat'; id: SeatId }
/** Ledgers from before revisions were recorded resolve under rules 1. */
@@ -41,6 +44,11 @@ export class Store {
.map((l) => JSON.parse(l) as LedgerLine);
}
/** Whether a ledger exists on disk, loaded or not; a code must not be reissued over one. */
exists(roomId: string): boolean {
return existsSync(this.path(roomId));
}
roomIds(): string[] {
return readdirSync(this.dir)
.filter((f) => f.endsWith('.jsonl'))
+1 -1
View File
@@ -46,7 +46,7 @@
</ul>
{#if !room.spectating}
<div class="ways">
{#if seatFree}
{#if seatFree && room.isHost}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
+3 -4
View File
@@ -87,9 +87,8 @@ export const game: GameSpec<State, Input> = {
view: (state, viewer) => (viewer === SPECTATOR ? structuredClone(state) : structuredClone(state)),
// A function of the state alone: the seed, the round and the seat, so a replay draws the same pick.
botInput: (state, seat) => ({ pick: 1 + Math.floor(random(state.rng + state.rounds.length * 7919 + state.seats.indexOf(seat)) * HIGHEST) }),
cleanInput: (raw) => {
const pick = Number((raw as { pick?: unknown })?.pick);
return { pick: Number.isInteger(pick) && pick >= 1 && pick <= HIGHEST ? pick : 1 };
},
cleanInput: (raw) => ({ pick: Number((raw as { pick?: unknown })?.pick) }),
validate: (_state, _seat, input) =>
Number.isInteger(input.pick) && input.pick >= 1 && input.pick <= HIGHEST ? null : `Name a number from 1 to ${HIGHEST}.`,
nameOf: (state, seat) => state.players[seat]?.name ?? seat
};
+7
View File
@@ -4,6 +4,11 @@
// inputs at a time, ask who still has to move, and hand each viewer the
// view they are allowed to see. A new game implements GameSpec once, in
// src/lib/game/index.ts, and the rest of the kit works unchanged.
//
// The shape is simultaneous rounds: every seat that needs input submits, then
// the round resolves. Turn-at-a-time games fit too (only one seat needs input
// at once). A game of single commands with out-of-turn interruptions, like
// Wiz-War, does not fit this contract and needs its own server.
/** A seat at the table: a single letter from GameSpec.seatIds. */
export type SeatId = string;
@@ -43,6 +48,8 @@ export interface GameSpec<State, Input> {
botInput(state: State, seat: SeatId): Input;
/** Only the shapes the engine understands get through; the engine validates the rest. */
cleanInput(raw: unknown): Input;
/** Why this seat may not make this move now, or null when it may. The server answers with the reason. */
validate?(state: State, seat: SeatId, input: Input): string | null;
/** A seat's name from the state, for the hall and the chronicle. */
nameOf(state: State, seat: SeatId): string;
}