import { Effect } from "effect"; import type { AppsRuntime } from "../plugin/runtime"; import type { Bindings } from "../plugin/bindings"; // --------------------------------------------------------------------------- // Apps HTTP surface — a plain web handler (Request -> Response) the self-host // app mounts as an extension route under `/api/apps/*`. Covers: // POST /api/apps/:scope/publish { files } -> descriptor // GET /api/apps/:scope/descriptor -> descriptor // POST /api/apps/:scope/tools/:tool { args, bindings } -> result // POST /api/apps/:scope/workflows/:wf/start { input, bindings } -> run // POST /api/apps/:scope/workflows/runs/:runId/signal { event, payload } -> run // GET /api/apps/:scope/workflows/runs/:runId -> run + steps // GET /api/apps/:scope/workflows/runs -> runs // GET /api/apps/:scope/ui/:name -> compiled bundle (JS) // GET /api/apps/:scope/live (SSE) -> invalidations // // It's deliberately transport-thin: all logic is in AppsRuntime. // --------------------------------------------------------------------------- export interface AppsHttpDeps { readonly runtime: AppsRuntime; /** Mount prefix (default "/api/apps"). */ readonly prefix?: string; /** * Authenticate an inbound request. Returns `true` when the caller is * authorized and `false` otherwise; the handler answers `false` with a 401. * * The apps surface (publish / invoke / workflow lifecycle / SSE) mutates * per-scope state and reaches real integrations, so it MUST be behind the same * credential the rest of `/api` requires. This seam lets the host thread its * own identity check (self-host's Better Auth session/bearer/api-key) in * without this package importing the host's auth stack. When omitted (tests * that drive the runtime directly) every request is allowed. */ readonly authenticate?: (request: Request) => Promise; } const json = (body: unknown, status = 200): Response => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); const run = (effect: Effect.Effect): Promise => Effect.runPromise(effect as never); const unknownMessage = (cause: unknown): string => { // oxlint-disable-next-line executor/no-instanceof-error, executor/no-unknown-error-message -- boundary: HTTP adapter preserves existing error response text return cause instanceof Error ? cause.message : String(cause); }; export const makeAppsHttpRoutes = ( deps: AppsHttpDeps, ): { readonly path: string; readonly handler: (request: Request) => Promise } => { const prefix = deps.prefix ?? "/api/apps"; const runtime = deps.runtime; const handler = async (request: Request): Promise => { const url = new URL(request.url); if (!url.pathname.startsWith(prefix)) return new Response("not found", { status: 404 }); // Auth gate: the whole apps surface (publish/invoke/workflows/ui/SSE) is // behind the host's identity check. An unauthenticated caller gets a 401 // BEFORE any route logic runs — including the SSE stream. if (deps.authenticate) { const ok = await deps.authenticate(request).then( (value) => value, () => false, ); if (!ok) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "content-type": "application/json" }, }); } } const rest = url.pathname.slice(prefix.length).replace(/^\//, ""); const parts = rest.split("/").filter(Boolean); // parts[0] = scope const scope = parts[0]; if (!scope) return json({ error: "scope required" }, 400); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: HTTP handler converts route/runtime failures into JSON 400 responses try { // POST :scope/publish if (parts[1] === "publish" && request.method === "POST") { const body = (await request.json()) as { files: Record; message?: string }; const files = new Map(Object.entries(body.files ?? {})); const out = await run(runtime.publish({ scope, files, message: body.message })); return json({ snapshotId: out.snapshotId, descriptor: out.descriptor }); } // GET :scope/descriptor if (parts[1] === "descriptor" && request.method === "GET") { const descriptor = await run(runtime.getDescriptor(scope)); return json({ descriptor }); } // POST :scope/tools/:tool if (parts[1] === "tools" && parts[2] && request.method === "POST") { const body = (await request.json()) as { args?: unknown; bindings?: Bindings }; const result = await run( runtime.invokeTool({ scope, tool: parts[2], args: body.args ?? {}, bindings: body.bindings ?? {}, }), ); return json({ result }); } // POST :scope/workflows/:wf/start if (parts[1] === "workflows" && parts[3] === "start" && request.method === "POST") { const body = (await request.json()) as { input?: unknown; bindings?: Bindings; runId?: string; }; const runView = await run( runtime.startWorkflow({ scope, workflow: parts[2], input: body.input, bindings: body.bindings, runId: body.runId, }), ); return json({ run: runView }); } // POST :scope/workflows/runs/:runId/signal if ( parts[1] === "workflows" && parts[2] === "runs" && parts[3] && parts[4] === "signal" && request.method === "POST" ) { const body = (await request.json()) as { event: string; payload?: unknown }; const runView = await run( runtime.signalWorkflow({ scope, runId: parts[3], event: body.event, payload: body.payload, }), ); return json({ run: runView }); } // GET :scope/workflows/runs/:runId if (parts[1] === "workflows" && parts[2] === "runs" && parts[3] && request.method === "GET") { const runView = await run(runtime.getRun(parts[3])); const steps = await run(runtime.listSteps(parts[3])); return json({ run: runView, steps }); } // GET :scope/workflows/runs if ( parts[1] === "workflows" && parts[2] === "runs" && !parts[3] && request.method === "GET" ) { const runs = await run(runtime.listRuns(scope)); return json({ runs }); } // GET :scope/ui/:name -> compiled JS bundle, OR the self-booting HTML // document when `?document=html` (Fix 10: the fallback a non-UI MCP client // opens in a browser). Both are behind the same auth gate as the rest of // the surface. if (parts[1] === "ui" && parts[2] && request.method === "GET") { if (url.searchParams.get("document") === "html") { const doc = await run(runtime.getUiDocument(scope, parts[2])); if (!doc) return new Response("ui not found", { status: 404 }); return new Response(doc.html, { status: 200, headers: { "content-type": "text/html; charset=utf-8", "x-ui-title": doc.title ?? "", "x-ui-max-height": String(doc.maxHeight ?? ""), }, }); } const bundle = await run(runtime.getUiBundle(scope, parts[2])); if (!bundle) return new Response("ui not found", { status: 404 }); return new Response(bundle.code, { status: 200, headers: { "content-type": "application/javascript", "x-ui-title": bundle.title ?? "", "x-ui-max-height": String(bundle.maxHeight ?? ""), }, }); } // GET :scope/live -> SSE invalidations if (parts[1] === "live" && request.method === "GET") { return sseResponse(scope, runtime); } return new Response("not found", { status: 404 }); } catch (cause) { return json({ error: unknownMessage(cause) }, 400); } }; return { path: `${prefix}/*`, handler }; }; // An SSE stream of `{table, version}` invalidations for a scope. Each scope-db // write bumps a counter and publishes here; a `: keepalive` comment holds the // connection open. const sseResponse = (scope: string, runtime: AppsRuntime): Response => { let unsubscribe: (() => void) | undefined; let keepalive: ReturnType | undefined; const stream = new ReadableStream({ start(controller) { const enc = new TextEncoder(); controller.enqueue(enc.encode(`event: ready\ndata: {"scope":"${scope}"}\n\n`)); unsubscribe = runtime.subscribeLive(scope, (event) => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: ReadableStream enqueue throws after the client closes try { controller.enqueue( enc.encode( `event: invalidate\ndata: ${JSON.stringify({ scope, table: event.table, version: event.version })}\n\n`, ), ); } catch { /* controller closed */ } }); keepalive = setInterval(() => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: keepalive enqueue is best-effort after disconnect try { controller.enqueue(enc.encode(`: keepalive\n\n`)); } catch { /* closed */ } }, 15_000); }, cancel() { unsubscribe?.(); if (keepalive) clearInterval(keepalive); }, }); return new Response(stream, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive", }, }); };