From 668010bcf2a2206324dcec4b72caceab365d3cf5 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 01:24:26 -0300 Subject: [PATCH] feat(api): traffic-inspector sessions + internal ingest routes (F6) --- .../internal/ingest/route.ts | 127 ++++++++++++++++++ .../sessions/[id]/export.har/route.ts | 61 +++++++++ .../traffic-inspector/sessions/[id]/route.ts | 123 +++++++++++++++++ .../tools/traffic-inspector/sessions/route.ts | 52 +++++++ 4 files changed, 363 insertions(+) create mode 100644 src/app/api/tools/traffic-inspector/internal/ingest/route.ts create mode 100644 src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts create mode 100644 src/app/api/tools/traffic-inspector/sessions/[id]/route.ts create mode 100644 src/app/api/tools/traffic-inspector/sessions/route.ts diff --git a/src/app/api/tools/traffic-inspector/internal/ingest/route.ts b/src/app/api/tools/traffic-inspector/internal/ingest/route.ts new file mode 100644 index 0000000000..cf47212a1f --- /dev/null +++ b/src/app/api/tools/traffic-inspector/internal/ingest/route.ts @@ -0,0 +1,127 @@ +/** + * POST /api/tools/traffic-inspector/internal/ingest + * + * Internal endpoint consumed by `server.cjs` (D4 fallback) to push + * intercepted request data into the traffic buffer when the request does + * NOT pass through a TypeScript handler that already calls + * `agentBridgeHook.ts`. + * + * Security model (double LOCAL_ONLY): + * 1. `isLocalOnlyPath("/api/tools/traffic-inspector/")` blocks all non- + * loopback callers unconditionally — this is handled by the authz pipeline. + * 2. The shared secret `INSPECTOR_INTERNAL_INGEST_TOKEN` (set in .env or + * auto-generated at process boot) must match the `Authorization: Bearer` + * header. This prevents any other loopback process from stuffing the buffer. + * + * Body: partial `InterceptedRequest` — only `id`, `timestamp`, `method`, + * `host`, `path` are required; all other fields default. + * + * LOCAL_ONLY enforced by routeGuard + token gate below. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { randomUUID } from "node:crypto"; +import { InterceptedRequestSchema } from "@/mitm/inspector/types"; +import { globalTrafficBuffer } from "@/mitm/inspector/buffer"; + +// ── Token management ──────────────────────────────────────────────────────── + +let _cachedToken: string | null = null; + +function getIngestToken(): string { + if (_cachedToken) return _cachedToken; + const env = process.env.INSPECTOR_INTERNAL_INGEST_TOKEN; + if (env && env.length >= 16) { + _cachedToken = env; + } else { + // Auto-generate on first call; persists for the lifetime of the process. + _cachedToken = randomUUID().replace(/-/g, ""); + } + return _cachedToken; +} + +function tokenMatches(received: string): boolean { + const expected = getIngestToken(); + if (!received || !expected) return false; + try { + const a = createHash("sha256").update(expected).digest(); + const b = createHash("sha256").update(received).digest(); + return timingSafeEqual(a, b); + } catch { + return false; + } +} + +// ── Partial schema (only required fields; rest optional) ─────────────────── + +const IngestBodySchema = InterceptedRequestSchema.partial().required({ + id: true, + timestamp: true, + method: true, + host: true, + path: true, + source: true, + requestHeaders: true, + requestSize: true, + responseHeaders: true, + responseSize: true, + status: true, +}); + +// ── Handler ───────────────────────────────────────────────────────────────── + +export async function POST(request: Request): Promise { + // Token gate (second layer after LOCAL_ONLY IP check). + const authHeader = request.headers.get("authorization") ?? ""; + const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""; + if (!tokenMatches(token)) { + return new Response(JSON.stringify(buildErrorBody(403, "Invalid or missing ingest token")), { + status: 403, + headers: { "content-type": "application/json" }, + }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = IngestBodySchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + try { + // Fill in any missing optional fields with sensible defaults. + const req = { + requestBody: null, + responseBody: null, + ...parsed.data, + }; + globalTrafficBuffer.push(req); + return Response.json({ ok: true, id: req.id }, { status: 200 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Ingest failed")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +/** + * Expose the auto-generated token for use by `server.cjs` bootstrap. + * Called once at process start via dynamic import. + */ +export function getIngestTokenForBootstrap(): string { + return getIngestToken(); +} diff --git a/src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts b/src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts new file mode 100644 index 0000000000..ba5301f168 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/sessions/[id]/export.har/route.ts @@ -0,0 +1,61 @@ +/** + * GET /api/tools/traffic-inspector/sessions/[id]/export.har + * + * Export all requests of a specific session as HAR v1.2. + * Secrets are always masked — see `toHar`. + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { getSession, getSessionRequests } from "@/lib/db/inspectorSessions"; +import { toHar } from "@/lib/inspector/harExport"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params): Promise { + const { id } = await params; + + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + const rows = getSessionRequests(id); + const requests: InterceptedRequest[] = rows + .map((r) => { + try { + return JSON.parse(r.payload) as InterceptedRequest; + } catch { + return null; + } + }) + .filter((r): r is InterceptedRequest => r !== null); + + const har = toHar(requests); + const sessionName = (session.name ?? `session-${id}`).replace(/[^a-z0-9_-]/gi, "_"); + const filename = `${sessionName}.har`; + + return new Response(JSON.stringify(har, null, 2), { + status: 200, + headers: { + "content-type": "application/json", + "content-disposition": `attachment; filename="${filename}"`, + "cache-control": "no-store", + }, + }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "HAR export failed")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/sessions/[id]/route.ts b/src/app/api/tools/traffic-inspector/sessions/[id]/route.ts new file mode 100644 index 0000000000..a8c752d9d6 --- /dev/null +++ b/src/app/api/tools/traffic-inspector/sessions/[id]/route.ts @@ -0,0 +1,123 @@ +/** + * GET /api/tools/traffic-inspector/sessions/[id] — session detail + requests + * PATCH /api/tools/traffic-inspector/sessions/[id] — stop or rename + * DELETE /api/tools/traffic-inspector/sessions/[id] — delete + cascade requests + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorSessionPatchSchema } from "@/shared/schemas/inspector"; +import { + getSession, + getSessionRequests, + stopSession, + renameSession, + deleteSession, +} from "@/lib/db/inspectorSessions"; + +interface Params { + params: Promise<{ id: string }>; +} + +export async function GET(_request: Request, { params }: Params): Promise { + const { id } = await params; + + try { + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + const requests = getSessionRequests(id).map((r) => { + try { + return JSON.parse(r.payload) as unknown; + } catch { + return r.payload; + } + }); + return Response.json({ session, requests }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to get session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function PATCH(request: Request, { params }: Params): Promise { + const { id } = await params; + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const parsed = InspectorSessionPatchSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + if (parsed.data.action === "stop") { + stopSession(id); + } else if (parsed.data.action === "rename") { + if (!parsed.data.name) { + return new Response( + JSON.stringify(buildErrorBody(400, "name is required for rename action")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + renameSession(id, parsed.data.name); + } + return Response.json(getSession(id)); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to update session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function DELETE(_request: Request, { params }: Params): Promise { + const { id } = await params; + + const session = getSession(id); + if (!session) { + return new Response(JSON.stringify(buildErrorBody(404, "Session not found")), { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + + try { + deleteSession(id); + return new Response(null, { status: 204 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to delete session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} diff --git a/src/app/api/tools/traffic-inspector/sessions/route.ts b/src/app/api/tools/traffic-inspector/sessions/route.ts new file mode 100644 index 0000000000..470cc486fc --- /dev/null +++ b/src/app/api/tools/traffic-inspector/sessions/route.ts @@ -0,0 +1,52 @@ +/** + * GET /api/tools/traffic-inspector/sessions — list all sessions + * POST /api/tools/traffic-inspector/sessions — start a new recording session + * + * LOCAL_ONLY enforced by routeGuard. + */ + +import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; +import { InspectorSessionStartSchema } from "@/shared/schemas/inspector"; +import { listSessions, createSession } from "@/lib/db/inspectorSessions"; + +export async function GET(): Promise { + try { + const sessions = listSessions(); + return Response.json({ sessions }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to list sessions")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +} + +export async function POST(request: Request): Promise { + let body: unknown; + try { + body = await request.json(); + } catch { + // Empty body is valid — name is optional + body = {}; + } + + const parsed = InspectorSessionStartSchema.safeParse(body); + if (!parsed.success) { + return new Response( + JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message ?? "Validation error")), + { status: 400, headers: { "content-type": "application/json" } } + ); + } + + try { + const session = createSession({ name: parsed.data.name }); + return Response.json(session, { status: 201 }); + } catch (err) { + const msg = sanitizeErrorMessage(err); + return new Response(JSON.stringify(buildErrorBody(500, msg || "Failed to create session")), { + status: 500, + headers: { "content-type": "application/json" }, + }); + } +}