The keeper can bring back a seat a browser lost: a reseat line on the ledger gives that wizard's seat a new token and the old one opens nothing after, wizwar-reseat.sh on the host asks for it through a keeper door that listens on the loopback alone and opens only to a key the deploy makes once, and the link it prints carries the seat in the fragment, which leaves the address bar at once and is kept only when no seat held in this browser still opens and the server takes it; a gallery watching a game in play is told both ways back, the front page asks before forgetting a game not yet finished, and the server's tests now run with the rest

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m
This commit is contained in:
Eric WagonerandClaude Opus 5.5 committed 2026-10-13 18:33:04 -04:00
1 parent 1b9acfd7c4
commit 5ea0d4fdec
16 files changed
+423 -16

No files matched your search

+83
View File
@@ -0,0 +1,83 @@
// 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/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 { PlayerId } from "@wizwar/engine";
import { getRoom, reseat, seatsForKeeper, 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. */
onReseat: (room: Room, name: PlayerId) => 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] !== "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);
// 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;
}