import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { createLogger, isPlatform, loadConfig } from "@src/shared"; import { PostgresStore } from "@src/storage"; import { Queries } from "./queries.ts"; import { JobRunner } from "./jobs.ts"; /** * Control-plane + research API (§53, §63, §82). Plain node:http, JSON, SSE. * Consumed by the Next.js console (apps/dashboard) through its authenticated proxy; mutations require SRC_API_TOKEN. */ const log = createLogger("api"); const cfg = loadConfig(); const here = path.dirname(fileURLToPath(import.meta.url)); const publicDir = path.join(here, "..", "public"); const API_TOKEN = process.env.SRC_API_TOKEN ?? ""; const PORT = Number(process.env.SRC_API_PORT ?? cfg.dashboardPort); if (!cfg.databaseUrl) { console.error("SRC_DATABASE_URL is required for the API"); process.exit(1); } const store = await PostgresStore.connect(cfg.databaseUrl); await store.migrate(); const queries = new Queries(store, cfg); const jobs = new JobRunner(store, cfg, Number(process.env.SRC_MAX_JOBS ?? 2)); function json(res: http.ServerResponse, data: unknown, status = 200) { res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(data)); } function readBody(req: http.IncomingMessage): Promise { return new Promise((resolve, reject) => { let s = ""; req.on("data", (c) => { s += c; if (s.length > 1e6) reject(new Error("body too large")); }); req.on("end", () => resolve(s)); req.on("error", reject); }); } function authorized(req: http.IncomingMessage): boolean { if (!API_TOKEN) return true; // dev return req.headers["x-src-token"] === API_TOKEN; } const server = http.createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const parts = url.pathname.split("/").filter(Boolean); const method = req.method ?? "GET"; try { if (parts[0] !== "api" && parts[0] !== "media") { // legacy static console (apps/api/public) — kept for local debugging const file = path.join(publicDir, parts.length ? parts.join("/") : "index.html"); if (!file.startsWith(publicDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404); res.writeHead(200, { "content-type": file.endsWith(".html") ? "text/html; charset=utf-8" : "application/octet-stream" }); return fs.createReadStream(file).pipe(res); } if (parts[0] === "media") { const file = path.join(cfg.mediaDir, ...parts.slice(1).map(decodeURIComponent)); if (!file.startsWith(cfg.mediaDir) || !fs.existsSync(file)) return json(res, { error: "not found" }, 404); res.writeHead(200, { "content-type": "image/jpeg", "cache-control": "public, max-age=86400" }); return fs.createReadStream(file).pipe(res); } if (method !== "GET" && !authorized(req)) return json(res, { error: "unauthorized" }, 401); const [, r1, r2, r3] = parts; if (r1 === "health") return json(res, { ok: true, jobs_running: jobs.activeCount, db: true, ts: new Date().toISOString() }); if (r1 === "overview") return json(res, await queries.overview()); if (r1 === "platforms" && !r2) return json(res, queries.platforms()); if (r1 === "platforms" && r2) return isPlatform(r2) ? json(res, queries.platformDetail(r2)) : json(res, { error: "unknown platform" }, 404); if (r1 === "sessions" && !r2) return json(res, await queries.sessions(Number(url.searchParams.get("limit") ?? 100))); if (r1 === "sessions" && r2) { const id = r2; if (!r3) { const s = await queries.session(id); return s ? json(res, s) : json(res, { error: "not found" }, 404); } if (r3 === "events") return json(res, await store.sessionEvents(id, { types: url.searchParams.get("types")?.split(",").filter(Boolean), limit: Number(url.searchParams.get("limit") ?? 300) })); if (r3 === "actions") return json(res, await store.sessionActions(id)); if (r3 === "entities") return json(res, await store.sessionEntities(id, Number(url.searchParams.get("limit") ?? 500))); if (r3 === "media") return json(res, await store.sessionMedia(id)); if (r3 === "pages") return json(res, await queries.pages(id)); if (r3 === "world") return json(res, await queries.world(id)); if (r3 === "schemas") { const s = await queries.session(id); return json(res, await queries.schemas(s?.platform)); } if (r3 === "relationships") return json(res, await store.sessionRelationships(id)); } if (r1 === "entities" && !r2) return json(res, await queries.searchEntities({ q: url.searchParams.get("q") ?? undefined, type: url.searchParams.get("type") ?? undefined, platform: url.searchParams.get("platform") ?? undefined, sort: url.searchParams.get("sort") ?? undefined, limit: Math.min(200, Number(url.searchParams.get("limit") ?? 50)), offset: Number(url.searchParams.get("offset") ?? 0) })); if (r1 === "entities" && r2) { const e = await queries.entity(decodeURIComponent(r2)); return e ? json(res, e) : json(res, { error: "not found" }, 404); } if (r1 === "schemas") return json(res, await queries.schemas(url.searchParams.get("platform") ?? undefined)); if (r1 === "stats") return json(res, await store.entityStats()); if (r1 === "stream") { // Server-Sent Events: poll the observations table and push new rows (§53 live panels). res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" }); let since = url.searchParams.get("since") ?? new Date(Date.now() - 60_000).toISOString(); const session = url.searchParams.get("session") ?? undefined; const types = url.searchParams.get("types")?.split(",").filter(Boolean); let alive = true; req.on("close", () => (alive = false)); res.write(`event: hello\ndata: ${JSON.stringify({ since })}\n\n`); while (alive) { try { const rows = await queries.liveEvents(since, session, 200); for (const row of rows) { since = new Date(row.ts).toISOString(); if (types && !types.includes(row.event_type)) continue; res.write(`event: observation\ndata: ${JSON.stringify(row)}\n\n`); } res.write(`: ping ${Date.now()}\n\n`); } catch (err) { res.write(`event: error\ndata: ${JSON.stringify({ error: (err as Error).message })}\n\n`); } await new Promise((r) => setTimeout(r, 1500)); } return res.end(); } if (r1 === "jobs" && method === "GET" && !r2) return json(res, await queries.jobs()); if (r1 === "jobs" && method === "POST" && !r2) { const body = JSON.parse((await readBody(req)) || "{}"); try { return json(res, await jobs.start(body), 201); } catch (err) { return json(res, { error: (err as Error).message }, 400); } } if (r1 === "jobs" && r2 && r3 === "log") { res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" }); return res.end(jobs.logTail(r2, Number(url.searchParams.get("lines") ?? 200))); } if (r1 === "jobs" && r2 && r3 === "stop" && method === "POST") return json(res, { stopped: jobs.stop(r2) }); return json(res, { error: "not found" }, 404); } catch (err) { log.error("request failed", { url: req.url, err: (err as Error).message }); json(res, { error: (err as Error).message }, 500); } }); server.listen(PORT, "127.0.0.1", () => log.info(`api → http://127.0.0.1:${PORT} (token ${API_TOKEN ? "on" : "off"})`));