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
+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 {