// The front door for duels between people. Plain HTTP for actions, a // websocket only to say "something changed, fetch the view again". // // POST /api/rooms {name, size?} create a duel and take seat A // POST /api/rooms/:id/join {name} take the next seat // POST /api/rooms/:id/bot {token} seat a bot (any seated player may, before the duel begins) // POST /api/rooms/:id/begin {token} the host begins the duel with the wizards seated so far // GET /api/rooms/:id?token= the view for that seat; without a token, the gallery's view // POST /api/rooms/:id/turn {token, input} this seat's move // POST /api/rooms/:id/say {token, text} table talk, from a seat to the whole room // WS /ws?room=:id&token= {type:"update", seq} whenever the room changes; without a token, // a seat in the Peanut Gallery, counted for the table // POST /api/rooms/:id/report {token?, happened, expected} a report to the keeper, pinned to the turn // POST /api/reports/:id/image a screenshot for a report just filed // POST /api/reports/mine {seats: [{roomId, token}]} your reports and the keeper's replies // POST /api/reports/:id/answer {roomId, token, text} your word under the keeper's reply // // Caddy serves the static site and proxies /api and /ws here. import * as Sentry from '@sentry/node'; import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; import { WebSocketServer, WebSocket } from 'ws'; import { RateLimit } from './ratelimit'; import { IMAGE_MAX_BYTES, Reports } from './reports'; import { RoomError, Rooms, SPECTATOR } from './rooms'; import { Store } from './store'; import { dirname } from 'node:path'; const PORT = Number(process.env.PORT ?? 8788); const HOST = process.env.HOST ?? '127.0.0.1'; const DATA_DIR = process.env.WH_DATA_DIR ?? '../data/rooms'; const BODY_LIMIT = 16 * 1024; // Errors go to Sentry when a DSN is set; the SDK drops them otherwise. if (process.env.SENTRY_DSN) { Sentry.init({ dsn: process.env.SENTRY_DSN, environment: 'production', tracesSampleRate: 0 }); } process.on('unhandledRejection', (reason) => { console.error(reason); Sentry.captureException(reason); }); const rooms = new Rooms(new Store(DATA_DIR)); /** Reports live beside the room ledgers, not among them. */ const reports = new Reports(dirname(DATA_DIR)); const PUBLIC_URL = (process.env.WH_PUBLIC_URL ?? 'https://hands.kestrelsnest.social').replace(/\/$/, ''); /** Opening rooms and taking seats are open to anyone; a script gets a few dozen an hour, not thousands. */ const doors = new RateLimit(40, 60 * 60 * 1000); /** Table talk: a lively table says a few lines a minute, not hundreds. */ const voices = new RateLimit(240, 60 * 60 * 1000); /** Reports, answers and pictures: a few an hour from one address; each rings the keeper's phone. */ const desk = new RateLimit(6, 60 * 60 * 1000); const MAX_AUDIENCE = 30; const IDLE_ROOM_MS = 7 * 24 * 60 * 60 * 1000; setInterval(() => { const n = rooms.evictIdle(IDLE_ROOM_MS); doors.prune(); voices.prune(); desk.prune(); if (n) console.log(`evicted ${n} idle room${n === 1 ? '' : 's'} from memory; ${rooms.loaded} loaded`); }, 60 * 60 * 1000).unref(); /** The visitor's address as Caddy reports it, or the socket's when unproxied. */ function clientOf(req: IncomingMessage): string { const forwarded = req.headers['x-forwarded-for']; const first = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0].trim(); return first || req.socket.remoteAddress || '?'; } function send(res: ServerResponse, status: number, body: unknown): void { res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }); res.end(status === 204 ? undefined : JSON.stringify(body)); } function readBytes(req: IncomingMessage, limit: number): Promise { return new Promise((resolve, reject) => { const declared = Number(req.headers['content-length'] ?? 0); if (declared > limit) { reject(new RoomError('A picture of 2.5 MB at most.', 413)); req.destroy(); return; } let size = 0; const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > limit) { reject(new RoomError('A picture of 2.5 MB at most.', 413)); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => resolve(Buffer.concat(chunks))); req.on('error', reject); }); } /** A report or an answer is worth waking the keeper for; the desk's own replies are not. */ function ringBell(title: string, detail: { fingerprint: string[]; tags: Record; extra: Record }): void { Sentry.captureMessage(title, { level: 'error', ...detail }); } /** The seats a browser claims, kept to the ones its tokens prove. */ function provenSeats(raw: unknown): { roomId: string; seat: string }[] { const claims = Array.isArray(raw) ? raw.slice(0, 50) : []; const proven: { roomId: string; seat: string }[] = []; for (const claim of claims) { if (typeof claim !== 'object' || claim === null) continue; const c = claim as Record; try { const room = rooms.get(String(c.roomId ?? '')); proven.push({ roomId: room.id, seat: rooms.seatOf(room, typeof c.token === 'string' ? c.token : undefined).id }); } catch { // A seat this browser cannot prove is not its business. } } return proven; } async function handleReports(req: IncomingMessage, res: ServerResponse, parts: string[]): Promise { if (req.method !== 'POST') throw new RoomError('Not here.', 404); if (parts[2] === 'mine' && parts.length === 3) { const body = await readBody(req); return send(res, 200, { reports: reports.mine(provenSeats(body.seats)) }); } const report = reports.find(parts[2] ?? ''); if (!report) throw new RoomError('No such report.', 404); if (parts[3] === 'image') { if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429); reports.attachImage(report, await readBytes(req, IMAGE_MAX_BYTES)); return send(res, 204, {}); } if (parts[3] === 'answer') { if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429); const body = await readBody(req); const room = rooms.get(String(body.roomId ?? report.roomId)); const seat = rooms.seatOf(room, typeof body.token === 'string' ? body.token : undefined); if (room.id !== report.roomId) throw new RoomError('That report is not yours to answer.', 403); reports.answer(report, seat, body.text); ringBell(`${seat.name} answers on report ${report.id} (${room.id}): ${String(body.text ?? '').slice(0, 100)}`, { fingerprint: ['report-answer', report.id, new Date().toISOString()], tags: { room: room.id, report: report.id, player: seat.name }, extra: { text: body.text, link: `${PUBLIC_URL}/join/${room.id}` } }); return send(res, 200, { ok: true }); } throw new RoomError('Not here.', 404); } function readBody(req: IncomingMessage): Promise> { return new Promise((resolve, reject) => { let size = 0; const chunks: Buffer[] = []; req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > BODY_LIMIT) { reject(new RoomError('That is more than a turn needs.', 413)); req.destroy(); return; } chunks.push(chunk); }); req.on('end', () => { if (chunks.length === 0) return resolve({}); try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch { reject(new RoomError('The request was not JSON.')); } }); req.on('error', reject); }); } async function handle(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? '/', 'http://localhost'); const parts = url.pathname.split('/').filter(Boolean); if (parts[0] === 'api' && parts[1] === 'reports') return handleReports(req, res, parts); if (parts[0] !== 'api' || parts[1] !== 'rooms') throw new RoomError('Not here.', 404); if (parts.length === 2 && req.method === 'POST') { if (!doors.allow(clientOf(req))) throw new RoomError('Too many duels opened from here just now; try again later.', 429); const body = await readBody(req); const { room, seat } = rooms.create(String(body.name ?? ''), Number(body.size ?? 2)); return send(res, 201, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); } const room = rooms.get(parts[2] ?? ''); const action = parts[3]; if (!action && req.method === 'GET') { const token = url.searchParams.get('token'); if (!token) return send(res, 200, rooms.view(room, SPECTATOR)); return send(res, 200, rooms.view(room, rooms.seatOf(room, token).id)); } if (req.method !== 'POST') throw new RoomError('Not here.', 404); const body = await readBody(req); if (action === 'join') { if (!doors.allow(clientOf(req))) throw new RoomError('Too many seats taken from here just now; try again later.', 429); const seat = rooms.join(room, String(body.name ?? '')); return send(res, 200, { seat: seat.id, token: seat.token, view: rooms.view(room, seat.id) }); } if (action === 'bot') { rooms.addBot(room, typeof body.token === 'string' ? body.token : undefined); const seat = rooms.seatOf(room, body.token as string); return send(res, 200, rooms.view(room, seat.id)); } if (action === 'begin') { const token = typeof body.token === 'string' ? body.token : undefined; rooms.begin(room, token); const seat = rooms.seatOf(room, token); return send(res, 200, rooms.view(room, seat.id)); } if (action === 'turn') { const token = typeof body.token === 'string' ? body.token : undefined; rooms.submit(room, token, body.input); const seat = rooms.seatOf(room, token); return send(res, 200, rooms.view(room, seat.id)); } if (action === 'report') { if (!desk.allow(clientOf(req))) throw new RoomError('The desk has plenty from here for now; more in an hour.', 429); const token = typeof body.token === 'string' && body.token ? body.token : undefined; const seat = token ? rooms.seatOf(room, token) : null; const report = reports.file(room, seat, body.happened, body.expected); ringBell(`Report from ${report.player} in ${room.id}: ${report.happened.slice(0, 100)}`, { fingerprint: ['report', report.id], tags: { room: room.id, report: report.id, player: report.player }, extra: { happened: report.happened, expected: report.expected, turn: report.turn, seq: report.seq, link: `${PUBLIC_URL}/join/${room.id}` } }); return send(res, 201, { id: report.id }); } if (action === 'say') { if (!voices.allow(clientOf(req))) throw new RoomError('The table has heard enough from here for now.', 429); const token = typeof body.token === 'string' ? body.token : undefined; rooms.say(room, token, body.text); const seat = rooms.seatOf(room, token); return send(res, 200, rooms.view(room, seat.id)); } throw new RoomError('Not here.', 404); } const server = createServer((req, res) => { handle(req, res).catch((err: unknown) => { if (err instanceof RoomError) return send(res, err.status, { error: err.message }); console.error(err); Sentry.captureException(err, { extra: { url: req.url, method: req.method } }); send(res, 500, { error: 'Something went wrong on the server.' }); }); }); const wss = new WebSocketServer({ noServer: true }); server.on('upgrade', (req, socket, head) => { const url = new URL(req.url ?? '/', 'http://localhost'); if (url.pathname !== '/ws') return socket.destroy(); let seatId: string; let room: ReturnType; try { room = rooms.get(url.searchParams.get('room') ?? ''); const token = url.searchParams.get('token'); seatId = token ? rooms.seatOf(room, token).id : SPECTATOR; if (seatId === SPECTATOR && rooms.audience(room) >= MAX_AUDIENCE) throw new RoomError('The gallery is full.', 429); } catch { return socket.destroy(); } wss.handleUpgrade(req, socket, head, (ws: WebSocket) => { const onChange = (changed: ReturnType) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'update', seq: changed.seq })); }; const unsubscribe = seatId === SPECTATOR ? rooms.watch(room, onChange) : rooms.subscribe(room.id, onChange); ws.on('close', unsubscribe); ws.send(JSON.stringify({ type: 'hello', seat: seatId })); }); }); server.listen(PORT, HOST, () => { console.log(`waving-hands server listening on http://${HOST}:${PORT}, rooms in ${DATA_DIR}`); });