/** * Serve the Console on port 6533 — SPA + operator flows + live channel. * * Host/Origin validation is mandatory (console §10.1). Security headers and * SameSite=Strict cookies are applied from the first response. */ import { join } from "node:path"; import { checkRequestSecurity, forbiddenResponse, resolveAllowedHosts, secureFetch, } from "../../runtime/security.ts"; import { runWithDevSurface } from "../../runtime/dev-request-log.ts"; import { CONSOLE_PORT, type ServerHandle } from "../../runtime/types.ts"; import { bindConsoleListRuns, bindManifestSignalBus, bindManifestStoreRuntime, bindManifestVaultRuntime, bootConsoleApp, createConsoleApp, wrapConsoleRunsForLive, type ConsoleAppHandle, type CreateConsoleAppOptions, } from "./app.ts"; export type { ConsoleAppHandle }; import { createLiveWebsocket, type ConsoleLiveData } from "./live.ts"; import { openConsolePersistence } from "./operator-db.ts"; import { handleRunsIngest, RUNS_INGEST_PATH } from "./runs-ingest.ts"; import { CONSOLE_COOKIES, CONSOLE_VITE_DEV_CSP, consoleSessionCookie, withConsoleSecurityHeaders, } from "./security-headers.ts"; /** Console SPA pathnames that receive `index.html`. Anything else is 404. */ export const CONSOLE_SPA_PATHS = [ "/", "/overview", "/flows", "/flows/decisions", "/store", "/vault", "/observability", "/monitoring", ] as const; /** * Whether `pathname` is a real Console page (not a legacy alias). * * @param pathname - Request pathname */ export function isConsoleSpaPath(pathname: string): boolean { return (CONSOLE_SPA_PATHS as readonly string[]).includes(pathname); } /** * Whether `pathname` is owned by the Console kernel (never Vite-proxied). * * @param pathname - Request pathname */ export function isConsoleKernelPath(pathname: string): boolean { return pathname.startsWith("/console/") || pathname.startsWith("/plugin-frame/"); } /** * Filesystem directory of the prebuilt Console SPA (`ui-next/dist`). * * Must be a real OS path. `new URL(...).pathname` on Windows is `/C:/…`, which * `Bun.file` cannot open — the kernel then serves the "Shell assets not built" * fallback even when the SPA is on disk. * * @param override - Explicit directory from {@link ServeConsoleOptions.staticDir} */ export function resolveConsoleStaticDir(override?: string): string { return override ?? join(import.meta.dir, "../ui-next/dist"); } /** * Use the caller's `oke.config` when given; otherwise load it from `cwd` * so Console sees `drivers.vault` (built-in vs the env default). * Missing or unreadable config stays `null` — framework defaults apply. * * @param cwd - Project root * @param explicit - Already-loaded config, or `null` when the caller tried */ async function resolveServeOkeConfig( cwd: string, explicit: CreateConsoleAppOptions["okeConfig"], ): Promise { if (explicit !== undefined) return explicit; try { const { loadOkeConfig } = await import("../../cli/load-config.ts"); return (await loadOkeConfig(cwd)).config; } catch { return null; } } /** Options for {@link serveConsole}. */ export interface ServeConsoleOptions extends CreateConsoleAppOptions { readonly port?: number; readonly hostname?: string; readonly allowedHosts?: readonly string[]; /** Directory of prebuilt SPA assets (Vite outDir). */ readonly staticDir?: string; /** * When set, non-kernel paths (`/`, `/@vite/client`, `/src/…`) are * proxied to a Vite HMR server. Kernel routes stay local. */ readonly spaProxy?: { readonly origin: string; }; /** Boot environment — use `"dev"` / `"prod"` so Bearer auth is production-like. */ readonly env?: "dev" | "prod" | "test"; /** * Persist operators in Postgres `oke_console` (+ `.oke/console.secret`). * Default true. Set false for ephemeral test servers. */ readonly persist?: boolean; } /** Running Console server handle. */ export interface ConsoleServerHandle extends ServerHandle { readonly console: ConsoleAppHandle; } /** * Boot and serve the Console kernel. * * @param options - Port, static dir, claim options */ export async function serveConsole( options: ServeConsoleOptions = {}, ): Promise { const hostname = options.hostname ?? "127.0.0.1"; const port = options.port ?? CONSOLE_PORT; const cwd = options.cwd ?? process.cwd(); const okeConfig = await resolveServeOkeConfig(cwd, options.okeConfig); const wantPersist = options.persist !== false && options.operators === undefined; const persistence = wantPersist ? await openConsolePersistence(cwd, { envSecret: options.secret ?? process.env.OKE_CONSOLE_SECRET, }) : null; const handle = createConsoleApp({ ...options, cwd, okeConfig, silentClaim: options.silentClaim ?? false, ...(persistence ? { secret: options.secret ?? persistence.secret, operators: persistence.operators, sessions: persistence.sessions, persistOperator: persistence.persistOperator, persistSessions: persistence.persistSessions, } : {}), }); // Console serve defaults to `test` drivers when compose isn't up; pass // `env: "dev"` only with compose URLs (or memory overrides) available. // Never inherit `OKE_DOCKER=1` from `oke dev` into capability minting — // Console operator flows don't stamp Manifest effects, and docker+dev // would otherwise hard-fail with OKE1020 on the first undeclared flow // (`console.setup.status`). Compose URLs for Manifest panels bind via // `bindManifest*` below, not this boot's driver map. const env = options.env ?? "test"; await handle.app.boot({ env: env === "dev" || env === "test" || env === "prod" ? env : "test", docker: false, }); wrapConsoleRunsForLive(handle); bindConsoleListRuns(handle); await bindManifestSignalBus(handle.state); await bindManifestStoreRuntime(handle.state); await bindManifestVaultRuntime(handle.state); const staticDir = resolveConsoleStaticDir(options.staticDir); const live = createLiveWebsocket(handle.state); const allowed = resolveAllowedHosts(hostname, options.allowedHosts); const appFetch = async (request: Request): Promise => { const url = new URL(request.url); if (url.pathname === "/console/live") { return withConsoleSecurityHeaders( new Response("Expected WebSocket upgrade", { status: 426 }), ); } // Host → Console WideEvent bridge (oke dev). Secret-gated; not an operator flow. if (url.pathname === RUNS_INGEST_PATH) { return withConsoleSecurityHeaders(await handleRunsIngest(request, handle)); } const authed = withCookieAuth(request); if (url.pathname.startsWith("/agent/") && handle.state.forwardAgent) { return handle.state.forwardAgent(request); } if (url.pathname.startsWith("/console/")) { const response = await runWithDevSurface("Console", () => handle.app.fetch(authed)); const withCookies = await attachSessionCookies(authed, response); return withConsoleSecurityHeaders(withCookies); } if (url.pathname.startsWith("/plugin-frame/")) { return withConsoleSecurityHeaders(pluginFrameResponse(url.pathname)); } if (options.spaProxy && !isConsoleKernelPath(url.pathname)) { const proxied = await proxySpa(options.spaProxy.origin, request); if (proxied) { return withConsoleSecurityHeaders(proxied, { csp: CONSOLE_VITE_DEV_CSP }); } } const staticResponse = await serveStatic(staticDir, url.pathname); if (staticResponse) { return withConsoleSecurityHeaders(staticResponse); } const spa = isConsoleSpaPath(url.pathname); const index = Bun.file(join(staticDir, "index.html")); if (await index.exists()) { return withConsoleSecurityHeaders( new Response(index, { status: spa ? 200 : 404, headers: { "Content-Type": "text/html; charset=utf-8" }, }), ); } return withConsoleSecurityHeaders( new Response(spa ? fallbackShellHtml() : notFoundHtml(), { status: spa ? 200 : 404, headers: { "Content-Type": "text/html; charset=utf-8" }, }), ); }; const fetchHandler = secureFetch(appFetch, options, hostname); const server = Bun.serve({ port, hostname, fetch(req, srv) { const url = new URL(req.url); if (url.pathname === "/console/live") { const check = checkRequestSecurity(req, allowed); if (!check.ok) return forbiddenResponse(check.reason); const upgraded = srv.upgrade(req, { data: { kind: "console-live" } }); if (upgraded) return undefined as unknown as Response; return withConsoleSecurityHeaders( new Response("WebSocket upgrade failed", { status: 400 }), ); } return (async () => { const res = await fetchHandler(req); const ct = (res.headers.get("content-type") ?? "").split(";")[0]?.trim() ?? ""; if (/^text\/event-stream$/i.test(ct)) srv.timeout(req, 0); return res; })(); }, websocket: { open: live.open, message: live.message, close: live.close, }, }); const boundPort = server.port ?? port; const boundHost = server.hostname ?? hostname; const hostForUrl = boundHost.includes(":") && !boundHost.startsWith("[") ? `[${boundHost}]` : boundHost; const url = new URL(`http://${hostForUrl}:${boundPort}/`); const closeIdle = ( server as typeof server & { closeIdleConnections(): void } ).closeIdleConnections.bind(server); const onPressure = (): void => { closeIdle(); }; const proc = process as NodeJS.EventEmitter; proc.on("memoryPressure", onPressure); return { console: handle, url, port: boundPort, hostname: boundHost, fetch: fetchHandler, stop(closeActive = false) { proc.off("memoryPressure", onPressure); void handle.app.stop(); void persistence?.close(); return server.stop(closeActive); }, }; } /** * Create + boot without listening (unit tests). * * @param options - Console options */ export async function startConsoleApp( options: CreateConsoleAppOptions & { readonly env?: "dev" | "prod" | "test"; /** Opt-in durable operators in `oke_console` (default false for unit tests). */ readonly persist?: boolean; } = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); const okeConfig = await resolveServeOkeConfig(cwd, options.okeConfig); const persistence = options.persist === true && options.operators === undefined ? await openConsolePersistence(cwd, { envSecret: options.secret ?? process.env.OKE_CONSOLE_SECRET, }) : null; const handle = createConsoleApp({ ...options, cwd, okeConfig, silentClaim: options.silentClaim ?? true, ...(persistence ? { secret: options.secret ?? persistence.secret, operators: persistence.operators, sessions: persistence.sessions, persistOperator: persistence.persistOperator, persistSessions: persistence.persistSessions, } : {}), }); await bootConsoleApp(handle); return handle; } /** * Promote session cookie → Authorization when Bearer is absent. * * @param request - Incoming request */ export function withCookieAuth(request: Request): Request { if (request.headers.get("authorization")?.startsWith("Bearer ")) { return request; } const cookie = request.headers.get("cookie") ?? ""; const match = cookie.match(new RegExp(`(?:^|;\\s*)${CONSOLE_COOKIES.access}=([^;]+)`)); if (!match?.[1]) return request; const token = decodeURIComponent(match[1]); const headers = new Headers(request.headers); headers.set("Authorization", `Bearer ${token}`); return new Request(request, { headers }); } /** * After successful claim/login, mirror tokens into SameSite=Strict cookies. * * @param request - Request * @param response - Flow response */ export async function attachSessionCookies( request: Request, response: Response, ): Promise { if (response.status >= 400) return response; const url = new URL(request.url); const isSessionIssue = url.pathname === "/console/setup/claim" || url.pathname === "/console/session/login"; const isLogout = url.pathname === "/console/session/logout"; if (!isSessionIssue && !isLogout) return response; let body: { data?: { accessToken?: string; refreshToken?: string }; }; try { body = (await response.clone().json()) as typeof body; } catch { return response; } const headers = new Headers(response.headers); if (isLogout) { headers.append("Set-Cookie", consoleSessionCookie(CONSOLE_COOKIES.access, "", { clear: true })); headers.append( "Set-Cookie", consoleSessionCookie(CONSOLE_COOKIES.refresh, "", { clear: true }), ); } else if (body.data?.accessToken && body.data?.refreshToken) { headers.append( "Set-Cookie", consoleSessionCookie(CONSOLE_COOKIES.access, body.data.accessToken, { maxAgeSec: 14 * 60, }), ); headers.append( "Set-Cookie", consoleSessionCookie(CONSOLE_COOKIES.refresh, body.data.refreshToken, { maxAgeSec: 30 * 24 * 60 * 60, }), ); } return new Response(JSON.stringify(body), { status: response.status, headers, }); } /** * Forward a browser request to the Vite HMR origin. * * @param origin - Vite `http://127.0.0.1:` * @param request - Incoming Console request */ export async function proxySpa(origin: string, request: Request): Promise { const incoming = new URL(request.url); const target = new URL(incoming.pathname + incoming.search, origin); const headers = new Headers(); for (const [key, value] of request.headers) { const lower = key.toLowerCase(); if (lower === "host" || lower === "connection" || lower === "keep-alive") continue; headers.set(key, value); } headers.set("host", target.host); try { const method = request.method; return await fetch(target, { method, headers, body: method === "GET" || method === "HEAD" ? undefined : request.body, redirect: "manual", }); } catch { return null; } } async function serveStatic(staticDir: string, pathname: string): Promise { if (pathname.includes("..")) return null; const path = pathname === "/" ? "index.html" : pathname.replace(/^\//, ""); const file = Bun.file(join(staticDir, path)); if (!(await file.exists())) return null; return new Response(file); } function pluginFrameResponse(pathname: string): Response { const id = pathname.slice("/plugin-frame/".length).replace(/[^a-z0-9_-]/gi, ""); const html = ` Plugin ${id} `; return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } function notFoundHtml(): string { return ` 404 — okengine Console

404

This URL is not a Console page.

`; } function fallbackShellHtml(): string { return ` okengine Console

okengine Console

Shell assets not built — run the Console UI build, or use the API on /console/*.

`; }