// Static file server for the Brain Dashboard. Serves the repo root over // localhost so dashboard.html can fetch() episodes-*.jsonl (file:// cannot). // Run: node tools/brain-dashboard-server.mjs (npm run brain:dashboard) import { createServer as httpCreateServer } from 'node:http'; import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs'; import { join, resolve, extname, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..'); const PORT = Number(process.env.BRAIN_DASHBOARD_PORT) || 7700; const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.jsonl': 'application/x-ndjson; charset=utf-8', '.svg': 'image/svg+xml', }; export function contentType(ext) { return MIME[ext] || 'application/octet-stream'; } export function listEpisodeFiles(root) { const dir = join(root, 'docs', 'observer'); if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f)) .sort(); } // Resolve a URL path to an absolute path inside root; null if it escapes root. export function resolveStaticPath(urlPath, root) { const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, ''); // Use resolve for the traversal check (canonicalizes both sides consistently) const normRoot = resolve(root); const abs = resolve(normRoot, clean); if (abs !== normRoot && !abs.startsWith(normRoot + sep)) return null; // Return join-based path so callers get root-relative path with root's own separators return join(root, clean); } export function createServer(root = REPO_ROOT) { return httpCreateServer((req, res) => { const url = req.url || '/'; if (url.split('?')[0] === '/api/episodes') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(listEpisodeFiles(root))); return; } let path = url.split('?')[0]; if (path === '/') { // Redirect (not rewrite) so the browser's base URL becomes /docs/observer/, // which makes relative