From c934bbe43401060db1b74f0fbdf9a6fbf774e13c Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 12:07:53 -0400 Subject: [PATCH] Hashed seat tokens, ledgered inputs, refusable moves, disk-checked codes, host-only bots, and a drift script Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up --- CONVENTIONS.md | 17 ++++--- README.md | 26 ++++++++--- drift-map/waving-hands.txt | 2 + drift.sh | 31 +++++++++++++ template/deploy/hash-tokens.ts | 44 ++++++++++++++++++ template/deploy/replay-ledgers.ts | 7 ++- template/server/src/index.ts | 10 ++--- template/server/src/rooms.ts | 57 +++++++++++++++--------- template/server/src/store.ts | 10 ++++- template/src/lib/components/Lobby.svelte | 2 +- template/src/lib/game/index.ts | 7 ++- template/src/lib/game/spec.ts | 7 +++ 12 files changed, 173 insertions(+), 47 deletions(-) create mode 100644 drift-map/waving-hands.txt create mode 100755 drift.sh create mode 100644 template/deploy/hash-tokens.ts diff --git a/CONVENTIONS.md b/CONVENTIONS.md index c2bcfcd..0130140 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -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 diff --git a/README.md b/README.md index d07b061..796d8c1 100644 --- a/README.md +++ b/README.md @@ -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/.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. diff --git a/drift-map/waving-hands.txt b/drift-map/waving-hands.txt new file mode 100644 index 0000000..08e7275 --- /dev/null +++ b/drift-map/waving-hands.txt @@ -0,0 +1,2 @@ +src/lib/net/client.ts src/lib/game/client.ts +src/lib/net/talk.ts src/lib/game/talk.ts diff --git a/drift.sh b/drift.sh new file mode 100755 index 0000000..cfd5282 --- /dev/null +++ b/drift.sh @@ -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 [slug] e.g. ./drift.sh ../waving-hands waving-hands +# Files are paired by drift-map/.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 [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" diff --git a/template/deploy/hash-tokens.ts b/template/deploy/hash-tokens.ts new file mode 100644 index 0000000..16ecec3 --- /dev/null +++ b/template/deploy/hash-tokens.ts @@ -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 '); + 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; + 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`); diff --git a/template/deploy/replay-ledgers.ts b/template/deploy/replay-ledgers.ts index 0562095..1d25467 100644 --- a/template/deploy/replay-ledgers.ts +++ b/template/deploy/replay-ledgers.ts @@ -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 { } 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 { diff --git a/template/server/src/index.ts b/template/server/src/index.ts index a38827a..65df0a4 100644 --- a/template/server/src/index.ts +++ b/template/server/src/index.ts @@ -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 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 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); diff --git a/template/server/src/rooms.ts b/template/server/src/rooms.ts index 604051a..7a97c02 100644 --- a/template/server/src/rooms.ts +++ b/template/server/src/rooms.ts @@ -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 { id: string; size: number; @@ -122,28 +127,29 @@ export class Rooms { return this.rooms.size; } - /** The seat a token unlocks. */ + /** The seat a token unlocks, compared by hash in constant time. */ seatOf(room: Room, 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; seat: Seat } { + create(name: string, size: number): { room: Room; 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 = { 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, name: string): Seat { + join(room: Room, 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 { this.commit(room, { t: 'start', seed: newSeed(), rules: this.game.currentRules }); } + /** The host seats a bot in an empty chair. */ addBot(room: Room, 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, 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, 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 { 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, 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): void { @@ -295,7 +305,10 @@ export class Rooms { 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 { const room: Room = { 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; } diff --git a/template/server/src/store.ts b/template/server/src/store.ts index 1c2906a..67f4966 100644 --- a/template/server/src/store.ts +++ b/template/server/src/store.ts @@ -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')) diff --git a/template/src/lib/components/Lobby.svelte b/template/src/lib/components/Lobby.svelte index 2c78844..0167c24 100644 --- a/template/src/lib/components/Lobby.svelte +++ b/template/src/lib/components/Lobby.svelte @@ -46,7 +46,7 @@ {#if !room.spectating}
- {#if seatFree} + {#if seatFree && room.isHost} {/if} {#if room.isHost} diff --git a/template/src/lib/game/index.ts b/template/src/lib/game/index.ts index 1b00c8c..2b488e8 100644 --- a/template/src/lib/game/index.ts +++ b/template/src/lib/game/index.ts @@ -87,9 +87,8 @@ export const game: GameSpec = { 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 }; diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index 25d0908..06a7e64 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -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 { 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; }