Extension contribution points — UI, HTTP, actions, data

Date: 2026-06-03 Scope: How an installed extension extends the product surface (not just agent tools/skills): a client-side React UI + nav, named callable actions, its own HTTP endpoints, and its own workspace-scoped data — all gated by enforced per-extension permissions. Also documents the platform security hardening folded into this work. Companion docs: /help/extensions/overview (author guide), /help/extensions/swapping-mcp-servers, code-audit-2026-05-27.md


TL;DR

Before this, an extension could only contribute MCP tools + SKILL.md skills. Now nihmbus.extension.json (API version 1.1.0, lib/extensions/manifest.ts) can also declare:

Block Adds Surfaced at Resolved/handled by
ui A client-side React panel + sidebar nav /x/<ext> + an "Extensions" nav group Module Federation (CSR, no SSR)
actions Named actions ext.<ext>.<name> /api/invoke/ext.<ext>.<name> + (optional) agent tools lib/extensions/actions-registry.ts
api An HTTP namespace /api/ext/<ext>/* extApi('/…') from the UI; external callers app/api/ext/[name]/[...path]/route.ts
(data) A per-extension KV/document store extApi('/data/<collection>/<key>') + the data API lib/extensions/data.tsextension_records

Everything runs inside the calling user's ServerContext, narrowed to the extension principal + its granted permissions. The whole thing is least-privilege: an extension can never exceed the user that invoked it.


1. Manifest additions (schema in lib/extensions/manifest.ts)

{
  // … existing name/version/publisher/secrets/mcp/skills …
 
  "permissions": ["spend", "deploy", "data.read", "data.write"],  // now ENFORCED, not just consent
 
  "ui": {
    "remoteName": "ext_vercel",                       // = the remote build's federation name
    "remoteEntry": "/ext-remotes/vercel/remoteEntry.js", // https URL (or same-origin path for first-party)
    "remoteEntryIntegrity": "sha384-…",               // optional SRI, verified before load
    "exposedModule": "./Panel",                       // default
    "requestedIsolation": "federated",                // a REQUEST; the host decides (see §4)
    "nav": [{ "id": "home", "label": "Vercel / v0", "icon": "▲", "route": "" }]
  },
 
  "actions": [
    {
      "name": "deploy",                               // → ext.vercel.deploy
      "description": "Deploy the project and attach a domain.",
      "method": "POST",
      "mutating": true,
      "inputSchema": { "type": "object", "properties": { "projectName": { "type": "string" } }, "required": ["projectName"] },
      "handler": { "kind": "mcp", "tool": "deploy" },  // or { "kind": "http", "path": "/deploy" }
      "requiredPermissions": ["deploy"],
      "exposeAsTool": false                            // true ⇒ also an agent tool
    }
  ],
 
  "api": {
    "baseUrl": "https://api.example.com",             // omit for first-party in-repo handlers
    "authSecret": "VERCEL_TOKEN",
    "headers": { "Authorization": "Bearer {secret}" },// {secret}/{secret.KEY} interpolation
    "routes": [
      { "method": "GET",  "path": "/summary", "requiredPermissions": [] },
      { "method": "GET",  "path": "/data",    "requiredPermissions": ["data.read"] },
      { "method": "PUT",  "path": "/data",    "requiredPermissions": ["data.write"] }
    ]
  }
}

All three blocks are optional and independent — a skills-only or MCP-only extension stays valid.


2. UI — client-side React Module Federation (CSR, no SSR)

Why federation (and why not the webpack plugin)

Next 16 builds with Turbopack, so the webpack ModuleFederationPlugin / @module-federation/nextjs-mf are not usable on the host. We use the framework-agnostic @module-federation/runtime: init() once, then loadRemote() per extension. React / React-DOM / react/jsx-runtime are registered as shared singletons pointing at the host's live instances, so a remote built with shared: { react: { singleton: true } } reuses the host's React (bundling its own would crash hooks with a duplicate-React error).

The pieces

File Role
lib/extensions/ui/federation-host.ts ensureFederationHost() (shares React singletons), loadExtensionModule() (SRI verify → registerRemotesloadRemote). Client-only.
lib/extensions/ui/host-sdk.ts The NihmbusHostSDK contract + createHostSdk() + snapshotThemeTokens().
lib/extensions/ui/remote-host.tsx Loads the remote, builds the SDK, renders <Panel sdk=… />. Loaded only via next/dynamic({ ssr:false }).
components/extensions/ext-remote-view.tsx The tier switch (federated → in-DOM; iframe → sandboxed; denied) + ErrorBoundary + Suspense.
app/(app)/x/[ext]/[[...slug]]/page.tsx Server component: resolve ctx → look up enabled install → read manifest.ui → hand off to the client renderer. 404 otherwise.
lib/extensions/ui/use-ext-nav.ts + extensions.list_ui Dynamic nav; rendered as an "Extensions" group in components/app-shell.tsx. viewFromPathname short-circuits /x/*.

The host SDK (the only way a remote reaches the platform)

interface NihmbusHostSDK {
  ext: { name; displayName }
  theme: { tokens: Record<string,string>; mode: 'light'|'dark' }   // host design-token snapshot
  invoke<T>(action, input?): Promise<T>     // same-origin POST /api/invoke (cookie-authed)
  extApi(path, init?): Promise<Response>     // pinned to /api/ext/<name>/…
  navigate(to): void
  notify(kind, message): void
}

The remote never sees a token. invoke/extApi are same-origin fetches carrying the user's session cookie, so the server resolves the real ServerContext and applies workspace scoping, the role gate, and the extension's granted permissions. Style against theme.tokens (the CSS-variable contract) so the panel matches the shell.

Building a UI remote (Vite)

Each remote is its own Vite build, separate from the Next app — see extensions/README-ui-remotes.md:

cd extensions/vercel/ui && pnpm install && pnpm build   # → public/ext-remotes/vercel/remoteEntry.js

@module-federation/vite config exposes ./Panel and marks React shared/singleton. First-party output goes to public/ext-remotes/<name>/ (served same-origin → the federated tier). A third party deploys remoteEntry.js to its own origin and ships that URL in the manifest.

public/ext-remotes/ is a build artifact — gitignored. Build it (locally / CI / postinstall) before /x/<ext> will render; until then the page shows the graceful error boundary, not a white screen.


3. Actions + HTTP + data (the backend half)

Dynamic actions

  • Resolved at request time by lib/extensions/actions-registry.tsMap<'ext.<ext>.<name>', ResolvedExtAction>.
  • Dispatched via a fallthrough in app/api/invoke/[...action]/route.ts: static built-ins are unchanged; a name starting ext. that misses the static registry is resolved, input-validated (JSON Schema via the dependency-free lib/extensions/json-validate.ts), permission-checked, and run in the extension-scoped context.
  • handler.kind: 'mcp' calls one of the extension's MCP tools; 'http' calls its reserved route.
  • exposeAsTool: true also registers it as an agent tool (ext_<ext>_<action>) in lib/tools/registry.ts.

Reserved HTTP namespace

app/api/ext/[name]/[...path]/route.ts: resolve ctx (401) → look up the enabled install (404) → rate-limit → match a declared route + enforce its requiredPermissions → dispatch to the in-repo handler (lib/extensions/inrepo-handlers.ts) or proxy to api.baseUrl (vault-authed, SSRF-guarded). /data/* is handled by the built-in store.

Coexists with the *-mcp routes: Next prefers static segments, so the dynamic [name] never shadows vercel-mcp; names ending in -mcp are rejected.

Per-extension data store

One shared table extension_records (workspace_id, extension_name, collection, key, jsonb value), not per-extension DDL (running untrusted migrations at install on serverless is an attack surface). lib/extensions/data.ts get/put/list/delete force whereWorkspace and extension_name = ctx.ext.name on every query — the extension never supplies its own name. Reads need data.read, writes need data.write.


4. Security model

In-DOM federation is NOT a sandbox (the central risk)

A federated remote runs in the host DOM with the signed-in user's ambient authority — same-origin fetch to /api/invoke/*, full DOM read. Server-side permission/role checks protect the backend; they do not protect the browser session from a malicious in-DOM remote. Mitigations (in lib/extensions/permissions.ts + config):

  1. Trust tier (uiTrust column, decided by the host). decideUiTrust() grants the in-DOM federated tier only to publisher: 'nihmbus' and publishers in EXT_TRUSTED_PUBLISHERS. Everyone else is forced to the sandboxed iframe tier (cross-origin, sandbox attr) regardless of their requestedIsolation. No UI → denied.
  2. SRIremoteEntryIntegrity (sha384) is verified before the remote loads.
  3. CSP — opt-in EXT_CSP=1 (next.config.mjs) pins script-src/connect-src (add origins via EXT_SCRIPT_SRC/EXT_CONNECT_SRC). Raises exfiltration cost; doesn't contain a determined remote.
  • Granted at install (granted_permissions column, subset of manifest.permissions, via extensions.install's grantPermissions).
  • assertExtPermissions(install, required) throws a 403-shaped error, called from the reserved route, the action fallthrough, and the data store.

Per-extension principal

ServerContext.ext = { name, permissions } (set via withExtension() in lib/auth/context.ts). It narrows the user's context — same workspace/role, plus the extension constraint — so whereWorkspace/stampForInsert are unchanged and an extension is always ≤ the calling user.

Audit + rate limit + optional RLS

  • Audit: every resolveExtensionSecret() for an extension appends to extension_secret_access (lib/extensions/runtime.ts auditSecretAccess).
  • Rate limit: per-(workspace, extension) token bucket (lib/extensions/rate-limit.ts). In-memory ⇒ per-instance on serverless; back with Redis for a hard global cap.
  • RLS (defense-in-depth, opt-in): EXT_RECORDS_RLS=1 + apply lib/db/rls/extension_records.sql; lib/db/rls.ts sets app.workspace_id/app.ext_name GUCs in a transaction so the policy can filter even if a query forgot a predicate.

5. Platform security hardening (folded into this work)

From the review punch list (code-audit-2026-05-27.md):

Fix Where
DEV_BYPASS_AUTH can't bypass auth in production (hard-fail / not honored) assertBypassAllowed/bypassEnabled in lib/auth/context.ts; middleware.ts requires non-prod
WEBHOOK_DEV_MODE is ignored in production (signatures enforced) lib/engagements/webhook-hmac.ts
COOKIE_ENCRYPTION_KEY already required in prod (no deterministic fallback) lib/crypto/cookie-encrypt.ts (verified)
Mutating actions deny the read-only viewer role by default (even without explicit requiresRole) lib/auth/role-gate.ts, wired into both dispatcher branches
extensions.install now requires owner/admin lib/schema/actions.ts

OAuth tokens: the review flagged "OAuth tokens stored unencrypted," but the connections table stores external reference IDs (Composio connection id, GitHub installation id, auth config id) — not raw access/refresh tokens, which live at the provider. Manually-entered secrets already use the encrypted vault. So there was nothing to encrypt there; no change made.


6. Reference extensions

vercel and commerce exercise every contribution point (catalog in lib/extensions/catalog.ts, canonical packages under extensions/<name>/):

  • ui → a federated ./Panel (source in extensions/<name>/ui/, build to public/ext-remotes/<name>/).
  • actions → MCP-backed (generate_landing_page/deploy; set_price).
  • api/summary in-repo handler + the built-in /data store.
  • permissions → includes data.read/data.write; publisher: 'nihmbus'uiTrust: 'federated'.

7. Migration & verification

  • Migration: 0011_acoustic_black_crow.sql adds extension_records, extension_secret_access, and the granted_permissions / ui_trust columns. Apply with pnpm db:migrate.
  • Tests: pnpm testtest/extensions-contract.test.ts covers manifest parsing, permission allow/deny, decideUiTrust, JSON-schema validation, the role gate, and that the bundled catalog parses with the new blocks.
  • Manual: curl the reserved route + /api/invoke/ext.<ext>.<action> with/without a session and with a denied permission (expect 401/403/200); build a UI remote and load /x/<ext>; with EXT_RECORDS_RLS=1, confirm a cross-workspace direct query on extension_records returns nothing.

8. Roadmap

  • Declarative-REST / bundled-JS tool tiers (MCP is still the executable-tool tier).
  • A postMessage host-SDK bridge for the iframe tier (today the iframe loads the remote's own page; full SDK parity for untrusted publishers is pending).
  • Redis-backed global rate limiting; broader RLS rollout beyond extension_records.
  • A hosted extension marketplace (today: bundled catalog + git/ClawHub/URL/local).