Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<wsId>/apps/<bundle>/<mount>/*`. 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`.
Expand Down
10 changes: 0 additions & 10 deletions src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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/<wsId>/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
Expand Down
20 changes: 3 additions & 17 deletions src/api/middleware/security-headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
239 changes: 0 additions & 239 deletions src/api/routes/proxy.ts

This file was deleted.

66 changes: 1 addition & 65 deletions src/bundles/defaults.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -53,60 +44,5 @@ export function extractBundleMeta(manifest: Record<string, unknown>): 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<string, unknown> | undefined,
): HttpProxyConfig | null {
const raw = meta?.["ai.nimblebrain/http-proxy"];
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
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,
};
}
Loading