From 23036cb1dd604087154526af5e4dfdfd8f1a4108 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 8 Mar 2026 11:45:31 -0500 Subject: [PATCH] fix(web): route Mission Control API calls through orchestrator proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mission Control components were calling /api/mission-control/* which routes to mosaic-api.woltje.com (the main API) — those routes don't exist there, causing 404s. These routes live on the orchestrator service and must go through the Next.js server-side proxy (which injects ORCHESTRATOR_API_KEY): Changes: - Add catch-all proxy route at /api/orchestrator/[...path]/route.ts Forwards any GET/POST/PATCH/PUT/DELETE to ORCHESTRATOR_URL/ Replaces the need for per-endpoint proxy files for new routes. - Update all Mission Control components to call /api/orchestrator/api/mission-control/* instead of /api/mission-control/* - Update test expectations to match new paths --- .../app/api/orchestrator/[...path]/route.ts | 96 +++++++++++++++++++ .../mission-control/AuditLogDrawer.tsx | 2 +- .../mission-control/BargeInInput.test.tsx | 10 +- .../mission-control/BargeInInput.tsx | 2 +- .../GlobalAgentRoster.test.tsx | 2 +- .../mission-control/GlobalAgentRoster.tsx | 4 +- .../mission-control/KillAllDialog.test.tsx | 8 +- .../mission-control/KillAllDialog.tsx | 2 +- .../mission-control/PanelControls.test.tsx | 6 +- .../mission-control/PanelControls.tsx | 8 +- 10 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/app/api/orchestrator/[...path]/route.ts diff --git a/apps/web/src/app/api/orchestrator/[...path]/route.ts b/apps/web/src/app/api/orchestrator/[...path]/route.ts new file mode 100644 index 0000000..2b329a0 --- /dev/null +++ b/apps/web/src/app/api/orchestrator/[...path]/route.ts @@ -0,0 +1,96 @@ +import { type NextRequest, NextResponse } from "next/server"; + +const DEFAULT_ORCHESTRATOR_URL = "http://localhost:3001"; + +function getOrchestratorUrl(): string { + return ( + process.env.ORCHESTRATOR_URL ?? + process.env.NEXT_PUBLIC_ORCHESTRATOR_URL ?? + process.env.NEXT_PUBLIC_API_URL ?? + DEFAULT_ORCHESTRATOR_URL + ); +} + +/** + * Generic catch-all proxy for orchestrator API routes. + * + * Forwards any request to /api/orchestrator/ → ORCHESTRATOR_URL/ + * with the ORCHESTRATOR_API_KEY injected server-side so it never reaches the browser. + * + * Supports GET, POST, PATCH, DELETE, PUT. + * + * Example: + * GET /api/orchestrator/mission-control/sessions + * → GET ORCHESTRATOR_URL/api/mission-control/sessions + * POST /api/orchestrator/mission-control/sessions/abc/kill + * → POST ORCHESTRATOR_URL/api/mission-control/sessions/abc/kill + */ +async function proxyToOrchestrator( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +): Promise { + const orchestratorApiKey = process.env.ORCHESTRATOR_API_KEY; + if (!orchestratorApiKey) { + return NextResponse.json( + { error: "ORCHESTRATOR_API_KEY is not configured on the web server." }, + { status: 503 } + ); + } + + const { path } = await context.params; + const upstreamPath = `/${path.join("/")}`; + const search = request.nextUrl.search ?? ""; + const upstreamUrl = `${getOrchestratorUrl()}${upstreamPath}${search}`; + + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, 30_000); + + try { + const headers: Record = { + "X-API-Key": orchestratorApiKey, + }; + + const contentType = request.headers.get("Content-Type"); + if (contentType) { + headers["Content-Type"] = contentType; + } + + const body = + request.method !== "GET" && request.method !== "HEAD" + ? await request.text() + : undefined; + + const upstream = await fetch(upstreamUrl, { + method: request.method, + headers, + body, + cache: "no-store", + signal: controller.signal, + }); + + const responseText = await upstream.text(); + return new NextResponse(responseText, { + status: upstream.status, + headers: { + "Content-Type": + upstream.headers.get("Content-Type") ?? "application/json", + }, + }); + } catch (error) { + const message = + error instanceof Error && error.name === "AbortError" + ? "Orchestrator request timed out." + : "Unable to reach orchestrator."; + return NextResponse.json({ error: message }, { status: 502 }); + } finally { + clearTimeout(timeout); + } +} + +export const GET = proxyToOrchestrator; +export const POST = proxyToOrchestrator; +export const PATCH = proxyToOrchestrator; +export const PUT = proxyToOrchestrator; +export const DELETE = proxyToOrchestrator; diff --git a/apps/web/src/components/mission-control/AuditLogDrawer.tsx b/apps/web/src/components/mission-control/AuditLogDrawer.tsx index 5154c69..4f2fb0e 100644 --- a/apps/web/src/components/mission-control/AuditLogDrawer.tsx +++ b/apps/web/src/components/mission-control/AuditLogDrawer.tsx @@ -158,7 +158,7 @@ async function fetchAuditLog( } try { - return await apiGet(`/api/mission-control/audit-log?${params.toString()}`); + return await apiGet(`/api/orchestrator/api/mission-control/audit-log?${params.toString()}`); } catch (error) { if (isRateLimitError(error)) { return createEmptyAuditLogResponse(page, "Rate limited - retrying..."); diff --git a/apps/web/src/components/mission-control/BargeInInput.test.tsx b/apps/web/src/components/mission-control/BargeInInput.test.tsx index 83bce0e..6dbc0f7 100644 --- a/apps/web/src/components/mission-control/BargeInInput.test.tsx +++ b/apps/web/src/components/mission-control/BargeInInput.test.tsx @@ -59,7 +59,7 @@ describe("BargeInInput", (): void => { await user.click(screen.getByRole("button", { name: "Send" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-1/inject", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/session-1/inject", { content: "execute plan", }); }); @@ -83,12 +83,12 @@ describe("BargeInInput", (): void => { const calls = mockApiPost.mock.calls as [string, unknown?][]; - expect(calls[0]).toEqual(["/api/mission-control/sessions/session-2/pause", undefined]); + expect(calls[0]).toEqual(["/api/orchestrator/api/mission-control/sessions/session-2/pause", undefined]); expect(calls[1]).toEqual([ - "/api/mission-control/sessions/session-2/inject", + "/api/orchestrator/api/mission-control/sessions/session-2/inject", { content: "hello world" }, ]); - expect(calls[2]).toEqual(["/api/mission-control/sessions/session-2/resume", undefined]); + expect(calls[2]).toEqual(["/api/orchestrator/api/mission-control/sessions/session-2/resume", undefined]); }); it("submits with Enter and does not submit on Shift+Enter", async (): Promise => { @@ -105,7 +105,7 @@ describe("BargeInInput", (): void => { fireEvent.keyDown(textarea, { key: "Enter", code: "Enter", shiftKey: false }); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-3/inject", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/session-3/inject", { content: "first", }); }); diff --git a/apps/web/src/components/mission-control/BargeInInput.tsx b/apps/web/src/components/mission-control/BargeInInput.tsx index d83c735..16c7936 100644 --- a/apps/web/src/components/mission-control/BargeInInput.tsx +++ b/apps/web/src/components/mission-control/BargeInInput.tsx @@ -39,7 +39,7 @@ export function BargeInInput({ sessionId, onSent }: BargeInInputProps): React.JS } const encodedSessionId = encodeURIComponent(sessionId); - const baseEndpoint = `/api/mission-control/sessions/${encodedSessionId}`; + const baseEndpoint = `/api/orchestrator/api/mission-control/sessions/${encodedSessionId}`; let didPause = false; let didInject = false; diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx index d7e95cd..c76c714 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.test.tsx @@ -177,7 +177,7 @@ describe("GlobalAgentRoster", (): void => { fireEvent.click(screen.getByRole("button", { name: "Kill session killme12" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/killme123456/kill", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/killme123456/kill", { force: false, }); }); diff --git a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx index 41e950b..29ddf7e 100644 --- a/apps/web/src/components/mission-control/GlobalAgentRoster.tsx +++ b/apps/web/src/components/mission-control/GlobalAgentRoster.tsx @@ -83,7 +83,7 @@ function groupByProvider(sessions: MissionControlSession[]): ProviderSessionGrou async function fetchSessions(): Promise { const payload = await apiGet( - "/api/mission-control/sessions" + "/api/orchestrator/api/mission-control/sessions" ); return Array.isArray(payload) ? payload : payload.sessions; } @@ -118,7 +118,7 @@ export function GlobalAgentRoster({ const killMutation = useMutation({ mutationFn: async (sessionId: string): Promise => { - await apiPost<{ message: string }>(`/api/mission-control/sessions/${sessionId}/kill`, { + await apiPost<{ message: string }>(`/api/orchestrator/api/mission-control/sessions/${sessionId}/kill`, { force: false, }); return sessionId; diff --git a/apps/web/src/components/mission-control/KillAllDialog.test.tsx b/apps/web/src/components/mission-control/KillAllDialog.test.tsx index 6bdc56a..eb3c0d9 100644 --- a/apps/web/src/components/mission-control/KillAllDialog.test.tsx +++ b/apps/web/src/components/mission-control/KillAllDialog.test.tsx @@ -112,12 +112,12 @@ describe("KillAllDialog", (): void => { await user.click(screen.getByRole("button", { name: "Kill All Agents" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-1/kill", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/internal-1/kill", { force: true, }); }); - expect(mockApiPost).not.toHaveBeenCalledWith("/api/mission-control/sessions/external-1/kill", { + expect(mockApiPost).not.toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/external-1/kill", { force: true, }); expect(onComplete).toHaveBeenCalledTimes(1); @@ -141,10 +141,10 @@ describe("KillAllDialog", (): void => { await user.click(screen.getByRole("button", { name: "Kill All Agents" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/internal-2/kill", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/internal-2/kill", { force: true, }); - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/external-2/kill", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/external-2/kill", { force: true, }); }); diff --git a/apps/web/src/components/mission-control/KillAllDialog.tsx b/apps/web/src/components/mission-control/KillAllDialog.tsx index b093d89..b823cea 100644 --- a/apps/web/src/components/mission-control/KillAllDialog.tsx +++ b/apps/web/src/components/mission-control/KillAllDialog.tsx @@ -96,7 +96,7 @@ export function KillAllDialog({ sessions, onComplete }: KillAllDialogProps): Rea const killRequests = targetSessions.map(async (session) => { try { - await apiPost<{ message: string }>(`/api/mission-control/sessions/${session.id}/kill`, { + await apiPost<{ message: string }>(`/api/orchestrator/api/mission-control/sessions/${session.id}/kill`, { force: true, }); return true; diff --git a/apps/web/src/components/mission-control/PanelControls.test.tsx b/apps/web/src/components/mission-control/PanelControls.test.tsx index 5c43b2b..710998d 100644 --- a/apps/web/src/components/mission-control/PanelControls.test.tsx +++ b/apps/web/src/components/mission-control/PanelControls.test.tsx @@ -89,7 +89,7 @@ describe("PanelControls", (): void => { await waitFor((): void => { expect(mockApiPost).toHaveBeenCalledWith( - "/api/mission-control/sessions/session%20with%20space/pause", + "/api/orchestrator/api/mission-control/sessions/session%20with%20space/pause", undefined ); }); @@ -114,7 +114,7 @@ describe("PanelControls", (): void => { await user.click(screen.getByRole("button", { name: "Confirm" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-4/kill", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/session-4/kill", { force: false, }); }); @@ -137,7 +137,7 @@ describe("PanelControls", (): void => { await user.click(screen.getByRole("button", { name: "Confirm" })); await waitFor((): void => { - expect(mockApiPost).toHaveBeenCalledWith("/api/mission-control/sessions/session-5/kill", { + expect(mockApiPost).toHaveBeenCalledWith("/api/orchestrator/api/mission-control/sessions/session-5/kill", { force: true, }); }); diff --git a/apps/web/src/components/mission-control/PanelControls.tsx b/apps/web/src/components/mission-control/PanelControls.tsx index 0e3c761..fba94ee 100644 --- a/apps/web/src/components/mission-control/PanelControls.tsx +++ b/apps/web/src/components/mission-control/PanelControls.tsx @@ -50,23 +50,23 @@ export function PanelControls({ switch (action) { case "pause": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/pause` ); return { nextStatus: "paused" }; case "resume": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/resume` ); return { nextStatus: "active" }; case "graceful-kill": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, { force: false } ); return { nextStatus: "killed" }; case "force-kill": await apiPost<{ message: string }>( - `/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, + `/api/orchestrator/api/mission-control/sessions/${encodeURIComponent(sessionId)}/kill`, { force: true } ); return { nextStatus: "killed" };