Seat tokens hashed on the ledger, inputs written as they arrive, codes checked against the disk

A seat line now carries the SHA-256 of its token, compared in constant
time; the token itself goes once to the browser that earned it. Ledgers
written before this are read once more by their raw token, and
deploy/hash-tokens.ts rewrites them in place. Each submitted move is its
own ledger line, so a half-written turn survives a restart. A room code
is never reissued over an evicted room's ledger. Only the host seats a
bot. The deploy gate compares against the gallery view, so no token
leaves the ledger for it.

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:24 -04:00
co-authored by Claude Fable 5.1
parent effe17c729
commit 966a36b4aa
6 changed files with 94 additions and 33 deletions
+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/waving-hands/app/server && npx tsx ../deploy/hash-tokens.ts /var/lib/waving-hands/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 { createGame, type GameState, type TurnInput, type WizardId } from '../sr
interface Seat {
id: WizardId;
name: string;
token: string;
bot: boolean;
}
@@ -51,9 +50,9 @@ async function main(): Promise<void> {
continue;
}
let verdict = state ? `${turns} turns, ${state.over ? '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 every wizard's health 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 duel 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 duel begins)
// POST /api/rooms/:id/bot {token} the host seats a bot, before the duel begins
// POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far
// POST /api/rooms/:id/unseat {token, seat} the host sends a bot away before the duel begins
// GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view
@@ -183,8 +183,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 duels 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 ?? 2));
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 ?? 2));
return send(res, 201, { seat: seat.id, token, view: rooms.view(room, seat.id) });
}
const room = rooms.get(parts[2] ?? '');
@@ -198,8 +198,8 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
const body = await readBody(req);
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, typeof body.token === 'string' ? body.token : undefined);
+32 -22
View File
@@ -3,7 +3,7 @@
// same place. Inputs wait in memory until every seat that must move has moved;
// then the turn resolves at once, bots included.
import { randomBytes } from 'node:crypto';
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import { chooseBotTurn } from '../../src/lib/game/bot';
import { resolveTurn } from '../../src/lib/game/resolve';
import { GESTURES, type Gesture } from '../../src/lib/game/spells';
@@ -23,10 +23,15 @@ const CHAT_KEEP = 200;
export interface Seat {
id: WizardId;
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;
@@ -113,25 +118,26 @@ 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 duel.', 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 duel.', 403);
return seat;
}
create(name: string, size: number): { room: Room; seat: Seat } {
create(name: string, size: number): { room: Room; seat: Seat; token: string } {
if (!Number.isInteger(size) || size < 2 || size > MAX_SEATS) throw new RoomError(`A table seats 2 to ${MAX_SEATS} wizards.`);
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 duel has begun; no more seats are taken.', 409);
return this.sit(room, name, false);
}
@@ -145,23 +151,27 @@ export class Rooms {
this.commit(room, { t: 'start', seed: newSeed(), rules: CURRENT_RULES });
}
/** 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 wizard who opened the table may seat a bot.', 403);
if (room.state) throw new RoomError('The duel has begun; no more seats are taken.', 409);
const taken = new Set(room.seats.map((s) => s.name.toLowerCase()));
const pool = BOT_NAMES.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 duel is taken.', 409);
const name = cleanName(rawName);
if (room.seats.some((s) => s.name.toLowerCase() === name.toLowerCase())) throw new RoomError('Another wizard here already has that name.', 409);
const id = SEAT_IDS.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 duel begins. People leave by not coming back. */
@@ -174,17 +184,14 @@ export class Rooms {
this.commit(room, { t: 'unseat', id: seat.id });
}
/** Record a seat's move; resolve the turn once nobody else is awaited. */
/** Record a seat's move on the ledger, so a restart keeps it; resolve the turn 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 duel has not begun: seats are still empty.', 409);
if (room.state.over) throw new RoomError('The duel is over.', 409);
if (!waitingOn(room).includes(seat.id)) throw new RoomError('It is not your move.', 409);
room.pending[seat.id] = cleanInput(raw);
room.updatedAt = Date.now();
room.seq += 1;
this.commit(room, { t: 'input', at: Date.now(), id: seat.id, input: cleanInput(raw) });
if (waitingOn(room).length === 0) this.resolve(room);
this.notify(room);
}
private resolve(room: Room): void {
@@ -286,7 +293,10 @@ function apply(room: Room, line: LedgerLine): void {
case 'room':
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] = line.input;
break;
case 'unseat':
room.seats = room.seats.filter((s) => s.id !== line.id);
@@ -314,7 +324,7 @@ function replay(id: string, lines: LedgerLine[]): Room | null {
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) {
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 { TurnInput, WizardId } from '../../src/lib/game/state';
export type LedgerLine =
| { t: 'room'; id: string; seats: number; createdAt: number }
| { t: 'seat'; id: WizardId; 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: WizardId; name: string; tokenHash?: string; token?: string; bot: boolean }
/** One seat's move for the turn in progress, so a restart keeps it. The turn line that follows carries every input again. */
| { t: 'input'; at: number; id: WizardId; input: TurnInput }
/** A bot sent away by the host before the duel began; its seat id is free again. */
| { t: 'unseat'; id: WizardId }
/** 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
@@ -180,7 +180,7 @@
</ul>
{#if !watching}
<div class="ways">
{#if r.seats.length < r.size}
{#if r.seats.length < r.size && room.isHost}
<button type="button" class="quiet" onclick={() => room?.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}