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:
co-authored by
Claude Fable 5.1
parent
effe17c729
commit
966a36b4aa
@@ -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`);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user