Files
wizwar6e/packages/server/src/index.ts
T
Eric WagonerandClaude Fable 5 ce8b6a315b Harden seat transfer phrases (security review finding)
Three words from 32 was ~15 bits — enumerable within a code's
lifetime against an unthrottled endpoint. Now: 64-word list, 4-word
phrases (~24 bits / 16.7M combinations), a per-connection cap of 5
failed claims, and a global backstop that voids all pending codes and
cools the endpoint after 30 failures in 10 minutes. Codes stay
speakable; a brute force now gets ~35 guesses against 16.7M before
the door closes.

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

182 lines
7.0 KiB
TypeScript

// Websocket front door. Protocol (JSON messages):
// client -> server:
// {type:"create", name} create a room, become host
// {type:"join", roomId, name} join (or rejoin) a room
// {type:"start"} host starts the game
// {type:"command", command} a game Command for the engine
// server -> client:
// {type:"welcome"} on connect
// {type:"room", roomId, players, hostId, started}
// {type:"events", events} redacted for this recipient
// {type:"state", view} redacted full view (after every change)
// {type:"error", message}
import { WebSocketServer, WebSocket } from "ws";
import type { Command, PlayerId } from "@wizwar/engine";
import {
claimTransferCode,
createRoom,
getRoom,
joinRoom,
loadPersistedRooms,
makeTransferCode,
redactFor,
runCommand,
startGame,
summarize,
viewForPlayer,
type Room,
} from "./rooms";
const port = Number(process.env.PORT ?? 8787);
loadPersistedRooms();
const wss = new WebSocketServer({ port });
interface Session {
socket: WebSocket;
playerId: PlayerId | null;
roomId: string | null;
claimFails: number;
}
const sessions = new Set<Session>();
function send(socket: WebSocket, message: unknown): void {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
}
function roomInfo(room: Room) {
return {
type: "room",
roomId: room.id,
players: room.players,
hostId: room.hostId,
started: room.state !== null,
};
}
function broadcast(room: Room, makeMessage: (playerId: PlayerId) => unknown): void {
for (const s of sessions) {
if (s.roomId === room.id && s.playerId) {
send(s.socket, makeMessage(s.playerId));
}
}
}
function broadcastRoomState(room: Room): void {
broadcast(room, (playerId) => roomInfo(room));
if (room.state) {
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
}
}
wss.on("connection", (socket) => {
const session: Session = { socket, playerId: null, roomId: null, claimFails: 0 };
sessions.add(session);
send(socket, { type: "welcome", game: "wizwar" });
socket.on("close", () => sessions.delete(session));
socket.on("message", (data) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(data.toString());
} catch {
return send(socket, { type: "error", message: "invalid JSON" });
}
try {
switch (msg.type) {
case "create": {
const name = String(msg.name ?? "").trim();
if (!name) return send(socket, { type: "error", message: "name required" });
const { room, token } = createRoom(name);
session.playerId = name;
session.roomId = room.id;
send(socket, { type: "seat", playerId: name, token });
broadcastRoomState(room);
break;
}
case "join": {
const name = String(msg.name ?? "").trim();
const roomId = String(msg.roomId ?? "").trim();
if (!name || !roomId) return send(socket, { type: "error", message: "name and roomId required" });
const room = getRoom(roomId);
if (!room) return send(socket, { type: "error", message: "no such room" });
const result = joinRoom(room, name, typeof msg.token === "string" ? msg.token : null);
if ("error" in result) return send(socket, { type: "error", message: result.error });
session.playerId = name;
session.roomId = room.id;
send(socket, { type: "seat", playerId: name, token: result.token });
// Rejoining a running game: replay the chronicle so far.
if (room.state) {
send(socket, { type: "events", events: redactFor(room.events, name) });
}
broadcastRoomState(room);
break;
}
case "start": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
if (session.playerId !== room.hostId) return send(socket, { type: "error", message: "only the host can start" });
const result = startGame(room, msg.expansion === true);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcastRoomState(room);
break;
}
case "command": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = runCommand(room, session.playerId, msg.command as Command);
if ("error" in result) return send(socket, { type: "error", message: result.error });
broadcast(room, (playerId) => ({ type: "events", events: redactFor(result.events, playerId) }));
broadcast(room, (playerId) => ({ type: "state", view: viewForPlayer(room, playerId) }));
break;
}
case "makeTransfer": {
const room = session.roomId ? getRoom(session.roomId) : undefined;
if (!room || !session.playerId) return send(socket, { type: "error", message: "not in a room" });
const result = makeTransferCode(room, session.playerId);
if ("error" in result) return send(socket, { type: "error", message: result.error });
send(socket, { type: "transferCode", code: result.code, expiresAt: result.expiresAt });
break;
}
case "claimTransfer": {
if (session.claimFails >= 5) {
return send(socket, { type: "error", message: "too many attempts on this connection — reconnect and mint a fresh phrase" });
}
const result = claimTransferCode(String(msg.code ?? ""));
if ("error" in result) {
session.claimFails++;
return send(socket, { type: "error", message: result.error });
}
session.claimFails = 0;
send(socket, { type: "transferClaimed", seat: result });
break;
}
case "myGames": {
// {seats: [{roomId, name, token}]} -> summaries for valid seats.
const seats = Array.isArray(msg.seats) ? msg.seats : [];
const games = [];
for (const seat of seats) {
const room = getRoom(String(seat.roomId ?? ""));
if (!room) continue;
const name = String(seat.name ?? "");
if (room.tokens.get(name) !== seat.token) continue;
games.push(summarize(room, name));
}
send(socket, { type: "games", games });
break;
}
default:
send(socket, { type: "error", message: `unknown message type: ${String(msg.type)}` });
}
} catch (e) {
send(socket, { type: "error", message: e instanceof Error ? e.message : "internal error" });
}
});
});
console.log(`wizwar server listening on ws://localhost:${port}`);