import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { isIP } from 'node:net'; import { BusReadError, busStatus, subjectOk, sendOutcome } from './bus.mjs'; import { ReadError, readStatus } from './reads.mjs'; export const DEFAULT_BOARD = 'http://127.0.0.1:7331'; export function isLoopback(host) { return host === 'localhost' || host === '::1' || (isIP(host) === 4 && host.startsWith('127.')); } export function boardURL(value) { const url = new URL(value); if (url.protocol !== 'http:' || !isLoopback(url.hostname.replace(/^\[|\]$/g, '')) || url.username || url.password || url.search || url.hash || url.pathname !== '/') { throw new Error('board must be an HTTP loopback origin without credentials, path, query or fragment'); } // Avoid hostname resolution for localhost. if (url.hostname === 'localhost') url.hostname = '127.0.0.1'; return url.origin; } const root = resolve(import.meta.dirname, 'public'); const files = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], ...['app.js', 'brand.js', 'bus.js'].map(f => ['/' + f, [f, 'text/javascript; charset=utf-8']]), ...['shared/app.css', 'tokens.css', 'console.css', 'live.css', 'bus.css', 'shell.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ['/icons.svg', ['icons.svg', 'image/svg+xml']], ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]), ...[400, 500, 700].map(w => [`/assets/fonts/jetbrains-mono-${w}.woff2`, [`assets/fonts/jetbrains-mono-${w}.woff2`, 'font/woff2']]), ]); function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); } async function body(req, max = 4096) { if ((req.headers['content-type'] || '').split(';')[0].trim().toLowerCase() !== 'application/json') throw new Error('Content-Type must be application/json'); const chunks = []; let size = 0; for await (const chunk of req) { size += chunk.length; if (size > max) throw new Error(`body larger than ${max} bytes`); chunks.push(chunk); } const bytes = Buffer.concat(chunks); const value = JSON.parse(bytes.toString('utf8')); if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('body must be a JSON object'); return bytes; } // Slice 1 S5 (#1522): GET only, the four Q1 verbs, nothing else (lead decision 56, Q4). // Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60 // (#1549) adds `credentials`: what the bus host loaded, metadata only. const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials']; const DECISION_LISTS = ['roles', 'recent']; async function busRead(res, bus, verb, search) { let arg; if (verb === 'trail') { arg = new URLSearchParams(search).get('subject'); if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); } if (verb === 'decisions') { arg = new URLSearchParams(search).get('list'); if (!DECISION_LISTS.includes(arg)) return json(res, 400, { error: 'invalid-request', message: 'list must be roles or recent' }); } if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); try { const rows = await bus[verb](arg); if (!Array.isArray(rows)) throw new BusReadError('invalid-response'); return json(res, 200, { rows, at: new Date().toISOString() }); } catch (err) { const code = err instanceof BusReadError ? err.code : 'invalid-response'; return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code }); } } // Row 76 (#1570, lead decision 95): with --send, the one POST preview mode allows, a human // message to a role. Checked here again whatever passed above: the header on this request, its own // Origin, Sec-Fetch-Site same-origin, JSON, 16 KiB, and only these keys. `to` and the body are the // broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown. const SEND_KEYS = ['to', 'body', 'in_reply_to']; const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null); async function busSend(req, res, { send, preview, log }) { if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' }); let to = null; // `to` is quoted and cut short so a crafted value can't forge a log line. const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`); // `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been. const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); }; if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin'); if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin'); let value; try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); } if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some(k => !SEND_KEYS.includes(k)) || !('to' in value) || !('body' in value)) return refuse(400, 'invalid-request', 'a send takes to, body and an optional in_reply_to, nothing else'); ({ to } = value); const args = { to: value.to, body: value.body }; if ('in_reply_to' in value) args.in_reply_to = value.in_reply_to; let result; try { result = await send(args); } catch (err) { const { code, sent } = sendOutcome(err); return sent === false ? refuse(busStatus(code), code, `not sent: the bus refused it (${code})`) : refuse(503, 'outcome-unknown', 'the message may have been sent; nothing was retried. Check the trail before sending again', 'unknown'); } if (!subjectOk(result?.id)) return refuse(503, 'outcome-unknown', 'the bus answered without a message id; the message may have been sent. Check the trail before sending again', 'unknown'); line(`id=${result.id}`); return json(res, 200, { id: result.id, at: new Date().toISOString() }); } // Row 54 (#1543): GET only. `reads` is consoleReads() from reads.mjs; its // bodies and messages arrive redacted. No route here writes or calls out. const QUEUE_ID = /^[1-9]\d{0,5}$/; async function consoleRead(res, read) { try { return json(res, 200, await read()); } catch (err) { const code = err instanceof ReadError ? err.code : 'invalid-response'; return json(res, readStatus(code), { error: code, message: err instanceof ReadError ? err.message : code }); } } // Row 74 (#1568, lead decision 93): the preview mode behind the edge proxy. Off unless // all three are given. The bind stays loopback; the proxy signs the user in // and sets the header; the Console checks it against the one user id and serves reads only. // Nothing here names an identity provider: one header name and one user id, from the flags. const HOSTNAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+$/; const HEADER = /^[a-z0-9][a-z0-9-]{0,63}$/; const USER = /^[\x21-\x2b\x2d-\x7e]{1,256}$/; export function previewOptions({ publicHost, authHeader, allowUser } = {}) { const given = [publicHost, authHeader, allowUser].filter(v => v !== undefined && v !== null); if (!given.length) return null; if (given.length !== 3) throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); const host = String(publicHost).toLowerCase(), header = String(authHeader).toLowerCase(); if (!HOSTNAME.test(host) || isIP(host)) throw new Error('public host must be a DNS name without scheme or port'); if (!HEADER.test(header) || ['host', 'origin'].includes(header)) throw new Error('auth header must be a header name other than Host or Origin'); if (typeof allowUser !== 'string' || !USER.test(allowUser)) throw new Error('allowed user must be 1..256 visible ASCII characters without a comma'); return { host, header, user: allowUser }; } // `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log` // takes one line per send attempt that got past sign-in; never the body. export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, send = null, log = line => console.log(line) } = {}) { if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host'); if (host === 'localhost') host = '127.0.0.1'; const upstream = boardURL(board); // An object with none of the three is refused too: asking for the preview never serves without it. if (preview !== null && !(preview = previewOptions({ publicHost: preview.host, authHeader: preview.header, allowUser: preview.user }))) { throw new Error('preview mode needs --public-host, --auth-header and --allow-user together'); } if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)'); const server = createServer(async (req, res) => { res.setHeader('x-content-type-options', 'nosniff'); res.setHeader('referrer-policy', 'no-referrer'); res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'"); let path, search; // Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is // never legitimate, so it is refused before any check reads the Host. if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' }); // Preview mode: a request for the public name, exactly, as the proxy forwards it. const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host; // HEADER with any other Host comes from a proxy that rewrote Host (nginx's default) and would // otherwise land on the loopback path unchecked. The operator's browser never sends it. if (preview !== null && !named && req.headersDistinct[preview.header]) return json(res, 403, { error: 'proxy Host refused', message: 'the proxy must forward Host unchanged' }); try { if (!named) { const authority = new URL(`http://${req.headers.host}`); if (!isLoopback(authority.hostname.replace(/^\[|\]$/g, '')) || Number(authority.port || 80) !== server.address().port) return json(res, 403, { error: 'non-local Host refused' }); } ({ pathname: path, search } = new URL(req.url, 'http://localhost')); } catch { return json(res, 400, { error: 'invalid URL' }); } // No CORS. JSON content type and same-origin checks keep browser forms out. if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' }); if (named) { // One value, equal to the user id; the proxy strips client copies and sets it after sign-in. const values = req.headersDistinct[preview.header]; if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' }); } // With --send, POST /api/bus/message is the one exception; it checks the header again itself. if (send && path === '/api/bus/message') { if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); return busSend(req, res, { send, preview, log }); } // Nothing else but GET and HEAD gets past here in preview mode, whatever the Host. if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' }); // The conversation routes (#1507, CHAT-02) keep their query; the board validates it. const conversation = path === '/api/conversations' || path === '/api/conversation'; const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null; if (allowed) { if (req.method !== allowed) return json(res, 405, { error: 'method not allowed' }); let bytes; if (allowed === 'POST') { try { bytes = await body(req); } catch (err) { return json(res, 400, { error: err.message }); } } try { const response = await fetch(upstream + path + (conversation ? search : ''), { method: allowed, headers: bytes ? { 'content-type': 'application/json' } : {}, body: bytes, redirect: 'error', signal: AbortSignal.timeout(timeout), }); const text = await response.text(); JSON.parse(text); // Never serve upstream HTML or scripts as API data. res.writeHead(response.status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(text); } catch { return json(res, 502, { error: `Board unreachable or invalid response at ${upstream}. Check the board server. No automatic action retry.`, board: upstream }); } } if (path.startsWith('/api/bus/')) { const verb = path.slice('/api/bus/'.length); if (!BUS_VERBS.includes(verb)) return json(res, 404, { error: 'not found' }); if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); return busRead(res, bus, verb, search); } if (path === '/api/queue' || path.startsWith('/api/queue/') || path === '/api/business' || path === '/api/settings') { if (req.method !== 'GET') return json(res, 405, { error: 'method not allowed' }); const a = server.address(), address = `http://${a.family === 'IPv6' ? `[${a.address}]` : a.address}:${a.port}/`; if (path === '/api/settings') return consoleRead(res, async () => ({ ...(reads ? await reads.settings() : { release: null, business: null, notifier: { refused: 'not-configured', message: 'the Console has no reads configured' } }), board: upstream, address })); if (!reads) return json(res, 503, { error: 'not-configured', message: 'the Console has no reads configured' }); if (path === '/api/business') return consoleRead(res, () => reads.business()); if (path === '/api/queue') return consoleRead(res, () => reads.queue()); const id = path.slice('/api/queue/'.length); if (!QUEUE_ID.test(id)) return json(res, 400, { error: 'invalid-request', message: 'a queue row id is a positive integer' }); return consoleRead(res, () => reads.row(Number(id))); } if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' }); // `send`: the page shows the composer only where a send can pass (the public name, --send on). if (path === '/api/config') return json(res, 200, { board: upstream, send: Boolean(send) && named }); if (path === '/healthz') return json(res, 200, { ok: true }); if (path === '/favicon.ico') { res.writeHead(204); return res.end(); } const file = files.get(path); if (!file) return json(res, 404, { error: 'not found' }); try { const content = readFileSync(resolve(root, file[0])); res.writeHead(200, { 'content-type': file[1], 'cache-control': 'no-store' }); res.end(req.method === 'HEAD' ? undefined : content); } catch { json(res, 500, { error: 'WebUI asset unavailable' }); } }); return new Promise((resolvePromise, reject) => { server.once('error', reject); server.listen(port, host, () => { server.off('error', reject); resolvePromise(server); }); }); }