Files
wizwar6e/packages/server/src/store.ts
T
Eric WagonerandClaude Fable 5 0221e91ba3 Hash seat tokens at rest (security review finding)
Raw seat tokens no longer touch disk or long-lived memory: rooms store
sha-256 hashes, joins compare timing-safely, sessions keep the raw
token they authenticated with only for minting transfer phrases, and
legacy plaintext room files still load (hashed on read). Verified:
rejoin and transfer both work, and the room file contains only
hostTokenHash — no raw token anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 00:03:36 -04:00

77 lines
2.2 KiB
TypeScript

// Durable rooms: one append-only JSONL file per room. The first line is the
// room's birth certificate; every later line is a join, a start, or a game
// command. Because the engine is deterministic, replaying a file rebuilds
// the exact game state — server restarts lose nothing.
import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface RoomMetaLine {
kind: "meta";
id: string;
hostId: string;
/** sha-256 of the host's seat token. Raw tokens never touch disk. */
hostTokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */
hostToken?: string;
seed: number;
createdAt: string;
}
export interface JoinLine {
kind: "join";
name: string;
tokenHash?: string;
/** Legacy plaintext token (pre-hashing files only). */
token?: string;
}
export interface StartLine {
kind: "start";
expansion: boolean;
}
export interface CommandLine {
kind: "command";
seq: number;
playerId: string;
command: unknown;
at: string;
}
export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine;
const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms");
function fileFor(roomId: string): string {
return join(DATA_DIR, `${roomId}.jsonl`);
}
export function ensureDataDir(): void {
mkdirSync(DATA_DIR, { recursive: true });
}
export function appendLine(roomId: string, line: RoomLine): void {
appendFileSync(fileFor(roomId), JSON.stringify(line) + "\n", "utf8");
}
/** Read every persisted room's lines, keyed by room id. */
export function readAllRooms(): Map<string, RoomLine[]> {
ensureDataDir();
const rooms = new Map<string, RoomLine[]>();
for (const file of readdirSync(DATA_DIR)) {
if (!file.endsWith(".jsonl")) continue;
const id = file.slice(0, -".jsonl".length);
try {
const lines = readFileSync(join(DATA_DIR, file), "utf8")
.split("\n")
.filter((l) => l.trim().length > 0)
.map((l) => JSON.parse(l) as RoomLine);
if (lines.length > 0 && lines[0]!.kind === "meta") rooms.set(id, lines);
} catch (e) {
console.error(`skipping unreadable room file ${file}:`, e);
}
}
return rooms;
}