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.ts → extension_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 → registerRemotes → loadRemote). 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.ts→Map<'ext.<ext>.<name>', ResolvedExtAction>. - Dispatched via a fallthrough in
app/api/invoke/[...action]/route.ts: static built-ins are unchanged; a name startingext.that misses the static registry is resolved, input-validated (JSON Schema via the dependency-freelib/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: truealso registers it as an agent tool (ext_<ext>_<action>) inlib/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
*-mcproutes: Next prefers static segments, so the dynamic[name]never shadowsvercel-mcp; names ending in-mcpare 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):
- Trust tier (
uiTrustcolumn, decided by the host).decideUiTrust()grants the in-DOMfederatedtier only topublisher: 'nihmbus'and publishers inEXT_TRUSTED_PUBLISHERS. Everyone else is forced to the sandboxediframetier (cross-origin,sandboxattr) regardless of theirrequestedIsolation. No UI →denied. - SRI —
remoteEntryIntegrity(sha384) is verified before the remote loads. - CSP — opt-in
EXT_CSP=1(next.config.mjs) pinsscript-src/connect-src(add origins viaEXT_SCRIPT_SRC/EXT_CONNECT_SRC). Raises exfiltration cost; doesn't contain a determined remote.
Permission enforcement (was consent-only, now real)
- Granted at install (
granted_permissionscolumn, subset ofmanifest.permissions, viaextensions.install'sgrantPermissions). 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 toextension_secret_access(lib/extensions/runtime.tsauditSecretAccess). - 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+ applylib/db/rls/extension_records.sql;lib/db/rls.tssetsapp.workspace_id/app.ext_nameGUCs 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
connectionstable 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 inextensions/<name>/ui/, build topublic/ext-remotes/<name>/).actions→ MCP-backed (generate_landing_page/deploy;set_price).api→/summaryin-repo handler + the built-in/datastore.permissions→ includesdata.read/data.write;publisher: 'nihmbus'⇒uiTrust: 'federated'.
7. Migration & verification
- Migration:
0011_acoustic_black_crow.sqladdsextension_records,extension_secret_access, and thegranted_permissions/ui_trustcolumns. Apply withpnpm db:migrate. - Tests:
pnpm test—test/extensions-contract.test.tscovers manifest parsing, permission allow/deny,decideUiTrust, JSON-schema validation, the role gate, and that the bundled catalog parses with the new blocks. - Manual:
curlthe 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>; withEXT_RECORDS_RLS=1, confirm a cross-workspace direct query onextension_recordsreturns nothing.
8. Roadmap
- Declarative-REST / bundled-JS tool tiers (MCP is still the executable-tool tier).
- A
postMessagehost-SDK bridge for theiframetier (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).