diff --git a/CHANGELOG.md b/CHANGELOG.md index 6748741b..aa0572db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,6 @@ - `UserMenu` in the shell sidebar (avatar + display name + popover with Profile settings + Sign out). - Top-level `/profile` route. Identity isn't a setting; un-nested from `/settings/*`. - Org-admin gate on `set_model_config` — backend now refuses non-org-admin writes (was UI-only via RouteGuard). Distinguishes "no identity" (cron, automations) from "wrong role" so debug logs make non-user code paths obvious. -- HTTP proxy primitive (`_meta["ai.nimblebrain/http-proxy"]`). Bundles can expose a loopback HTTP server (e.g. `astro preview`, Jupyter kernel) through the platform at `/v1/ws//apps///*`. Loopback-only target, credentials and `Accept-Encoding` stripped on forward, `Set-Cookie`/CSP/X-Frame-Options stripped on response, per-workspace kill switch via `Workspace.allowHttpProxy`. Bundles get `NB_WORKSPACE_ID`, `NB_PROXY_PREFIX`, `NB_PUBLIC_ORIGIN` in their env at spawn ([docs](https://docs.nimblebrain.ai/apps/http-proxy/)). - `PersonalWorkspaceInvariantError` typed error (`src/workspace/errors.ts`) → HTTP 422 `personal_workspace_invariant` with structured `{ workspaceId, reason }` body. Mirrors the `ConversationCorruptedError` → 422 precedent; raised by `WorkspaceStore` on attempts to mutate the locked members / `isPersonal` / `ownerUserId` fields on personal workspaces. - `scripts/cleanup-personal-workspace-members.ts` (alias: `bun run cleanup:personal-workspace-members`) — one-off retroactive cleanup that converges pre-Stage-1.1 personal workspaces to the sole-owner-admin shape. Idempotent, dry-run by default. - `src/orchestrator/` — per-call workspace routing + watcher-backed tool-list aggregator (Stage 2). Errors: `UnknownNamespacedToolName` / `UnknownWorkspace` / `WorkspaceAccessDenied` / `UnknownToolSource`, identical `data.reason` shapes on REST and `/mcp`. diff --git a/src/api/app.ts b/src/api/app.ts index d5e6f8b3..d7f814eb 100644 --- a/src/api/app.ts +++ b/src/api/app.ts @@ -11,7 +11,6 @@ import { eventRoutes } from "./routes/events.ts"; import { healthRoutes } from "./routes/health.ts"; import { mcpRoutes } from "./routes/mcp.ts"; import { mcpAuthRoutes } from "./routes/mcp-auth.ts"; -import { proxyRoutes } from "./routes/proxy.ts"; import { resourceRoutes } from "./routes/resources.ts"; import { toolRoutes } from "./routes/tools.ts"; import { wellKnownRoutes } from "./routes/well-known.ts"; @@ -48,15 +47,6 @@ export function createApp( // matching route, so MCP must be registered before chat/tools/events. app.route("/", mcpRoutes(ctx)); - // HTTP proxy routes — same Hono ordering constraint as mcpRoutes above. - // `resourceRoutes`/`chatRoutes`/etc. attach `.use("*", requireWorkspace(...))` - // middleware that resolves workspace from the X-Workspace-Id header. Browser - // iframe loads can't set custom headers, so the proxy puts the workspace ID - // in the URL path (`/v1/ws//apps/...`). Register before any sub-app - // with header-based workspace middleware so it doesn't 400 the iframe load - // before our path-based handler runs. - app.route("/", proxyRoutes(ctx)); - // Conversation events SSE — Stage 1 Task 005 dropped the workspace // requirement on this route (conversations live at the user level // post-collapse). Must register BEFORE chat/tools/etc. so their diff --git a/src/api/middleware/security-headers.ts b/src/api/middleware/security-headers.ts index 0a2de03d..0f2cf5d2 100644 --- a/src/api/middleware/security-headers.ts +++ b/src/api/middleware/security-headers.ts @@ -39,19 +39,10 @@ export interface SecurityHeadersOptions { * to a stricter/looser value via env var or option. * * `X-Frame-Options` is set as a *default* (`DENY`) — routes that legitimately - * serve framed content (e.g., the same-origin http-proxy bundles use to embed - * their dev servers) override it explicitly to `SAMEORIGIN`. We use `set` only - * when the route hasn't already provided a value, so route-level intent wins. - * - * The proxy route serves iframed bundle dev-server content, where the strict - * default CSP would block the bundle's own scripts/styles. Such routes set - * the internal `X-NB-Skip-Security-Defaults` response header to opt out of - * HSTS/CSP defaults; this middleware strips that header before egress. The - * parent shell's `frame-ancestors 'none'` is the real protection vector for - * those responses, not a CSP on the iframe content itself. + * serve framed content override it explicitly to `SAMEORIGIN`. We use `set` + * only when the route hasn't already provided a value, so route-level intent + * wins. */ -export const SKIP_DEFAULTS_HEADER = "X-NB-Skip-Security-Defaults"; - export function securityHeaders(options: SecurityHeadersOptions = {}) { const hsts = process.env.NB_HSTS ?? options.hsts ?? DEFAULT_HSTS; const csp = process.env.NB_CSP ?? options.csp ?? DEFAULT_CSP; @@ -64,11 +55,6 @@ export function securityHeaders(options: SecurityHeadersOptions = {}) { c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); c.res.headers.set("X-XSS-Protection", "0"); c.res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); - const skipDefaults = c.res.headers.has(SKIP_DEFAULTS_HEADER); - if (skipDefaults) { - c.res.headers.delete(SKIP_DEFAULTS_HEADER); - return; - } if (hsts && !c.res.headers.has("Strict-Transport-Security")) { c.res.headers.set("Strict-Transport-Security", hsts); } diff --git a/src/api/routes/proxy.ts b/src/api/routes/proxy.ts deleted file mode 100644 index bfbccc6d..00000000 --- a/src/api/routes/proxy.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Hono } from "hono"; -import { log } from "../../cli/log.ts"; -import { WORKSPACE_ID_RE } from "../auth-middleware.ts"; -import { requireAuth } from "../middleware/auth.ts"; -import { errorLog } from "../middleware/error-log.ts"; -import { SKIP_DEFAULTS_HEADER } from "../middleware/security-headers.ts"; -import { type AppContext, type AppEnv, apiError } from "../types.ts"; - -/** - * HTTP proxy routes for bundles that declare - * `_meta["ai.nimblebrain/http-proxy"]` in their manifest. Forwards same-origin - * browser requests from `/v1/ws//apps///*` to the - * bundle's local HTTP server (typically on 127.0.0.1:). - * - * The workspace ID is in the URL (not a header) because this route is - * targeted by the BROWSER's iframe loads — those can't set custom headers - * the way the platform's REST helpers do. Membership is enforced in the - * handler against the authenticated identity. - * - * Opt-in per bundle (manifest) and per workspace (`allowHttpProxy`). - * - * ─── Trust model (read this before installing any http-proxy bundle) ─── - * - * A bundle declaring `http-proxy` runs as **same-origin code in the - * authenticated user's session**. The iframe loaded from this proxy is - * sandboxed `allow-scripts allow-same-origin`, which means the bundle's - * preview JS can read cookies for the platform origin, call same-origin - * REST APIs authenticated as the user, and read top-frame DOM where the - * host UI permits it. Treat http-proxy bundles like browser extensions: - * the operator vouches for the code. - * - * Defenses we DO enforce: - * 1. `target` restricted to loopback hosts in `extractHttpProxy` (no - * SSRF to cloud metadata, RFC1918 networks, or external hosts). - * 2. `Authorization`, `Cookie`, `X-Workspace-Id`, `X-Forwarded-For` - * stripped before forwarding — bundle's loopback server can't read - * user credentials and can't be told to trust a forged client IP. - * 3. `Accept-Encoding: identity` forced on forward so upstream returns - * identity-encoded bodies — keeps us out of the business of - * tracking which encodings our HTTP client decompresses. - * 4. `Set-Cookie` stripped from upstream — bundle can't plant cookies - * on the platform's origin. - * 5. Workspace membership verified per-request before forwarding. - * 6. `Workspace.allowHttpProxy = false` is the per-workspace kill switch. - * - * Defenses we do NOT have today: - * - Cross-origin isolation per bundle (would require subdomain-per-bundle - * + COEP). For untrusted-bundle marketplaces, that's the next - * investment. - * - In **dev auth mode** (no IdentityProvider configured) the request - * identity is undefined and the membership check below is a no-op — - * consistent with how every other workspace-scoped route on the - * platform behaves in dev. Production deployments configure an - * IdentityProvider and the membership check fires per request. - * - * Scope (v1): HTTP methods only. WebSocket upgrade declared in - * HttpProxyConfig.websocket but not yet wired through the route. - */ -export function proxyRoutes(ctx: AppContext) { - return new Hono() - .use("/v1/ws/*", requireAuth(ctx.authOptions)) - .use("/v1/ws/*", errorLog(ctx)) - .all("/v1/ws/:wsId/apps/:bundle/:mount/*", async (c) => { - const wsId = decodeURIComponent(c.req.param("wsId") ?? ""); - const bundleName = decodeURIComponent(c.req.param("bundle") ?? ""); - const mount = c.req.param("mount") ?? ""; - const method = c.req.method; - const requestPath = new URL(c.req.url).pathname; - - // Validate workspace ID format (prevents path traversal). - if (!WORKSPACE_ID_RE.test(wsId)) { - log.info(`[proxy] ${method} ${requestPath} → 400 (invalid wsId)`); - return apiError(400, "workspace_error", "Invalid workspace ID format."); - } - - const ws = await ctx.workspaceStore.get(wsId); - if (!ws) { - log.info(`[proxy] ${method} ${requestPath} → 400 (workspace not found)`); - return apiError(400, "workspace_error", `Workspace "${wsId}" not found.`); - } - const identity = c.var.identity; - if (identity) { - const isMember = ws.members.some((m) => m.userId === identity.id); - if (!isMember) { - log.info(`[proxy] ${method} ${requestPath} → 403 (not a member of ${wsId})`); - return apiError( - 403, - "workspace_error", - `Access denied: not a member of workspace "${wsId}".`, - ); - } - } - c.set("workspaceId", wsId); - - if (ws.allowHttpProxy === false) { - log.info(`[proxy] ${method} ${requestPath} → 403 (workspace ${wsId} disabled)`); - return apiError(403, "proxy_disabled", "HTTP proxy routes are disabled for this workspace"); - } - - const instance = ctx.runtime.getLifecycle().getInstance(bundleName, wsId); - if (!instance) { - log.info(`[proxy] ${method} ${requestPath} → 404 (no instance "${bundleName}" in ${wsId})`); - return apiError(404, "not_found", `App "${bundleName}" not found`); - } - - const cfg = instance.httpProxy; - if (!cfg || cfg.mount !== mount) { - log.info( - `[proxy] ${method} ${requestPath} → 404 (mount "${mount}" not declared by ${bundleName})`, - ); - return apiError( - 404, - "not_found", - `App "${bundleName}" does not expose proxy mount "${mount}"`, - ); - } - - // Forward the FULL incoming path to the target. Bundle is expected to - // configure its upstream server (e.g., `astro --base`) to match the - // public prefix so absolute URLs in responses line up. - const url = new URL(c.req.url); - const target = new URL(cfg.target); - const targetUrl = new URL(target.toString().replace(/\/$/, "") + url.pathname + url.search); - - const forwardHeaders = new Headers(); - for (const [k, v] of c.req.raw.headers) { - const lk = k.toLowerCase(); - if (HOP_BY_HOP.has(lk)) continue; - if (REQUEST_HEADERS_STRIPPED.has(lk)) continue; - forwardHeaders.set(k, v); - } - forwardHeaders.set("X-Forwarded-Host", url.host); - forwardHeaders.set("X-Forwarded-Proto", url.protocol.replace(":", "")); - // Force identity encoding from upstream. Stripping the inbound - // `Accept-Encoding` isn't enough: Bun's `fetch` auto-injects a default - // (`gzip, deflate, br, zstd`) when the header is absent, which would - // re-introduce the encoding-mismatch class of bug we're avoiding. - // Setting `identity` explicitly is the defensive fix. - forwardHeaders.set("accept-encoding", "identity"); - - // `duplex: "half"` is only valid (per WHATWG fetch) when the body is a - // stream — passing it for bodyless GET/HEAD can produce a 400 in Bun. - const hasBody = REQUEST_HAS_BODY.has(method); - const init: RequestInit = { - method, - headers: forwardHeaders, - redirect: "manual", - }; - if (hasBody) { - init.body = c.req.raw.body; - (init as RequestInit & { duplex?: "half" }).duplex = "half"; - } - - log.info(`[proxy] ${method} ${requestPath} → ${targetUrl.toString()}`); - - let upstream: Response; - try { - upstream = await fetch(targetUrl.toString(), init); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log.error(`[proxy] ${method} ${requestPath} fetch failed: ${msg}`); - return apiError(502, "bad_gateway", `Upstream ${cfg.target} unreachable`); - } - log.info(`[proxy] ${method} ${requestPath} ← ${upstream.status} from ${cfg.target}`); - - const outHeaders = new Headers(); - for (const [k, v] of upstream.headers) { - const lk = k.toLowerCase(); - if (HOP_BY_HOP.has(lk)) continue; - if (RESPONSE_HEADERS_STRIPPED.has(lk)) continue; - outHeaders.set(k, v); - } - // Same-origin embedding: the security-headers middleware respects this - // when already set; cross-origin embedding stays denied. - outHeaders.set("X-Frame-Options", "SAMEORIGIN"); - // Opt out of default HSTS/CSP — iframed bundle dev-server content needs - // its own (typically permissive) policy. The middleware strips this - // header before egress. - outHeaders.set(SKIP_DEFAULTS_HEADER, "1"); - return new Response(upstream.body, { - status: upstream.status, - statusText: upstream.statusText, - headers: outHeaders, - }); - }); -} - -const HOP_BY_HOP = new Set([ - "connection", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "te", - "trailer", - "transfer-encoding", - "upgrade", -]); - -/** - * Stripped before forwarding upstream — see top-of-file trust model. - * - * - host: fetch sets its own based on the target. - * - authorization, cookie, x-workspace-id: user credentials, see trust model. - * - x-forwarded-for: client-supplied; the bundle has no way to verify it - * and we don't want to imply we did. - * - accept-encoding: stripped here, then forced to `identity` after the - * loop. The strip alone isn't enough — Bun's `fetch` auto-injects a - * default Accept-Encoding when the header is absent, which would - * re-introduce the encoding mismatch. Identity in, identity out, no - * encoding state to track. Loopback bandwidth cost is negligible. - */ -const REQUEST_HEADERS_STRIPPED = new Set([ - "host", - "authorization", - "cookie", - "x-workspace-id", - "x-forwarded-for", - "accept-encoding", -]); - -/** - * Stripped from upstream responses. - * - * - Set-Cookie / Set-Cookie2: see top-of-file trust model. - * - X-Frame-Options / CSP: replaced with our own SAMEORIGIN. - * - X-NB-Skip-Security-Defaults: internal signal — this route sets it - * deliberately below. Stripping upstream copies prevents a bundle dev - * server from disabling platform HSTS/CSP for unrelated responses. - */ -const RESPONSE_HEADERS_STRIPPED = new Set([ - "set-cookie", - "set-cookie2", - "x-frame-options", - "content-security-policy", - "content-security-policy-report-only", - "x-nb-skip-security-defaults", -]); - -const REQUEST_HAS_BODY = new Set(["POST", "PUT", "PATCH", "DELETE"]); diff --git a/src/bundles/defaults.ts b/src/bundles/defaults.ts index 422f4f4d..876bf761 100644 --- a/src/bundles/defaults.ts +++ b/src/bundles/defaults.ts @@ -1,13 +1,4 @@ -import type { - BundleRef, - BundleUiMeta, - HostManifestMeta, - HttpProxyConfig, - LocalBundleMeta, -} from "./types.ts"; - -/** Hostnames that resolve to the bundle's own loopback interface. */ -const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "[::1]", "localhost"]); +import type { BundleRef, BundleUiMeta, HostManifestMeta, LocalBundleMeta } from "./types.ts"; /** * Bundles included by default as MCP subprocesses. @@ -53,60 +44,5 @@ export function extractBundleMeta(manifest: Record): LocalBundl briefing: hostMeta?.briefing ?? null, type: isUpjack ? "upjack" : "plain", upjackNamespace: upjackMeta?.namespace, - httpProxy: extractHttpProxy(meta), - }; -} - -/** - * Parse and validate `_meta["ai.nimblebrain/http-proxy"]`. - * - * Targets are restricted to loopback hosts. The proxy primitive exists so a - * bundle can expose its OWN local HTTP server (an `astro dev` it spawned, a - * Jupyter kernel, etc.) — there is no legitimate reason for a target to point - * at any other host. Allowing arbitrary hosts would turn the proxy into an - * SSRF gadget capable of reaching cloud metadata services (169.254.169.254), - * internal/RFC1918 networks, or arbitrary external hosts, with the - * authenticated user's credentials attached. - */ -export function extractHttpProxy( - meta: Record | undefined, -): HttpProxyConfig | null { - const raw = meta?.["ai.nimblebrain/http-proxy"]; - if (!raw || typeof raw !== "object") return null; - const r = raw as Record; - const target = typeof r.target === "string" ? r.target : undefined; - const mount = typeof r.mount === "string" ? r.mount : undefined; - if (!target || !mount) { - console.warn("[bundles] http-proxy declaration missing target or mount — ignoring"); - return null; - } - let parsed: URL; - try { - parsed = new URL(target); - } catch { - console.warn(`[bundles] http-proxy target is not a valid URL — got ${target}, ignoring`); - return null; - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - console.warn(`[bundles] http-proxy target must be http(s):// — got ${target}, ignoring`); - return null; - } - if (!LOOPBACK_HOSTS.has(parsed.hostname.toLowerCase())) { - console.warn( - `[bundles] http-proxy target must point to a loopback host (127.0.0.1, ::1, or localhost) — got ${parsed.hostname}, ignoring`, - ); - return null; - } - const normalizedMount = mount.replace(/^\/+/, "").replace(/\/+$/, ""); - if (!normalizedMount || normalizedMount.includes("/")) { - console.warn( - `[bundles] http-proxy mount must be a single path segment (no slashes) — got ${mount}, ignoring`, - ); - return null; - } - return { - target, - mount: normalizedMount, - websocket: r.websocket === true, }; } diff --git a/src/bundles/lifecycle.ts b/src/bundles/lifecycle.ts index 1165b709..15c0fd00 100644 --- a/src/bundles/lifecycle.ts +++ b/src/bundles/lifecycle.ts @@ -34,7 +34,6 @@ import type { BundleState, BundleUiMeta, HostManifestMeta, - HttpProxyConfig, RemoteTransportConfig, } from "./types.ts"; @@ -458,7 +457,6 @@ export class BundleLifecycleManager { trustScore: trustScore ?? null, ui: ui ?? null, briefing: null, - httpProxy: null, protected: false, type: "plain", wsId, @@ -1750,7 +1748,6 @@ export class BundleLifecycleManager { description?: string; ui: BundleUiMeta | null; briefing?: BriefingBlock | null; - httpProxy?: HttpProxyConfig | null; type: "upjack" | "plain"; upjackNamespace?: string; } @@ -1797,7 +1794,6 @@ export class BundleLifecycleManager { trustScore: ref.trustScore ?? null, ui: ref.ui ?? manifestMeta?.ui ?? null, briefing: manifestMeta?.briefing ?? null, - httpProxy: manifestMeta?.httpProxy ?? null, protected: ref.protected ?? false, type: manifestMeta?.type ?? "plain", wsId, @@ -1907,7 +1903,6 @@ function createInstance( trustScore: null, ui: null, briefing: null, - httpProxy: null, protected: false, type: isUpjack ? "upjack" : "plain", wsId, diff --git a/src/bundles/startup.ts b/src/bundles/startup.ts index d435c549..a0979953 100644 --- a/src/bundles/startup.ts +++ b/src/bundles/startup.ts @@ -92,10 +92,6 @@ export function composeBundleMcpContext( export interface PlatformContext { /** Workspace this bundle is being spawned for. Undefined outside a workspace. */ workspaceId: string | undefined; - /** Stable name the platform addresses this bundle by — composes into proxy URLs. */ - serverName: string; - /** Manifest `_meta` — read for capability declarations (e.g. `ai.nimblebrain/http-proxy`). */ - manifestMeta: Record | undefined; /** Browser-facing origin of the platform (e.g. https://hq.platform.nimblebrain.ai). */ publicOrigin: string; } @@ -104,10 +100,7 @@ export interface PlatformContext { * Build the NB_* env vars every bundle subprocess receives. * * Both spawn paths in this file (registry + local) call this so the contract - * cannot drift. The previous implementation duplicated this logic inline in - * only the local branch, which silently broke registry-installed bundles that - * declared `ai.nimblebrain/http-proxy` — preview URLs came back null with no - * error in the logs. + * cannot drift. */ export function buildPlatformEnv(ctx: PlatformContext): Record { const env: Record = {}; @@ -116,16 +109,6 @@ export function buildPlatformEnv(ctx: PlatformContext): Record { env.NB_WORKSPACE_ID = ctx.workspaceId; } - const httpProxyMeta = ctx.manifestMeta?.["ai.nimblebrain/http-proxy"] as - | { mount?: string } - | undefined; - if (httpProxyMeta?.mount && ctx.workspaceId) { - const mount = String(httpProxyMeta.mount).replace(/^\/+|\/+$/g, ""); - if (mount && !/\//.test(mount)) { - env.NB_PROXY_PREFIX = `/v1/ws/${ctx.workspaceId}/apps/${ctx.serverName}/${mount}`; - } - } - if (ctx.publicOrigin) { env.NB_PUBLIC_ORIGIN = ctx.publicOrigin; } @@ -440,7 +423,6 @@ export async function startBundleSource( version: `remote (${tools.length} tools)`, ui: ref.ui ?? null, briefing: null, - httpProxy: null, type: "plain" as const, }, sourceName, @@ -483,7 +465,6 @@ export async function startBundleSource( version: "remote (pending auth)", ui: ref.ui ?? null, briefing: null, - httpProxy: null, type: "plain" as const, }, sourceName, @@ -568,9 +549,8 @@ export async function startBundleSource( manifest = cachedManifest; } else { // With no manifest in cache we can't read `_meta` capability - // declarations, so http-proxy and host_capabilities get silently - // skipped at spawn. Surface it loudly instead of letting operators - // chase phantom UI bugs. + // declarations, so host_capabilities gets silently skipped at spawn. + // Surface it loudly instead of letting operators chase phantom UI bugs. // // The cache-warm step above (#60) closes the common cause of this — // a cold first-install no longer reaches here, since the warm either @@ -583,7 +563,7 @@ export async function startBundleSource( // manifest once prepareServer has re-populated it. log.warn( `[bundles] manifest cache miss for ${ref.name} — capability declarations ` + - "(http-proxy, host_capabilities, etc.) will be skipped at spawn, including " + + "(host_capabilities, etc.) will be skipped at spawn, including " + "the install-time host-resources gate. Reinstall the bundle to repopulate.", ); } @@ -662,8 +642,6 @@ export async function startBundleSource( // workspace's identity flows through one validated path. const platformEnv = buildPlatformEnv({ workspaceId: wsContext.workspaceId, - serverName: sourceName, - manifestMeta: cachedManifest?._meta as Record | undefined, publicOrigin: resolvePublicOrigin(), }); @@ -816,8 +794,6 @@ function buildLocalSource( spawnEnv, buildPlatformEnv({ workspaceId: wsId, - serverName, - manifestMeta: manifest._meta as Record | undefined, publicOrigin: resolvePublicOrigin(), }), ); diff --git a/src/bundles/types.ts b/src/bundles/types.ts index 9585bbf0..f8d36a6e 100644 --- a/src/bundles/types.ts +++ b/src/bundles/types.ts @@ -43,28 +43,6 @@ export interface BundleUiMeta { placements?: PlacementDeclaration[]; } -/** - * HTTP proxy declaration — declared by bundles that run their own HTTP server - * (e.g., an Astro preview, a Jupyter kernel gateway, a notebook renderer) - * and want the platform to expose it to the user's browser through a - * same-origin route under `/v1/ws//apps///*`. - * - * Opt-in: most bundles don't declare this. Workspaces can globally disable via - * `Workspace.allowHttpProxy = false`. - * - * Read from `_meta["ai.nimblebrain/http-proxy"]` in the manifest. - */ -export interface HttpProxyConfig { - /** URL of the bundle-local HTTP server to forward to. Must point to a - * loopback host (127.0.0.1, ::1, or localhost). */ - target: string; - /** Single path segment under `/v1/ws//apps//`. Cannot contain `/`. */ - mount: string; - /** Whether the proxy should handle WebSocket upgrades (HMR, live channels). - * Declared today; upgrade forwarding is not yet wired through the route. */ - websocket?: boolean; -} - /** Transport configuration for remote MCP servers (url-based bundles). */ export interface RemoteTransportConfig { type?: "streamable-http" | "sse"; @@ -368,8 +346,6 @@ export interface BundleInstance { ui: BundleUiMeta | null; /** Briefing metadata from _meta["ai.nimblebrain/host"].briefing. */ briefing: BriefingBlock | null; - /** HTTP proxy declaration from _meta["ai.nimblebrain/http-proxy"]. */ - httpProxy: HttpProxyConfig | null; /** Whether the bundle is protected from uninstall. */ protected: boolean; /** Whether this is an Upjack app or plain MCP server. */ @@ -427,8 +403,6 @@ export interface LocalBundleMeta { type: "upjack" | "plain"; /** Upjack namespace from manifest (e.g., "apps/crm"). */ upjackNamespace?: string; - /** HTTP proxy declaration (opt-in). */ - httpProxy: HttpProxyConfig | null; } /** Env vars injected into protected default bundles for internal host communication. */ diff --git a/src/runtime/workspace-runtime.ts b/src/runtime/workspace-runtime.ts index 3dbafc40..ae6869c3 100644 --- a/src/runtime/workspace-runtime.ts +++ b/src/runtime/workspace-runtime.ts @@ -231,7 +231,6 @@ export async function startWorkspaceBundles( version: "remote", ui: entry.bundle.ui ?? null, briefing: null, - httpProxy: null, type: "plain" as const, }, }; diff --git a/src/workspace/types.ts b/src/workspace/types.ts index ad2da6fa..cc1a0718 100644 --- a/src/workspace/types.ts +++ b/src/workspace/types.ts @@ -60,12 +60,6 @@ export interface Workspace { models?: Partial; /** Optional markdown identity override for this workspace's agent persona. */ identity?: string; - /** - * Allow bundles in this workspace to expose HTTP proxy routes (declared via - * `_meta["ai.nimblebrain/http-proxy"]` in their manifests). Default true. - * Set false to block all proxy routes in security-sensitive workspaces. - */ - allowHttpProxy?: boolean; /** * Per-workspace catalog allow-list. When set, only catalog entries * whose `id` is in this array appear on the Connections page for diff --git a/test/integration/api/proxy-route.test.ts b/test/integration/api/proxy-route.test.ts deleted file mode 100644 index ca202dab..00000000 --- a/test/integration/api/proxy-route.test.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * Integration tests for the http-proxy route. - * - * Covers: - * - Workspace ID validation (path-traversal guard) - * - Workspace existence check - * - Membership enforcement (DevIdentityProvider sets identity = usr_default) - * - Per-workspace kill switch (allowHttpProxy = false) - * - Bundle / mount existence checks - * - Upstream unreachable → 502 - * - Request header stripping (Authorization, Cookie, X-Workspace-Id, - * X-Forwarded-For, Accept-Encoding) - * - Response header behavior (Set-Cookie / X-Frame-Options / CSP stripped; - * X-Frame-Options: SAMEORIGIN set on success) - * - * The lifecycle's `instances` map is private; tests reach in via `as any` to - * register fake bundle instances. This is the lightest path that lets us - * exercise the proxy route without spinning up a real bundle subprocess. - */ -import { afterAll, beforeAll, describe, expect, it } from "bun:test"; -import { mkdtemp } from "node:fs/promises"; -import { rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { Runtime } from "../../../src/runtime/runtime.ts"; -import { startServer, type ServerHandle } from "../../../src/api/server.ts"; -import { createEchoModel } from "../../helpers/echo-model.ts"; -import { TEST_WORKSPACE_ID, provisionTestWorkspace } from "../../helpers/test-workspace.ts"; -import type { BundleInstance, HttpProxyConfig } from "../../../src/bundles/types.ts"; - -// ── Upstream test server ────────────────────────────────────────────── - -interface CapturedRequest { - method: string; - pathname: string; - search: string; - headers: Record; - body: string; -} - -let lastUpstreamRequest: CapturedRequest | null = null; -let upstreamResponseFactory: () => Response = () => - new Response("upstream-default", { status: 200, headers: { "Content-Type": "text/plain" } }); - -let upstreamServer: ReturnType | null = null; - -function startUpstream(): { port: number; stop: () => void } { - const server = Bun.serve({ - port: 0, - async fetch(req) { - const url = new URL(req.url); - const headers: Record = {}; - for (const [k, v] of req.headers) headers[k.toLowerCase()] = v; - const body = req.body ? await req.text() : ""; - lastUpstreamRequest = { - method: req.method, - pathname: url.pathname, - search: url.search, - headers, - body, - }; - return upstreamResponseFactory(); - }, - }); - upstreamServer = server; - return { port: server.port, stop: () => server.stop(true) }; -} - -// ── Setup ───────────────────────────────────────────────────────────── - -let runtime: Runtime; -let handle: ServerHandle; -let baseUrl: string; -let workDir: string; -let upstream: { port: number; stop: () => void }; - -const BUNDLE_NAME = "test-proxy-bundle"; -const MOUNT = "preview"; -const NON_MEMBER_WS = "ws_no_access"; -const KILLED_WS = "ws_killed"; -const NO_INSTANCE_WS = "ws_no_instance"; - -function fakeInstance(httpProxy: HttpProxyConfig | null, wsId: string): BundleInstance { - return { - serverName: BUNDLE_NAME, - bundleName: `@org/${BUNDLE_NAME}`, - version: "0.0.0-test", - state: "running", - trustScore: null, - ui: null, - briefing: null, - httpProxy, - protected: false, - type: "plain", - wsId, - }; -} - -function registerInstance(wsId: string, httpProxy: HttpProxyConfig | null) { - const lifecycle = runtime.getLifecycle() as unknown as { - instances: Map; - }; - lifecycle.instances.set(`${BUNDLE_NAME}|${wsId}`, fakeInstance(httpProxy, wsId)); -} - -beforeAll(async () => { - workDir = await mkdtemp(join(tmpdir(), "nb-proxy-route-")); - upstream = startUpstream(); - - runtime = await Runtime.start({ - model: { provider: "custom", adapter: createEchoModel() }, - noDefaultBundles: true, - logging: { disabled: true }, - workDir, - }); - - // ws_test — usr_default is a member. - await provisionTestWorkspace(runtime); - - // ws_no_access — exists, but usr_default is NOT a member. - const wsStore = runtime.getWorkspaceStore(); - await wsStore.create("No Access", "no_access"); - - // ws_killed — usr_default IS a member, but allowHttpProxy = false. - const killed = await wsStore.create("Killed", "killed"); - await wsStore.addMember(killed.id, "usr_default", "admin"); - await wsStore.update(killed.id, { allowHttpProxy: false }); - - // ws_no_instance — usr_default is a member, no bundle registered. - const noInst = await wsStore.create("No Instance", "no_instance"); - await wsStore.addMember(noInst.id, "usr_default", "admin"); - - // Register a bundle instance pointing at the upstream test server. - const proxy: HttpProxyConfig = { - target: `http://127.0.0.1:${upstream.port}`, - mount: MOUNT, - websocket: false, - }; - registerInstance(TEST_WORKSPACE_ID, proxy); - registerInstance(KILLED_WS, proxy); // for the kill-switch test - registerInstance(NON_MEMBER_WS, proxy); // for the membership test - - // For the "unknown mount" test we register an instance with a different mount. - registerInstance("ws_test", proxy); // already done above; here for clarity - - handle = startServer({ runtime, port: 0 }); - baseUrl = `http://localhost:${handle.port}`; -}); - -afterAll(async () => { - handle.stop(true); - await runtime.shutdown(); - upstream.stop(); - upstreamServer?.stop(true); - rmSync(workDir, { recursive: true, force: true }); -}); - -// ── Helpers ─────────────────────────────────────────────────────────── - -function proxyUrl(wsId: string, mount: string = MOUNT, rest: string = "") { - return `${baseUrl}/v1/ws/${wsId}/apps/${BUNDLE_NAME}/${mount}/${rest}`; -} - -function resetUpstream() { - lastUpstreamRequest = null; - upstreamResponseFactory = () => - new Response("ok", { status: 200, headers: { "Content-Type": "text/plain" } }); -} - -// ── Tests ───────────────────────────────────────────────────────────── - -describe("proxy route — workspace ID validation", () => { - it("400 when wsId is malformed (path-traversal guard)", async () => { - const res = await fetch(`${baseUrl}/v1/ws/..%2Fsecret/apps/${BUNDLE_NAME}/${MOUNT}/`); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("workspace_error"); - }); - - it("400 when wsId references a workspace that doesn't exist", async () => { - const res = await fetch(`${baseUrl}/v1/ws/ws_does_not_exist/apps/${BUNDLE_NAME}/${MOUNT}/`); - expect(res.status).toBe(400); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("workspace_error"); - }); -}); - -describe("proxy route — auth / membership", () => { - it("403 when authenticated identity is not a member of the workspace", async () => { - const res = await fetch(proxyUrl(NON_MEMBER_WS)); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("workspace_error"); - }); -}); - -describe("proxy route — kill switch", () => { - it("403 when workspace.allowHttpProxy is false", async () => { - const res = await fetch(proxyUrl(KILLED_WS)); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("proxy_disabled"); - }); -}); - -describe("proxy route — bundle / mount lookup", () => { - it("404 when no bundle instance exists for the workspace", async () => { - const res = await fetch(proxyUrl(NO_INSTANCE_WS)); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("not_found"); - }); - - it("404 when the requested mount is not the one declared by the bundle", async () => { - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID, "wrong-mount")); - expect(res.status).toBe(404); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("not_found"); - }); -}); - -describe("proxy route — upstream behavior", () => { - it("502 when upstream is unreachable", async () => { - // Register a bundle instance pointing at an unused port. - const wsStore = runtime.getWorkspaceStore(); - const ws = await wsStore.create("Dead Upstream", "dead_upstream"); - await wsStore.addMember(ws.id, "usr_default", "admin"); - registerInstance(ws.id, { - target: "http://127.0.0.1:1", // port 1 — refused by every host's TCP stack - mount: MOUNT, - websocket: false, - }); - - const res = await fetch(proxyUrl(ws.id)); - expect(res.status).toBe(502); - const body = (await res.json()) as { error: string }; - expect(body.error).toBe("bad_gateway"); - }); - - it("forwards full path + query string to upstream verbatim", async () => { - resetUpstream(); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID, MOUNT, "deep/path?q=1&x=y")); - expect(res.status).toBe(200); - expect(lastUpstreamRequest).not.toBeNull(); - expect(lastUpstreamRequest?.pathname).toBe( - `/v1/ws/${TEST_WORKSPACE_ID}/apps/${BUNDLE_NAME}/${MOUNT}/deep/path`, - ); - expect(lastUpstreamRequest?.search).toBe("?q=1&x=y"); - }); - - it("forwards the request method", async () => { - resetUpstream(); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID), { method: "DELETE" }); - expect(res.status).toBe(200); - expect(lastUpstreamRequest?.method).toBe("DELETE"); - }); - - it("forwards request body for POST", async () => { - resetUpstream(); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ hello: "world" }), - }); - expect(res.status).toBe(200); - expect(lastUpstreamRequest?.body).toBe('{"hello":"world"}'); - }); -}); - -describe("proxy route — request header stripping", () => { - it("strips Authorization, Cookie, X-Workspace-Id, X-Forwarded-For, Accept-Encoding", async () => { - resetUpstream(); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID), { - headers: { - Authorization: "Bearer secret-token-do-not-leak", - Cookie: "session=secret", - "X-Workspace-Id": "should-not-leak", - "X-Forwarded-For": "1.2.3.4", - "Accept-Encoding": "gzip, br", - "X-Custom-Header": "should-pass-through", - }, - }); - expect(res.status).toBe(200); - const fwd = lastUpstreamRequest?.headers ?? {}; - expect(fwd.authorization).toBeUndefined(); - expect(fwd.cookie).toBeUndefined(); - expect(fwd["x-workspace-id"]).toBeUndefined(); - expect(fwd["x-forwarded-for"]).toBeUndefined(); - // Accept-Encoding from the browser is replaced with `identity`, forcing - // upstream to return an unencoded body so we don't inherit Bun's - // per-encoding decompression behavior. - expect(fwd["accept-encoding"]).toBe("identity"); - // Sanity: non-stripped headers do pass through. - expect(fwd["x-custom-header"]).toBe("should-pass-through"); - }); - - it("sets X-Forwarded-Host and X-Forwarded-Proto on the forwarded request", async () => { - resetUpstream(); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID)); - expect(res.status).toBe(200); - const fwd = lastUpstreamRequest?.headers ?? {}; - expect(fwd["x-forwarded-host"]).toBeDefined(); - expect(fwd["x-forwarded-proto"]).toBe("http"); - }); -}); - -describe("proxy route — response header behavior", () => { - it("strips Set-Cookie, X-Frame-Options, CSP from upstream response", async () => { - upstreamResponseFactory = () => - new Response("hi", { - status: 200, - headers: { - "Set-Cookie": "evil=yes; HttpOnly", - "X-Frame-Options": "DENY", // upstream tries to deny framing - "Content-Security-Policy": "default-src 'none'", - "Content-Security-Policy-Report-Only": "default-src 'none'", - "X-Custom-Upstream": "passthrough-ok", - }, - }); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID)); - expect(res.status).toBe(200); - expect(res.headers.get("Set-Cookie")).toBeNull(); - // The platform overrides X-Frame-Options to SAMEORIGIN — see next test. - expect(res.headers.get("X-Frame-Options")).toBe("SAMEORIGIN"); - expect(res.headers.get("Content-Security-Policy")).toBeNull(); - expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeNull(); - // Sanity: non-stripped headers pass through. - expect(res.headers.get("X-Custom-Upstream")).toBe("passthrough-ok"); - }); - - it("sets X-Frame-Options: SAMEORIGIN on successful proxy responses", async () => { - resetUpstream(); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID)); - expect(res.status).toBe(200); - expect(res.headers.get("X-Frame-Options")).toBe("SAMEORIGIN"); - }); - - it("preserves upstream response status code (e.g., 404)", async () => { - upstreamResponseFactory = () => new Response("not here", { status: 404 }); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID)); - expect(res.status).toBe(404); - expect(await res.text()).toBe("not here"); - }); - - it("preserves upstream response body bytes", async () => { - const payload = "thebodyÿwith€binary"; - upstreamResponseFactory = () => - new Response(payload, { status: 200, headers: { "Content-Type": "text/plain" } }); - const res = await fetch(proxyUrl(TEST_WORKSPACE_ID)); - expect(res.status).toBe(200); - expect(await res.text()).toBe(payload); - }); -}); diff --git a/test/integration/app-upgrade.test.ts b/test/integration/app-upgrade.test.ts index 009a0692..d8f9962d 100644 --- a/test/integration/app-upgrade.test.ts +++ b/test/integration/app-upgrade.test.ts @@ -34,7 +34,7 @@ const MEMBER: UserIdentity = { preferences: {}, }; -const REG_META = { version: "0.1.0", ui: null, briefing: null, type: "plain" as const, httpProxy: null }; +const REG_META = { version: "0.1.0", ui: null, briefing: null, type: "plain" as const }; interface Harness { workDir: string; diff --git a/test/integration/lifecycle-startauth-interactive-failure.test.ts b/test/integration/lifecycle-startauth-interactive-failure.test.ts index 67b1a032..e12aecf7 100644 --- a/test/integration/lifecycle-startauth-interactive-failure.test.ts +++ b/test/integration/lifecycle-startauth-interactive-failure.test.ts @@ -127,7 +127,6 @@ describe("lifecycle.startAuth — interactive-flow failure is surfaced, not swal trustScore: null, ui: null, briefing: null, - httpProxy: null, protected: false, type: "plain", wsId: WS, diff --git a/test/unit/api/security-headers.test.ts b/test/unit/api/security-headers.test.ts index ce5008aa..2bad8312 100644 --- a/test/unit/api/security-headers.test.ts +++ b/test/unit/api/security-headers.test.ts @@ -3,7 +3,6 @@ import { Hono } from "hono"; import { DEFAULT_CSP, DEFAULT_HSTS, - SKIP_DEFAULTS_HEADER, securityHeaders, } from "../../../src/api/middleware/security-headers.ts"; @@ -98,21 +97,6 @@ describe("securityHeaders middleware", () => { expect(res.headers.get("Strict-Transport-Security")).toBeNull(); }); - test(`${SKIP_DEFAULTS_HEADER} opts route out of HSTS/CSP and is stripped from egress`, async () => { - const app = new Hono(); - app.use("*", securityHeaders()); - app.get("/proxied", (c) => { - c.header(SKIP_DEFAULTS_HEADER, "1"); - return c.text("ok"); - }); - const res = await app.request("/proxied"); - expect(res.headers.get("Strict-Transport-Security")).toBeNull(); - expect(res.headers.get("Content-Security-Policy")).toBeNull(); - expect(res.headers.get(SKIP_DEFAULTS_HEADER)).toBeNull(); - // Other defaults still apply — the opt-out is HSTS/CSP-specific. - expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff"); - }); - test("preserves route-level HSTS/CSP overrides", async () => { const app = new Hono(); app.use("*", securityHeaders()); diff --git a/test/unit/briefing-collector.test.ts b/test/unit/briefing-collector.test.ts index d936d587..1412603c 100644 --- a/test/unit/briefing-collector.test.ts +++ b/test/unit/briefing-collector.test.ts @@ -48,7 +48,6 @@ function makeInstance(overrides: Partial = {}): BundleInstance { }, ], }, - httpProxy: null, protected: false, type: "upjack", wsId: "ws_test", diff --git a/test/unit/bundle-upgrade.test.ts b/test/unit/bundle-upgrade.test.ts index dbed7d7d..1473bc54 100644 --- a/test/unit/bundle-upgrade.test.ts +++ b/test/unit/bundle-upgrade.test.ts @@ -103,7 +103,7 @@ describe("BundleLifecycleManager.upgradeApp", () => { "upgradeable", "@testscope/upgradeable", { name: "@testscope/upgradeable" }, - { version: "0.1.0", ui: null, briefing: null, type: "plain", httpProxy: null }, + { version: "0.1.0", ui: null, briefing: null, type: "plain" }, ws, ); } diff --git a/test/unit/bundles/build-platform-env.test.ts b/test/unit/bundles/build-platform-env.test.ts index bf78e13b..3694c382 100644 --- a/test/unit/bundles/build-platform-env.test.ts +++ b/test/unit/bundles/build-platform-env.test.ts @@ -5,8 +5,6 @@ describe("buildPlatformEnv", () => { test("sets NB_WORKSPACE_ID when wsId is provided", () => { const env = buildPlatformEnv({ workspaceId: "ws_test", - serverName: "my-server", - manifestMeta: undefined, publicOrigin: "", }); expect(env.NB_WORKSPACE_ID).toBe("ws_test"); @@ -15,78 +13,14 @@ describe("buildPlatformEnv", () => { test("omits NB_WORKSPACE_ID when wsId is undefined", () => { const env = buildPlatformEnv({ workspaceId: undefined, - serverName: "my-server", - manifestMeta: undefined, publicOrigin: "", }); expect(env.NB_WORKSPACE_ID).toBeUndefined(); }); - test("sets NB_PROXY_PREFIX when manifest declares http-proxy and wsId is provided", () => { - const env = buildPlatformEnv({ - workspaceId: "ws_nimblebrain_shared", - serverName: "synapse-astro-editor", - manifestMeta: { - "ai.nimblebrain/http-proxy": { target: "http://127.0.0.1:4321", mount: "preview" }, - }, - publicOrigin: "", - }); - expect(env.NB_PROXY_PREFIX).toBe( - "/v1/ws/ws_nimblebrain_shared/apps/synapse-astro-editor/preview", - ); - }); - - test("strips leading and trailing slashes from declared mount", () => { - const env = buildPlatformEnv({ - workspaceId: "ws_test", - serverName: "srv", - manifestMeta: { - "ai.nimblebrain/http-proxy": { mount: "/preview/" }, - }, - publicOrigin: "", - }); - expect(env.NB_PROXY_PREFIX).toBe("/v1/ws/ws_test/apps/srv/preview"); - }); - - test("omits NB_PROXY_PREFIX when wsId is missing (declaration alone is not enough)", () => { - const env = buildPlatformEnv({ - workspaceId: undefined, - serverName: "srv", - manifestMeta: { - "ai.nimblebrain/http-proxy": { mount: "preview" }, - }, - publicOrigin: "", - }); - expect(env.NB_PROXY_PREFIX).toBeUndefined(); - }); - - test("omits NB_PROXY_PREFIX when manifest does not declare http-proxy", () => { - const env = buildPlatformEnv({ - workspaceId: "ws_test", - serverName: "srv", - manifestMeta: { "some.other/meta": { foo: "bar" } }, - publicOrigin: "", - }); - expect(env.NB_PROXY_PREFIX).toBeUndefined(); - }); - - test("rejects mount with embedded path separators (single segment only)", () => { - const env = buildPlatformEnv({ - workspaceId: "ws_test", - serverName: "srv", - manifestMeta: { - "ai.nimblebrain/http-proxy": { mount: "preview/deep" }, - }, - publicOrigin: "", - }); - expect(env.NB_PROXY_PREFIX).toBeUndefined(); - }); - test("sets NB_PUBLIC_ORIGIN when provided", () => { const env = buildPlatformEnv({ workspaceId: "ws_test", - serverName: "srv", - manifestMeta: undefined, publicOrigin: "https://hq.platform.nimblebrain.ai", }); expect(env.NB_PUBLIC_ORIGIN).toBe("https://hq.platform.nimblebrain.ai"); @@ -95,36 +29,18 @@ describe("buildPlatformEnv", () => { test("omits NB_PUBLIC_ORIGIN when publicOrigin is empty", () => { const env = buildPlatformEnv({ workspaceId: "ws_test", - serverName: "srv", - manifestMeta: undefined, publicOrigin: "", }); expect(env.NB_PUBLIC_ORIGIN).toBeUndefined(); }); - // Regression: the original bug was that the registry spawn path in - // startup.ts did not call this helper, so registry-installed bundles - // (synapse-astro-editor in tenant-hq, ws_nimblebrain_shared) silently - // never received NB_PROXY_PREFIX. The UI showed "No preview URL — check - // that the http-proxy declaration is wired" even though the manifest - // was correct. Both spawn paths now call buildPlatformEnv with the - // bundle's _meta; this test pins the produced contract for that scenario. - test("regression: full contract for a registry bundle declaring http-proxy", () => { + test("full contract: both NB_WORKSPACE_ID and NB_PUBLIC_ORIGIN", () => { const env = buildPlatformEnv({ workspaceId: "ws_nimblebrain_shared", - serverName: "synapse-astro-editor", - manifestMeta: { - "ai.nimblebrain/http-proxy": { - target: "http://127.0.0.1:4321", - mount: "preview", - websocket: true, - }, - }, publicOrigin: "https://hq.platform.nimblebrain.ai", }); expect(env).toEqual({ NB_WORKSPACE_ID: "ws_nimblebrain_shared", - NB_PROXY_PREFIX: "/v1/ws/ws_nimblebrain_shared/apps/synapse-astro-editor/preview", NB_PUBLIC_ORIGIN: "https://hq.platform.nimblebrain.ai", }); }); diff --git a/test/unit/bundles/extract-http-proxy.test.ts b/test/unit/bundles/extract-http-proxy.test.ts deleted file mode 100644 index 4d87bca7..00000000 --- a/test/unit/bundles/extract-http-proxy.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { extractHttpProxy } from "../../../src/bundles/defaults.ts"; - -// Quiet the warn() spam — these tests trigger validation rejections by design. -const originalWarn = console.warn; -beforeAll(() => { - console.warn = () => {}; -}); -afterAll(() => { - console.warn = originalWarn; -}); - -describe("extractHttpProxy", () => { - test("returns null when meta is undefined", () => { - expect(extractHttpProxy(undefined)).toBeNull(); - }); - - test("returns null when http-proxy key is absent", () => { - expect(extractHttpProxy({})).toBeNull(); - }); - - test("returns null when http-proxy is not an object", () => { - expect(extractHttpProxy({ "ai.nimblebrain/http-proxy": "nope" })).toBeNull(); - expect(extractHttpProxy({ "ai.nimblebrain/http-proxy": 42 })).toBeNull(); - expect(extractHttpProxy({ "ai.nimblebrain/http-proxy": null })).toBeNull(); - }); - - test("returns null when target is missing", () => { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { mount: "preview" }, - }), - ).toBeNull(); - }); - - test("returns null when mount is missing", () => { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "http://127.0.0.1:4321" }, - }), - ).toBeNull(); - }); - - test("returns null when target is not a parseable URL", () => { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "not a url at all", mount: "preview" }, - }), - ).toBeNull(); - }); - - test("returns null when target uses a non-http(s) protocol", () => { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "file:///etc/passwd", mount: "preview" }, - }), - ).toBeNull(); - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "ftp://127.0.0.1/x", mount: "preview" }, - }), - ).toBeNull(); - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "javascript:alert(1)", mount: "preview" }, - }), - ).toBeNull(); - }); - - test("rejects non-loopback hosts (SSRF guard)", () => { - const offLoopback = [ - "http://example.com", - "http://169.254.169.254", // AWS metadata - "http://10.0.0.1", // RFC1918 - "http://192.168.1.1", - "http://172.16.0.1", - "http://0.0.0.0", // wildcard, not loopback - ]; - for (const target of offLoopback) { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target, mount: "preview" }, - }), - ).toBeNull(); - } - }); - - test("accepts each loopback hostname (127.0.0.1, ::1, localhost)", () => { - for (const target of ["http://127.0.0.1:4321", "http://[::1]:4321", "http://localhost:4321"]) { - const result = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target, mount: "preview" }, - }); - expect(result).not.toBeNull(); - expect(result?.target).toBe(target); - } - }); - - test("loopback host check is case-insensitive (LocalHost)", () => { - const result = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "http://LocalHost:4321", mount: "preview" }, - }); - expect(result).not.toBeNull(); - }); - - test("rejects mount with embedded slashes", () => { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { - target: "http://127.0.0.1:4321", - mount: "deep/nested", - }, - }), - ).toBeNull(); - }); - - test("rejects empty mount after slash trimming", () => { - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "http://127.0.0.1:4321", mount: "/" }, - }), - ).toBeNull(); - expect( - extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "http://127.0.0.1:4321", mount: "" }, - }), - ).toBeNull(); - }); - - test("normalizes mount by trimming leading and trailing slashes", () => { - const r = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { - target: "http://127.0.0.1:4321", - mount: "/preview/", - }, - }); - expect(r?.mount).toBe("preview"); - }); - - test("websocket defaults to false when not declared or non-true", () => { - const a = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target: "http://127.0.0.1:4321", mount: "preview" }, - }); - expect(a?.websocket).toBe(false); - - const b = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { - target: "http://127.0.0.1:4321", - mount: "preview", - websocket: "yes", // truthy but not strictly true - }, - }); - expect(b?.websocket).toBe(false); - }); - - test("websocket=true is preserved", () => { - const r = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { - target: "http://127.0.0.1:4321", - mount: "preview", - websocket: true, - }, - }); - expect(r?.websocket).toBe(true); - }); - - test("returns the original target string verbatim (not the parsed URL)", () => { - const target = "http://127.0.0.1:4321/some/path?q=1"; - const r = extractHttpProxy({ - "ai.nimblebrain/http-proxy": { target, mount: "preview" }, - }); - expect(r?.target).toBe(target); - }); -}); diff --git a/test/unit/lifecycle-connection.test.ts b/test/unit/lifecycle-connection.test.ts index 385bbc73..d3a2f1bc 100644 --- a/test/unit/lifecycle-connection.test.ts +++ b/test/unit/lifecycle-connection.test.ts @@ -25,7 +25,6 @@ function seedInstance(lifecycle: BundleLifecycleManager, serverName: string, wsI trustScore: null, ui: null, briefing: null, - httpProxy: null, protected: false, type: "plain", wsId, diff --git a/test/unit/lifecycle-startauth-coalesce.test.ts b/test/unit/lifecycle-startauth-coalesce.test.ts index 4f298042..7baa7010 100644 --- a/test/unit/lifecycle-startauth-coalesce.test.ts +++ b/test/unit/lifecycle-startauth-coalesce.test.ts @@ -44,7 +44,6 @@ function seedInstance( trustScore: null, ui: null, briefing: null, - httpProxy: null, protected: false, type: "plain", wsId, diff --git a/test/unit/lifecycle-startauth-disconnect.test.ts b/test/unit/lifecycle-startauth-disconnect.test.ts index 6653828b..d45c99a8 100644 --- a/test/unit/lifecycle-startauth-disconnect.test.ts +++ b/test/unit/lifecycle-startauth-disconnect.test.ts @@ -42,7 +42,6 @@ function seedInstance( trustScore: null, ui: null, briefing: null, - httpProxy: null, protected: false, type: "plain", wsId,