Files
wizwar6e/packages/server/src/keeper.ts
T
Eric WagonerandClaude Fable 5.1 ecfee5d9c2 Seats are banked in a cookie the server sets, and a lost seat comes back by name to the address it was last used from
A seat lived in the browser's storage alone, and Safari clears that after a week away, or at a word from the person on another device. Now every seat taken is banked as well in an HttpOnly cookie the server writes, which such clearing leaves alone, and the wallet takes its seats back from the bank on arrival. For a browser that lost even the cookie, the ledger notes the address each seat was last used from, as a salted hash, at most once in ten minutes, and a name given in the lobby or from the gallery gets its seats back from the same address, with a new token; where the address differs, two people at the table share it, or the host gave the seat to the clockwork, the table is only counted, and the host's phrase or the keeper's link is the way back. A name refused as taken is looked for the same way before the refusal shows. The keeper's script finds a name across tables. The seat endpoints answer only this site's own pages, under a limit of their own.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
2026-11-10 21:57:20 -05:00

90 lines
4.2 KiB
TypeScript

// The keeper's door: a listener of its own on the loopback, apart from the
// port Caddy forwards to, so it answers only on the host itself, and only to
// the key in its key file, so another service on a shared host cannot use
// it. Its one caller is deploy/wizwar-reseat.sh, which sends the key:
// GET /keeper/seats?name= every table not finished where a person of that name sits, for one who lost the codes too
// GET /keeper/rooms/:id who sits at the table, and when each person last moved or spoke
// POST /keeper/rooms/:id/reseat {seat} a new token for a player locked out, as a link to send them
import { timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import type { GameEvent, PlayerId } from "@wizwar/engine";
import { getRoom, reseat, seatsForKeeper, tablesForKeeper, type Room } from "./rooms";
function send(res: ServerResponse, status: number, body: unknown): void {
res.writeHead(status, { "content-type": "application/json" }).end(JSON.stringify(body));
}
/** The key is read on each knock, so a key written after the server started is honoured. Absent, the door stays shut. */
function keyGiven(req: IncomingMessage, keyFile: string): boolean {
let key: Buffer;
try {
key = Buffer.from(readFileSync(keyFile, "utf8").trim());
} catch {
return false;
}
const given = Buffer.from(String(req.headers["x-keeper-key"] ?? ""));
return key.length > 0 && given.length === key.length && timingSafeEqual(given, key);
}
async function readSeat(req: IncomingMessage): Promise<string> {
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 4096) return "";
}
try {
const seat = (JSON.parse(raw) as { seat?: unknown }).seat;
return typeof seat === "string" ? seat : "";
} catch {
return "";
}
}
export interface KeeperDoor {
port: number;
publicUrl: string;
keyFile: string;
/** Lets the caller set adrift any socket still seated on the old token,
* and tell the table when the seat comes back from the clockwork. */
onReseat: (room: Room, name: PlayerId, returned?: GameEvent) => void;
}
async function handle(req: IncomingMessage, res: ServerResponse, { publicUrl, keyFile, onReseat }: KeeperDoor): Promise<void> {
const parts = (req.url ?? "/").split("?")[0]!.split("/").filter(Boolean);
// Nothing proxied belongs here: a forwarded request is turned away even if a proxy is someday pointed at this port.
if (req.headers["x-forwarded-for"] || !keyGiven(req, keyFile)) return send(res, 404, { error: "not here" });
if (parts[0] === "keeper" && parts[1] === "seats" && parts.length === 2 && req.method === "GET") {
const name = new URL(req.url ?? "/", "http://localhost").searchParams.get("name") ?? "";
return send(res, 200, { tables: tablesForKeeper(name.trim().slice(0, 40)) });
}
if (parts[0] !== "keeper" || parts[1] !== "rooms" || !parts[2]) return send(res, 404, { error: "not here" });
const room = getRoom(parts[2]);
if (!room) return send(res, 404, { error: "no such room" });
if (parts.length === 3 && req.method === "GET") {
return send(res, 200, { roomId: room.id, started: room.state !== null, finished: room.state?.phase === "finished", seats: seatsForKeeper(room) });
}
if (parts.length === 4 && parts[3] === "reseat" && req.method === "POST") {
const name = await readSeat(req);
const result = reseat(room, name);
if ("error" in result) return send(res, 404, result);
onReseat(room, name, result.returned);
// The token rides the fragment, which the browser keeps to itself: it reaches neither Caddy's log nor the server.
return send(res, 200, { name, link: `${publicUrl}/join/${room.id}#seat=${encodeURIComponent(name)}.${result.token}` });
}
return send(res, 404, { error: "not here" });
}
/** Open the door on 127.0.0.1, whatever address the game itself listens on. */
export function openKeeperDoor(door: KeeperDoor): Server {
const server = createServer((req, res) => {
handle(req, res, door).catch((e) => {
console.error("keeper door:", e);
if (!res.headersSent) send(res, 500, { error: "internal error" });
});
});
server.listen(door.port, "127.0.0.1");
return server;
}