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>
77 lines
2.2 KiB
TypeScript
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;
|
|
}
|