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 WagonerandClaude Fable 5.1 committed 2026-09-23 12:07:24 -04:00
1 parent effe17c729
commit 966a36b4aa
6 files changed
+94 -33

No files matched your search

+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'))