Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
f9b4864
feat: oauthScope schema + WorkspaceOAuthProvider memberId support
mgoldsborough May 3, 2026
8628917
feat(track-b): MemberPoolSource + principalId threading through ToolS…
mgoldsborough May 3, 2026
b4deb88
feat(track-b): lifecycle.startMemberAuth + per-bundle MemberPoolSourc…
mgoldsborough May 3, 2026
c8e7984
feat(track-b): thread principalId through tool execution + agent-iden…
mgoldsborough May 3, 2026
bdfab78
feat(track-b): RFC 7009 token revocation on disconnect
mgoldsborough May 3, 2026
7ef77ce
feat(track-b): OIDC identity capture from id_token
mgoldsborough May 3, 2026
9e95f2b
feat(track-a): pre-registered OAuth client + scopes + additionalAutho…
mgoldsborough May 3, 2026
aeae42b
feat(track-a): nb credential CLI for managing workspace OAuth secrets
mgoldsborough May 3, 2026
5afbf6b
feat(track-c): catalog + connections routes (catalog/installed/discon…
mgoldsborough May 3, 2026
9d0a878
feat(track-c): ConnectionsTab UI + settings nav route
mgoldsborough May 3, 2026
6caaceb
chore: biome format + organize imports across Tracks A/B/C files
mgoldsborough May 3, 2026
36eebcf
fix(pr-review): address W1-W5 + S6+S7 from /pr-review audit
mgoldsborough May 4, 2026
56d5842
test(mcp-auth): align /initiate 404 test with Track B's bundle_not_fo…
mgoldsborough May 7, 2026
3824197
fix(oauth-flow-registry): swallow unhandled rejections on orphaned flows
mgoldsborough May 7, 2026
5f51956
feat(connections): one-click install + symmetric disconnect/reconnect…
mgoldsborough May 7, 2026
d515025
fix(connections): address /pr-review findings — static-auth gate + te…
mgoldsborough May 7, 2026
a36c5a2
fix(mcp-auth): callback redirects back to Connections instead of tell…
mgoldsborough May 7, 2026
bafc66d
fix(dev): auto-derive NB_WEB_URL from NB_WEB_PORT in supervised dev mode
mgoldsborough May 7, 2026
054d599
fix(dev): default NB_WEB_URL to localhost:27246 — mirror Vite's default
mgoldsborough May 7, 2026
18bf3b5
fix(connections): SSE-driven auto-refresh on state transitions
mgoldsborough May 7, 2026
70ca3b0
feat(connections): user-global personal connections + manage_connecti…
mgoldsborough May 7, 2026
cbd2931
refactor(connectors): rename connection vocabulary to connector
mgoldsborough May 7, 2026
3871574
feat(connectors): split top-level /connections into two scoped settin…
mgoldsborough May 7, 2026
f1d4e40
feat(connectors): per-tool permission system + interactive metadata
mgoldsborough May 7, 2026
f38697a
feat(connectors): Configure detail page with per-tool permission table
mgoldsborough May 7, 2026
e15c90b
test(connectors): unit coverage for PermissionStore
mgoldsborough May 7, 2026
3a5669b
ui(connectors): flatten Configure page — no cards, centered, top-righ…
mgoldsborough May 7, 2026
fd3532f
fix(connectors): list_tools resolves source from registry, not Connec…
mgoldsborough May 7, 2026
cf7c197
ui(connectors): trim tool name + summarize description in permissions…
mgoldsborough May 7, 2026
504e995
ui(connectors): unified minimal list + browse stub + uninstall flow
mgoldsborough May 7, 2026
5888105
ui(connectors): browse page detects already-installed entries
mgoldsborough May 7, 2026
84deccc
feat(connectors): registry abstraction — curated + mpak + admin config
mgoldsborough May 7, 2026
dbe09cf
fix(connectors): uninstall removes from workspace.json/user.json + in…
mgoldsborough May 7, 2026
d83dd85
fix(connectors): OAuth callback redirects to scoped /connectors page
mgoldsborough May 7, 2026
8c6dd17
ui(connectors): remove Personal Connectors UI; align Browse/Configure…
mgoldsborough May 7, 2026
dfd14d2
feat(connectors): operator OAuth app setup — Browse modal + admin act…
mgoldsborough May 7, 2026
69685cb
test(connectors): coverage for registry abstraction + operator setup
mgoldsborough May 7, 2026
f195e38
fix(connectors): protocol validation triple — portalUrl, set_url, cal…
mgoldsborough May 7, 2026
97d079e
chore(connectors): dead code + stale comments sweep
mgoldsborough May 7, 2026
311ced1
fix(connectors): test backstop + browse dedupe + revert resolveWithCo…
mgoldsborough May 7, 2026
15bb28c
fix(mcp-auth): close script-context XSS exit in OAuth callback HTML
mgoldsborough May 7, 2026
693b68c
perf + cleanup: hoist directory workspace fetch + drift fixes (W2, S3…
mgoldsborough May 7, 2026
e3c4402
fix: harden permission gate (fail-closed) + redact /initiate errors
mgoldsborough May 7, 2026
1acd50b
refactor: extract writeJsonAtomic helper (S5)
mgoldsborough May 7, 2026
6e214d7
revert(about): keep Installed Bundles section during Connectors bake-in
mgoldsborough May 7, 2026
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
18 changes: 13 additions & 5 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,12 +628,20 @@ export async function handleToolCall(
const t0 = performance.now();
let result: Awaited<ReturnType<typeof registry.execute>> | undefined;
try {
// Thread the calling member's identity to the registry so member-
// scoped MCP bundles route to the right per-principal source.
// Workspace-scoped sources ignore principalId entirely.
const principalId = identity?.id;
result = await runWithRequestContext(reqCtx, () =>
registry.execute({
id: callId,
name: toolName,
input: coercedArgs,
}),
registry.execute(
{
id: callId,
name: toolName,
input: coercedArgs,
},
undefined,
principalId,
),
);
} catch (err) {
const ms = Math.round(performance.now() - t0);
Expand Down
145 changes: 110 additions & 35 deletions src/api/routes/mcp-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { Hono } from "hono";
import { WORKSPACE_PRINCIPAL_ID } from "../../bundles/connection.ts";
import { log } from "../../cli/log.ts";
import { resolveWithCode } from "../../tools/oauth-flow-registry.ts";
import { requireAuth } from "../middleware/auth.ts";
import { requireWorkspace } from "../middleware/workspace.ts";
Expand Down Expand Up @@ -36,12 +37,11 @@ export function mcpAuthRoutes(ctx: AppContext) {

// ── POST /v1/mcp-auth/initiate ────────────────────────────────────
//
// Workspace-authed. Body: { serverName }. Step 1 is workspace-scope only,
// so the principal is always WORKSPACE_PRINCIPAL_ID — we deliberately
// do NOT read it from the request body. Step 3 will accept a validated
// `principalId` here, with the caller authenticated as that member and
// user-supplied "_"-prefixed values rejected (the underscore prefix is
// reserved for synthetic principals — see connection.ts:8-13).
// Workspace-authed. Body: { serverName }. Resolves the principal from
// the bundle's declared scope (workspace-scope → WORKSPACE_PRINCIPAL_ID;
// member-scope → calling user id). Calls `lifecycle.startAuth` which
// is idempotent on double-click and tears down stale sources (so
// disconnect → reconnect works without a process restart).
//
// Auth + workspace middleware applied per-handler (not via .use("*"))
// so the unauthenticated /callback below is unaffected. Hono's
Expand All @@ -63,16 +63,54 @@ export function mcpAuthRoutes(ctx: AppContext) {
if (!serverName) {
return apiError(400, "bad_request", "serverName is required.");
}
const principalId = WORKSPACE_PRINCIPAL_ID;

const wsId = c.var.workspaceId;
const lifecycle = ctx.runtime.getLifecycle();
const authorizationUrl = lifecycle.getPendingAuthUrl(serverName, wsId, principalId);
if (!authorizationUrl) {

const instance = lifecycle.getInstance(serverName, wsId);
if (!instance) {
return apiError(404, "bundle_not_found", `Bundle "${serverName}" not installed.`);
}

// Resolve the principal from the bundle's scope. Member-scope
// requires an authenticated identity to act as.
const oauthScope = instance.oauthScope ?? "workspace";
let principalId: string;
if (oauthScope === "user") {
const callerId = c.var.identity?.id;
if (!callerId) {
return apiError(
401,
"unauthenticated",
"Member-scope bundles require an authenticated user.",
);
}
principalId = callerId;
} else {
principalId = WORKSPACE_PRINCIPAL_ID;
}

let authorizationUrl: string;
try {
const apiBase = process.env.NB_API_URL ?? "http://localhost:27247";
const callbackUrl = `${apiBase.replace(/\/+$/, "")}/v1/mcp-auth/callback`;
const result = await lifecycle.startAuth(serverName, wsId, principalId, {
workDir: ctx.runtime.getWorkDir(),
callbackUrl,
allowInsecureRemotes: ctx.runtime.getAllowInsecureRemotes(),
});
authorizationUrl = result.authorizationUrl;
} catch (err) {
// Don't leak SDK / DNS / TLS details in the response body.
// Workspace-authed callers, but the surface is wide and the
// body crosses trust boundaries (proxies, browser dev tools,
// HAR export). Log raw server-side; return a generic shape.
const msg = err instanceof Error ? err.message : String(err);
log.warn(`[mcp-auth] startAuth failed for ${serverName} in ${wsId}: ${msg}`);
return apiError(
404,
"not_pending_auth",
`No pending OAuth flow for serverName="${serverName}" in this workspace.`,
500,
"auth_start_failed",
"Failed to start OAuth flow. Check server logs for details.",
);
}

Expand Down Expand Up @@ -113,25 +151,6 @@ export function mcpAuthRoutes(ctx: AppContext) {
},
);

// ── GET /v1/connections/pending ────────────────────────────────────
//
// Workspace-authed snapshot of Connections in pending_auth. The web
// client fetches this once on workspace render to populate the banner
// — connection.state_changed SSE events only fire from connect time
// forward, so a client that connects after a bundle entered
// pending_auth would otherwise miss the signal until reload.
app.get(
"/v1/connections/pending",
requireAuth(ctx.authOptions),
requireWorkspace(ctx.workspaceStore),
(c) => {
const wsId = c.var.workspaceId;
const lifecycle = ctx.runtime.getLifecycle();
const pending = lifecycle.getPendingConnections(wsId);
return c.json({ connections: pending });
},
);

// ── GET /v1/mcp-auth/callback ─────────────────────────────────────
//
// Unauthenticated. Verifies the cookie matches before resolving the
Expand Down Expand Up @@ -175,15 +194,20 @@ export function mcpAuthRoutes(ctx: AppContext) {
);
}

const matched = resolveWithCode(state, code);
if (!matched) {
if (!resolveWithCode(state, code)) {
return c.html(
"<html><body><h3>Unknown or expired OAuth flow.</h3>" +
"<p>Re-initiate the connection from NimbleBrain.</p></body></html>",
404,
);
}

// Every connector lands on the workspace Connectors page. Personal
// Connectors UI is parked, so user-scope bundles share the same
// landing for now; when Personal returns, scope-aware dispatch
// here can read `lifecycle.getInstance(serverName, wsId).oauthScope`
// to branch.

// Clear the one-shot state cookie so a refresh of this page can't
// be used as a replay vector.
const expireParts = [
Expand All @@ -196,9 +220,60 @@ export function mcpAuthRoutes(ctx: AppContext) {
if (!ctx.isLocalhost) expireParts.push("Secure");
c.header("Set-Cookie", expireParts.join("; "));

// Auto-redirect back to the Connectors page (Personal or Workspace
// depending on the bundle's scope). The user came from NimbleBrain
// and was navigated away to the OAuth provider in their existing
// tab — telling them to "close this tab" is wrong because they'd
// lose NimbleBrain entirely. We bring them home.
//
// Resolution order for the return URL:
// 1. NB_WEB_URL env (operator config — production should set this
// to the platform's user-facing origin)
// 2. NB_API_URL env (in single-origin deployments the API and
// SPA share a host)
// 3. The request origin (last-resort: callback hit us at ${origin},
// so the SPA is *probably* on the same origin)
const fallbackOrigin = (() => {
try {
return new URL(c.req.url).origin;
} catch {
return "";
}
})();
const webBase = process.env.NB_WEB_URL ?? process.env.NB_API_URL ?? fallbackOrigin;
let returnUrl = `${webBase.replace(/\/+$/, "")}/settings/workspace/connectors`;
// Defense-in-depth: NB_WEB_URL / NB_API_URL are operator-controlled,
// but a malformed value with a `javascript:` / `data:` scheme would
// survive escapeHtml (which only escapes `&<>"'`) and execute when
// the meta-refresh fires. Validate the protocol; fall back to a
// same-origin relative path if anything looks off.
try {
const parsed = new URL(returnUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
returnUrl = "/settings/workspace/connectors";
}
} catch {
returnUrl = "/settings/workspace/connectors";
}
const safeReturnUrl = escapeHtml(returnUrl);
// Inline-script injection guard. `JSON.stringify` produces a valid
// JS string literal but does NOT escape `</script>` or `<!--` —
// those sequences would break out of script context even though
// they don't contain HTML metacharacters that escapeHtml /
// protocol-allowlist catch. Encode `<` as `<` so any literal
// `</script>` / `<!--` in the URL becomes a benign string. The
// protocol allowlist above already covers `javascript:` / `data:`
// schemes; this closes the parallel script-context exit.
const safeJsReturnUrl = JSON.stringify(returnUrl).replace(/</g, "\\u003c");
return c.html(
"<html><body><h3>Authorization complete.</h3>" +
"<p>You can close this tab and return to NimbleBrain.</p></body></html>",
`<!doctype html><html><head><meta charset="utf-8"><title>Authorization complete</title>
<meta http-equiv="refresh" content="1;url=${safeReturnUrl}"></head>
<body style="font-family:system-ui,sans-serif;padding:2rem;max-width:32rem;margin:0 auto">
<h3 style="margin:0 0 0.5rem">Authorization complete</h3>
<p style="color:#555">Returning to NimbleBrain…</p>
<p><a href="${safeReturnUrl}">Click here if you aren't redirected →</a></p>
<script>setTimeout(function(){location.replace(${safeJsReturnUrl});},800);</script>
</body></html>`,
);
});

Expand Down
1 change: 1 addition & 0 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ export function startServer(options: ServerOptions): ServerHandle {
authOptions: { mode: authMode, internalToken, eventSink: runtime.getEventSink() },
provider: effectiveProvider,
workspaceStore: runtime.getWorkspaceStore(),
userConnectorStore: runtime.getUserConnectorStore(),
healthMonitor,
sseManager,
conversationEventManager,
Expand Down
8 changes: 8 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { EventSink } from "../engine/types.ts";
import type { IdentityProvider, UserIdentity } from "../identity/provider.ts";
import type { Runtime } from "../runtime/runtime.ts";
import type { HealthMonitor } from "../tools/health-monitor.ts";
import type { UserConnectorStore } from "../users/user-connector-store.ts";
import type { WorkspaceStore } from "../workspace/workspace-store.ts";
import type { AuthMiddlewareOptions } from "./auth-middleware.ts";
import type { ConversationEventManager } from "./conversation-events.ts";
Expand Down Expand Up @@ -67,6 +68,13 @@ export interface AppContext {
authOptions: AuthMiddlewareOptions;
provider: IdentityProvider;
workspaceStore: WorkspaceStore;
/**
* Per-user storage for personal connectors. Sits parallel to
* workspaceStore — `users/<userId>/user.json` for personal bundles +
* `users/<userId>/credentials/...` for the OAuth tokens those bundles
* authenticate against.
*/
userConnectorStore: UserConnectorStore;
healthMonitor: HealthMonitor;
sseManager: SseEventManager;
conversationEventManager: ConversationEventManager;
Expand Down
49 changes: 35 additions & 14 deletions src/bundles/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,32 @@ export const WORKSPACE_PRINCIPAL_ID = "_workspace";
* Client + Transport + auth provider.
*
* Transitions:
* starting → running (auth complete, client connected)
* starting → pending_auth (interactive OAuth required)
* pending_auth → running (user completed auth, retry succeeded)
* pending_auth → dead (auth timed out or refresh failed)
* running → crashed (transport error, may auto-recover)
* running → pending_auth (refresh failed mid-session)
* crashed → running (HealthMonitor recovered)
* crashed → dead (give up after retries)
* * → stopped (explicit stop / uninstall)
* (init) → not_authenticated (URL bundle installed, no tokens)
* (init) → starting (URL bundle has persisted tokens; attempting boot)
* not_authenticated → pending_auth (user clicked Connect; OAuth flow in progress)
* reauth_required → pending_auth (user clicked Reconnect after RT failure)
* pending_auth → running (callback succeeded; tokens stored)
* pending_auth → dead (callback failed or 15s timeout)
* starting → running (boot succeeded with persisted tokens)
* starting → reauth_required (boot failed; refresh token rejected)
* starting → dead (other transport / network failure)
* running → reauth_required (refresh failed mid-session)
* running → crashed (transport error; HealthMonitor may recover)
* running → not_authenticated (user clicked Disconnect)
* crashed → running (HealthMonitor recovered)
* crashed → dead (give up after retries)
* * → stopped (explicit uninstall — bundle removed)
*
* UI contract:
* - `not_authenticated`: silent. Connections card shows "Connect" button.
* - `pending_auth`: silent during normal flow (browser is being redirected). If we render anything, "Connecting…" + spinner.
* - `running`: green pill, "Disconnect" button.
* - `reauth_required`: amber pill "Reconnection needed", "Reconnect" button.
* - `dead`: red pill "Failed", "Reconnect" button.
* - `starting` / `crashed` / `stopped`: transient or end states; surfaced as needed.
*
* No global banner — surface state on the Connections page card and inline
* in chat when a tool call hits an unauthenticated bundle.
*/
export type ConnectionState = BundleState;

Expand Down Expand Up @@ -58,14 +75,16 @@ export interface Connection {
}

/**
* Compute a `BundleState` summary from a connection map. For Step 1
* (workspace-scope only) the map has one entry and we return its state
* directly. For Step 3 (member-scope), apply summary rules:
* Compute a `BundleState` summary from a connection map. The map has one
* entry for workspace-scope and N entries for member-scope (one per active
* member). Summary rules — most-functional state wins:
*
* - Empty map → "stopped" (no principals have ever connected)
* - Any "running" → "running"
* - Empty map → "stopped" (bundle exists but no principals have any state yet)
* - Any "running" → "running" (at least one principal can use the bundle)
* - Else any "pending_auth" → "pending_auth"
* - Else any "starting" → "starting"
* - Else any "reauth_required" → "reauth_required"
* - Else any "not_authenticated" → "not_authenticated"
* - Else any "crashed" → "crashed"
* - Else (all "dead" or "stopped") → first state encountered
*
Expand All @@ -81,6 +100,8 @@ export function summarizeConnectionState(connections: Map<string, Connection>):
if (states.includes("running")) return "running";
if (states.includes("pending_auth")) return "pending_auth";
if (states.includes("starting")) return "starting";
if (states.includes("reauth_required")) return "reauth_required";
if (states.includes("not_authenticated")) return "not_authenticated";
if (states.includes("crashed")) return "crashed";
return states[0]!;
}
Loading