import { createServer } from 'node:http'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { isIP } from 'node:net'; import { BusReadError, busStatus, subjectOk } from './bus.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', 'console.css', 'live.css', 'bus.css'].map(f => ['/' + f, [f, 'text/css; charset=utf-8']]), ...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${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) { 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 > 4096) throw new Error('body larger than 4096 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). const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail']; async function busRead(res, bus, verb, search) { let subject; if (verb === 'trail') { subject = new URLSearchParams(search).get('subject'); if (!subjectOk(subject)) return json(res, 400, { error: 'invalid-request', message: 'subject must be a decision id, message id or task ref' }); } if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' }); try { const rows = await bus[verb](subject); 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 }); } } export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null } = {}) { 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); 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; try { 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 !== `http://${req.headers.host}`) return json(res, 403, { error: 'cross-origin request refused' }); // 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 (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' }); if (path === '/api/config') return json(res, 200, { board: upstream }); 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); }); }); }