From 5ea0d4fdec96bd26673203938634b689260d4539 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Tue, 13 Oct 2026 18:32:18 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MbtUPJ24P6vc6tE1x6RS3m --- deploy/README.md | 11 ++ deploy/deploy.sh | 8 ++ deploy/wizwar-reseat.sh | 36 ++++++ deploy/wizwar.service | 3 + package-lock.json | 3 +- packages/engine/test/automaton.test.ts | 4 +- packages/server/package.json | 6 +- packages/server/src/index.ts | 14 +++ packages/server/src/keeper.ts | 83 ++++++++++++++ packages/server/src/rooms.ts | 27 +++++ packages/server/src/store.ts | 11 +- packages/server/test/keeper.test.ts | 151 +++++++++++++++++++++++++ packages/server/tsconfig.json | 2 +- packages/web/src/App.svelte | 38 ++++++- packages/web/src/Help.svelte | 6 + packages/web/src/net.svelte.ts | 36 +++++- 16 files changed, 423 insertions(+), 16 deletions(-) create mode 100755 deploy/wizwar-reseat.sh create mode 100644 packages/server/src/keeper.ts create mode 100644 packages/server/test/keeper.test.ts diff --git a/deploy/README.md b/deploy/README.md index bcbead8..d6d2ca3 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -52,4 +52,15 @@ longer replays becomes unreachable after restart, so this is not optional. `setup-droplet.sh` does NOT install it — on a fresh droplet, copy the script, configure rclone (the script refuses to run while the config still holds its CHANGE_ME placeholder), and add the cron entry by hand. +- A player whose browser lost their seat (Safari's seven days, an app's own + browser, cleared data) writes to the keeper. On the droplet, + `wizwar-reseat.sh ROOM` lists who sits at the table and when each last + moved or spoke; `wizwar-reseat.sh ROOM NAME` gives that wizard's seat a new + token and prints a link carrying it in the fragment. The old token opens + nothing after. The script answers through the game server's keeper door, + a listener of its own on 127.0.0.1:9787 that Caddy never reaches, which + opens only to the key in /etc/wizwar/keeper.key (root and the wizwar user + alone can read it, so the other services on the host cannot use the door). + The first deploy makes the key, and every deploy installs the script at + /usr/local/bin. - One-off backup: `scp -r root@:/var/lib/wizwar/rooms ./rooms-backup` diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 7ce7db2..7a1eb36 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -25,6 +25,14 @@ ssh "root@$HOST" ' install -m 755 /opt/wizwar/deploy/wizwar-backup.sh /usr/local/bin/wizwar-backup.sh install -m 755 /opt/wizwar/deploy/wizwar-rollup.sh /usr/local/bin/wizwar-rollup.sh install -m 755 /opt/wizwar/deploy/wizwar-visitors.sh /usr/local/bin/wizwar-visitors.sh + install -m 755 /opt/wizwar/deploy/wizwar-reseat.sh /usr/local/bin/wizwar-reseat.sh + # The key to the keeper door: made once, readable by root (the reseat + # script) and the wizwar user (the server) alone. + if [ ! -s /etc/wizwar/keeper.key ]; then + install -d -m 750 -o root -g wizwar /etc/wizwar + (umask 037; openssl rand -hex 32 > /etc/wizwar/keeper.key) + chown root:wizwar /etc/wizwar/keeper.key + fi # On a host the hall shares, its hall-nightly.sh runs the rollup and backup # of every game and checks in with Sentry once for all; a game on a box of # its own schedules its own. diff --git a/deploy/wizwar-reseat.sh b/deploy/wizwar-reseat.sh new file mode 100755 index 0000000..2c73a4c --- /dev/null +++ b/deploy/wizwar-reseat.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# A way back for a player whose browser lost their seat: the seat gets a new +# token, the old one stops working, and the link printed carries the new one. +# Whether the one asking holds that seat is the keeper's call; send the link +# only to them. Runs ON the droplet, through the game server's keeper door, +# which listens on the loopback alone. +# wizwar-reseat.sh ROOM who sits at the table, and when each person last moved or spoke +# wizwar-reseat.sh ROOM NAME re-seat the wizard NAME and print the link +set -euo pipefail +ROOM="${1:?usage: wizwar-reseat.sh ROOM [NAME]}" +DOOR="http://127.0.0.1:${KEEPER_PORT:-9787}/keeper/rooms/${ROOM^^}" +KEY_FILE="${KEEPER_KEY_FILE:-/etc/wizwar/keeper.key}" +[ -s "$KEY_FILE" ] || { echo "No keeper's key at $KEY_FILE; a deploy makes it." >&2; exit 1; } +# The key goes as a header read from a file descriptor, never on a command line another user could see. +KEY="@/dev/fd/3" +exec 3< <(printf 'x-keeper-key: %s\n' "$(cat "$KEY_FILE")") +if [ $# -lt 2 ]; then + curl -s -H "$KEY" "$DOOR" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +print("room %s, %s" % (r["roomId"], "finished" if r["finished"] else "in play" if r["started"] else "not begun")) +for s in r["seats"]: + when = "automaton" if s["bot"] else "never moved or spoke" if s["lastActed"] is None else "last acted " + s["lastActed"][:16].replace("T", " ") + " UTC" + print(" %-24s %-5s %s" % (s["name"], "host" if s["host"] else "", when)) +' +else + python3 -c 'import json, sys; print(json.dumps({"seat": sys.argv[1]}))' "$2" \ + | curl -s -X POST -H "$KEY" -H 'content-type: application/json' --data-binary @- "$DOOR/reseat" | python3 -c ' +import json, sys +r = json.load(sys.stdin) +if "error" in r: sys.exit(r["error"]) +print("%s has a new token; their old one opens nothing now. Send them this link:" % r["name"]) +print(r["link"]) +' +fi diff --git a/deploy/wizwar.service b/deploy/wizwar.service index cd88e51..08c3d08 100644 --- a/deploy/wizwar.service +++ b/deploy/wizwar.service @@ -9,6 +9,9 @@ WorkingDirectory=/opt/wizwar/packages/server Environment=PORT=8787 # Caddy terminates TLS; the plaintext port must not face the internet. Environment=HOST=127.0.0.1 +# The keeper's door (deploy/wizwar-reseat.sh) binds the loopback whatever HOST says. +Environment=KEEPER_PORT=9787 +Environment=KEEPER_KEY_FILE=/etc/wizwar/keeper.key Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist Environment=WIZWAR_KEEPER_TZ=America/New_York diff --git a/package-lock.json b/package-lock.json index f9bfd28..fbba991 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2759,7 +2759,8 @@ "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "tsx": "^4.19.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } }, "packages/web": { diff --git a/packages/engine/test/automaton.test.ts b/packages/engine/test/automaton.test.ts index 4d4719d..16962ee 100644 --- a/packages/engine/test/automaton.test.ts +++ b/packages/engine/test/automaton.test.ts @@ -1021,7 +1021,7 @@ describe("interception, escape, and hazard sense", () => { const cmd = automatonCommand(viewFor(state, "bot"), "hunter", "archmage"); expect(cmd).toMatchObject({ type: "cast", instanceId: "DC", target: { kind: "cell" } }); const r = applyCommand(state, "bot", cmd!); - expect(r.ok).toBe(true); + if (!r.ok) throw new Error(r.error); // The box gone, the very next thought is the grab. const next = automatonCommand(viewFor(r.state, "bot"), "hunter", "archmage"); expect(next).toMatchObject({ type: "pickUpTreasure" }); @@ -1050,7 +1050,7 @@ describe("interception, escape, and hazard sense", () => { cmd = automatonFallback(view, "archmage"); r = applyCommand(state, seat, cmd); } - expect(r.ok).toBe(true); + if (!r.ok) throw new Error(`${seat}: ${r.error}`); state = r.state; ended = seat === "bot" && cmd.type === "endTurn"; } diff --git a/packages/server/package.json b/packages/server/package.json index 6e68b3e..3d069c6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "tsx watch src/index.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "@sentry/node": "^10.73.0", @@ -16,6 +17,7 @@ "@types/node": "^22.0.0", "@types/ws": "^8.5.0", "tsx": "^4.19.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8098580..40be0c7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -81,6 +81,7 @@ import { import { engagementStats, recordHotseat } from "./stats"; import { appendFeedback, readFeedback, readClips, clipAssetPath, CLIP_SLUG, feedbackImageDir } from "./store"; import { clipsIndexHtml, clipPageHtml } from "./clips"; +import { openKeeperDoor } from "./keeper"; import { SlidingLimit, clientAddress } from "./ratelimit"; import { getShare, loadShares, mintShare } from "./shares"; import { renderSharePng } from "./ogimage"; @@ -535,6 +536,19 @@ const httpServer = createServer((req, res) => { const wss = new WebSocketServer({ server: httpServer, maxPayload: 64 * 1024 }); httpServer.listen(port, host); +// The keeper's door listens on the loopback alone, on a port of its own, and +// opens only to the key in KEEPER_KEY_FILE (deploy/wizwar-reseat.sh). A +// socket still seated on the old token is closed: its reconnect offers that +// token, and it opens nothing now. +openKeeperDoor({ + port: Number(process.env.KEEPER_PORT ?? 9787), + publicUrl: PUBLIC_URL, + keyFile: process.env.KEEPER_KEY_FILE ?? "/etc/wizwar/keeper.key", + onReseat: (room, name) => { + for (const s of sessions) if (s.roomId === room.id && s.playerId === name) s.socket.close(); + }, +}); + interface Session { socket: WebSocket; playerId: PlayerId | null; diff --git a/packages/server/src/keeper.ts b/packages/server/src/keeper.ts new file mode 100644 index 0000000..f5295d8 --- /dev/null +++ b/packages/server/src/keeper.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/server/src/rooms.ts b/packages/server/src/rooms.ts index 8a33665..82650dd 100644 --- a/packages/server/src/rooms.ts +++ b/packages/server/src/rooms.ts @@ -331,6 +331,31 @@ export function kickSeat(room: Room, byId: PlayerId, name: PlayerId): string | n return null; } +/** The keeper gives a person's seat a new token, for a player whose browser + * lost the old one. Whether the one asking holds the seat is the keeper's + * judgment; this carries it out. The old token opens nothing after. */ +export function reseat(room: Room, name: PlayerId): { token: string } | { error: string } { + if (!room.tokens.has(name) || room.bots.has(name)) return { error: "no person sits in that seat" }; + const token = randomBytes(16).toString("hex"); + room.tokens.set(name, hashToken(token)); + appendLine(room.id, { kind: "reseat", name, tokenHash: hashToken(token), at: new Date().toISOString() }); + // A phrase minted with the old token would hand over a seat that no longer answers to it. + for (const [code, t] of transfers) if (t.roomId === room.id && t.name === name) transfers.delete(code); + return { token }; +} + +/** The table as the keeper sees it before re-seating: who sits where, and + * when each person last moved or spoke. */ +export function seatsForKeeper(room: Room): { name: PlayerId; host: boolean; bot: boolean; lastActed: string | null }[] { + const last = new Map(); + for (const c of room.log) last.set(c.playerId, c.at); + for (const c of room.chat) if ((last.get(c.player) ?? "") < c.at) last.set(c.player, c.at); + return room.players.map((name) => { + const bot = room.bots.has(name); + return { name, host: name === room.hostId, bot, lastActed: bot ? null : (last.get(name) ?? null) }; + }); +} + /** The host dissolves the room: a lobby that never dealt, or a finished * game done being remembered. The ledger is archived, never deleted. */ export function abandonRoom(room: Room, byId: PlayerId): string | null { @@ -835,6 +860,8 @@ function rebuildRoom(id: string, lines: RoomLine[]): Room | null { room.tokens.delete(line.name); room.bots.delete(line.name); room.colorChoices.delete(line.name); + } else if (line.kind === "reseat") { + if (room.tokens.has(line.name)) room.tokens.set(line.name, line.tokenHash); } else if (line.kind === "join") { if (line.bot) { room.players.push(line.name); diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index 0b309d0..b49b35e 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -93,13 +93,22 @@ export interface KickLine { at: string; } +/** The keeper gave a person's seat a new token, for a player whose browser + * lost the old one; the old one opens nothing after. */ +export interface ReseatLine { + kind: "reseat"; + name: string; + tokenHash: string; + at: string; +} + export interface AbandonLine { kind: "abandon"; by: string; at: string; } -export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; +export type RoomLine = RoomMetaLine | JoinLine | StartLine | CommandLine | ChatLine | KickLine | ReseatLine | AbandonLine | RematchLine | ChallengeLine | GalleryTalkLine; const DATA_DIR = process.env.WIZWAR_DATA_DIR ?? join(process.cwd(), "data", "rooms"); diff --git a/packages/server/test/keeper.test.ts b/packages/server/test/keeper.test.ts new file mode 100644 index 0000000..16a3645 --- /dev/null +++ b/packages/server/test/keeper.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +// The store reads its directory when first imported, so the rooms module is +// loaded only after the ledgers have somewhere of their own to go. +const scratch = mkdtempSync(join(tmpdir(), "wizwar-keeper-")); +process.env.WIZWAR_DATA_DIR = join(scratch, "rooms"); +const KEY_FILE = join(scratch, "keeper.key"); +const KEY = "a".repeat(64); + +let rooms: typeof import("../src/rooms"); +let keeper: typeof import("../src/keeper"); +beforeAll(async () => { + rooms = await import("../src/rooms"); + rooms.loadPersistedRooms(); + keeper = await import("../src/keeper"); +}); + +function table() { + const { room, token: ann } = rooms.createRoom("Ann"); + const joined = rooms.joinRoom(room, "Bo", null); + if ("error" in joined) throw new Error(joined.error); + const bot = rooms.addAutomaton(room); + if ("error" in bot) throw new Error(bot.error); + return { room, ann, bo: joined.token, bot: bot.name }; +} + +function token(result: { token: string } | { error: string }): string { + if ("error" in result) throw new Error(result.error); + return result.token; +} + +describe("the keeper's reseat", () => { + it("gives a person's seat a new token: the old one is refused and the new one opens the seat", () => { + const { room, bo } = table(); + const fresh = token(rooms.reseat(room, "Bo")); + expect(fresh).not.toBe(bo); + expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" }); + expect(rooms.joinRoom(room, "Bo", fresh)).toEqual({ token: fresh }); + expect(rooms.peekSummary(room.id, "Bo", bo)).toBe("badToken"); + }); + + it("re-seats the host the same way", () => { + const { room, ann } = table(); + const fresh = token(rooms.reseat(room, "Ann")); + expect("error" in rooms.joinRoom(room, "Ann", ann)).toBe(true); + expect(rooms.joinRoom(room, "Ann", fresh)).toEqual({ token: fresh }); + }); + + it("voids a transfer phrase minted with the old token", () => { + const { room, bo } = table(); + const minted = rooms.makeTransferCode(room, "Bo", bo); + if ("error" in minted) throw new Error(minted.error); + rooms.reseat(room, "Bo"); + expect("error" in rooms.claimTransferCode(minted.code)).toBe(true); + }); + + it("keeps the new token across a reload from the ledger", async () => { + const { room, ann, bo } = table(); + const fresh = token(rooms.reseat(room, "Bo")); + vi.resetModules(); + const reloaded: typeof import("../src/rooms") = await import("../src/rooms"); + const again = reloaded.getRoom(room.id); + expect(again).toBeDefined(); + expect(again).not.toBe(room); + expect(reloaded.joinRoom(again!, "Bo", bo)).toEqual({ error: "that wizard name is taken in this room" }); + expect(reloaded.joinRoom(again!, "Bo", fresh)).toEqual({ token: fresh }); + expect(reloaded.joinRoom(again!, "Ann", ann)).toEqual({ token: ann }); + }); + + it("re-seats only a person who sits at the table", () => { + const { room, bot } = table(); + expect(rooms.reseat(room, bot)).toEqual({ error: "no person sits in that seat" }); + expect(rooms.reseat(room, "Cy")).toEqual({ error: "no person sits in that seat" }); + }); + + it("tells the keeper who sits where, and when each person last moved or spoke", () => { + const { room, bot } = table(); + rooms.addChat(room, "Bo", "good luck"); + const seats = rooms.seatsForKeeper(room); + expect(seats.map((s) => [s.name, s.bot, s.host])).toEqual([["Ann", false, true], ["Bo", false, false], [bot, true, false]]); + expect(seats[0]!.lastActed).toBeNull(); + expect(seats[1]!.lastActed).toMatch(/^\d{4}-/); + expect(seats[2]!.lastActed).toBeNull(); + }); +}); + +describe("the keeper's door", () => { + let door: import("node:http").Server; + let base: string; + const knock = (path: string, init: RequestInit = {}) => + fetch(`${base}${path}`, { ...init, headers: { "x-keeper-key": KEY, ...(init.headers as Record) } }); + beforeAll(async () => { + writeFileSync(KEY_FILE, KEY + "\n"); + door = keeper.openKeeperDoor({ port: 0, publicUrl: "https://wizwar.example", keyFile: KEY_FILE, onReseat: () => {} }); + await once(door, "listening"); + const address = door.address() as AddressInfo; + base = `http://127.0.0.1:${address.port}`; + }); + afterAll(() => door.close()); + + it("listens on the loopback alone", () => { + expect((door.address() as AddressInfo).address).toBe("127.0.0.1"); + }); + + it("lists a table's seats and re-seats one as a link carrying the token in the fragment", async () => { + const { room, bo } = table(); + const listed = await (await knock(`/keeper/rooms/${room.id}`)).json(); + expect(listed.seats.map((s: { name: string }) => s.name)).toContain("Bo"); + const res = await knock(`/keeper/rooms/${room.id.toLowerCase()}/reseat`, { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ seat: "Bo" }), + }); + expect(res.status).toBe(200); + const { name, link } = await res.json(); + expect(name).toBe("Bo"); + const m = new RegExp(`^https://wizwar\\.example/join/${room.id}#seat=Bo\\.([0-9a-f]{32})$`).exec(link); + expect(m).not.toBeNull(); + expect("error" in rooms.joinRoom(room, "Bo", bo)).toBe(true); + expect(rooms.joinRoom(room, "Bo", m![1]!)).toEqual({ token: m![1] }); + }); + + it("turns away a forwarded request, an unknown room and a bot", async () => { + const { room, bot } = table(); + expect((await knock(`/keeper/rooms/${room.id}`, { headers: { "x-forwarded-for": "203.0.113.9" } })).status).toBe(404); + expect((await knock(`/keeper/rooms/${room.id}X`)).status).toBe(404); + const res = await knock(`/keeper/rooms/${room.id}/reseat`, { method: "POST", body: JSON.stringify({ seat: bot }) }); + expect(res.status).toBe(404); + expect((await res.json()).error).toBe("no person sits in that seat"); + }); + + it("opens only to the keeper's key, and stays shut while the key file is absent", async () => { + const { room, bo } = table(); + const reseatBo = (headers: Record) => + fetch(`${base}/keeper/rooms/${room.id}/reseat`, { method: "POST", headers, body: JSON.stringify({ seat: "Bo" }) }); + expect((await reseatBo({})).status).toBe(404); + expect((await reseatBo({ "x-keeper-key": "b".repeat(64) })).status).toBe(404); + expect((await reseatBo({ "x-keeper-key": KEY.slice(1) })).status).toBe(404); + rmSync(KEY_FILE); + try { + expect((await reseatBo({ "x-keeper-key": KEY })).status).toBe(404); + expect((await reseatBo({ "x-keeper-key": "" })).status).toBe(404); + } finally { + writeFileSync(KEY_FILE, KEY + "\n"); + } + expect(rooms.joinRoom(room, "Bo", bo)).toEqual({ token: bo }); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index c7b37a2..e6b2735 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,5 +4,5 @@ "noEmit": true, "types": ["node"] }, - "include": ["src"] + "include": ["src", "test"] } diff --git a/packages/web/src/App.svelte b/packages/web/src/App.svelte index 41c8752..e0cf7db 100644 --- a/packages/web/src/App.svelte +++ b/packages/web/src/App.svelte @@ -1,6 +1,6 @@