Extension Supervisor
The Extension Supervisor is a 3-layer isolation system that sandboxes extension code using cross-origin iframes and a Web Worker. Two minimal static apps — the Supervisor and the Extension Runtime — enforce origin boundaries and message validation between the Lumio host page and untrusted extension bundles.
Architecture
Host (lumio.vision)
└─ Supervisor iframe (supervisor.ext.lumio.vision)
└─ Extension Runtime iframe (ext.lumio.vision)
└─ Web Worker (lumio-runtime.js + extension bundle)
Each layer runs on a distinct origin. The browser's same-origin policy ensures no layer can access another layer's DOM, cookies, or JavaScript globals. All communication travels via postMessage.
Why three layers
| Layer | Origin | Purpose |
|---|---|---|
| Host | lumio.vision | Renders the real DOM; owns overlay/widget/editor pages |
| Supervisor | supervisor.ext.lumio.vision | Validates messages against an allowlist; proxies approved messages |
| Extension Runtime | ext.lumio.vision | Hosts the Web Worker; blocks browser storage |
| Web Worker | (spawned by Runtime) | Runs extension code with no DOM access |
A two-layer design (Host → Runtime → Worker) would let extension code send arbitrary JSON-RPC methods to the Host if the Runtime were compromised. The Supervisor layer is the enforcement point that makes the allowlist tamper-resistant — it runs on a different origin the extension code cannot reach.
Domain setup
| Environment | Host | Supervisor | Extension Runtime |
|---|---|---|---|
Production (tag 20*) | lumio.vision | supervisor.ext.lumio.vision | ext.lumio.vision |
Prod preview (main) | lumio.web.prod.zaflun.dev | lumio.supervisor.prod.zaflun.dev | lumio.ext.prod.zaflun.dev |
Staging (next) | lumio.web.staging.zaflun.dev | lumio.supervisor.staging.zaflun.dev | lumio.ext.staging.zaflun.dev |
| Local dev | http://localhost:4000 | http://localhost:3200 | http://localhost:3201 |
These are the origin lists baked into each build; see apps/extension-supervisor/tsup.config.ts (defaults) and the ALLOWED_* env vars in .github/workflows/deploy-extension-apps.yml (per-environment overrides). Each domain requires DNS records pointing at its Cloudflare Pages project and matching custom-domain configuration in the CF Pages dashboard.
CF Pages deployment
Both apps are TypeScript bundled with tsup into a single IIFE (dist/supervisor.js, dist/runtime.js) alongside the checked-in index.html and style.css. The deploy workflow at .github/workflows/deploy-extension-apps.yml runs pnpm install --frozen-lockfile, builds with pnpm --filter @lumio/extension-{supervisor,runtime} build, deletes any *.map files, and deploys dist/ to separate CF Pages projects.
| Branch/Tag | Supervisor project | Runtime project |
|---|---|---|
next | lumio-extension-supervisor-staging | lumio-extension-runtime-staging |
main | lumio-extension-supervisor-prod | lumio-extension-runtime-prod |
20* tags | lumio-extension-supervisor | lumio-extension-runtime |
The workflow uses secrets.CF_API_TOKEN and secrets.CF_ACCOUNT_ID (same secrets as all other CF Pages workflows), plus SENTRY_DSN_SUPERVISOR / SENTRY_DSN_RUNTIME.
Security headers
Neither app has a committed _headers file. Each tsup.config.ts generates dist/_headers in its onSuccess hook from the same origin arrays it compiles into the bundle via define, so the CSP and the runtime postMessage allowlist can never drift apart. Both emit X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer plus a CSP:
| App | CSP directives beyond default-src 'none' |
|---|---|
| Supervisor | script-src 'self', style-src 'self', img-src 'self', frame-src {runtime origins}, connect-src https://sentry.smutje.dev, frame-ancestors {host origins} |
| Runtime | script-src 'self', style-src 'self', img-src 'self', worker-src 'self' {runtime-url origins}, connect-src https://sentry.smutje.dev, frame-ancestors {supervisor origins} |
Because the lists are per-environment, a build only advertises the origins of the environment it is built for -- the production Runtime build's frame-ancestors names only supervisor.ext.lumio.vision, the staging build only lumio.supervisor.staging.zaflun.dev.
When adding a new environment domain, update the origin defaults in both tsup.config.ts files and the matching ALLOWED_*_ORIGINS env vars in .github/workflows/deploy-extension-apps.yml, alongside DNS and CF Pages configuration.
Message validation allowlist
The Supervisor (apps/extension-supervisor/src/supervisor.ts) defines two sets:
Worker → Host (extension sends, host receives)
const ALLOWED_WORKER_TO_HOST = new Set<string>([
"ui.render",
"ui.ready",
"ui.keyframes",
"ui.error",
"storage.set",
"storage.get",
"server.query",
"server.mutation",
"action.execute",
"config.update",
"ui.action",
]);
Host → Worker (host sends, extension receives)
const ALLOWED_HOST_TO_WORKER = new Set<string>([
"lifecycle.init",
"lifecycle.destroy",
"event.callback",
"event.platform",
"storage.update",
"theme.change",
"config.update",
]);
Messages with method values outside these sets are dropped. JSON-RPC response objects (with id but no method) bypass method validation — they are replies to explicit requests and must always pass through. A request id is only registered in pendingRequestIds after the message passes the allowlist, so a rejected host→worker message cannot pre-authorise an unsolicited worker→host "response" carrying that id.
When adding a new JSON-RPC method to the protocol, you must also add it to the appropriate allowlist in apps/extension-supervisor/src/supervisor.ts. The Supervisor is deployed independently; a deploy of the Supervisor alone is sufficient (no web app deploy needed).
Security properties
| Property | Mechanism |
|---|---|
| Origin isolation | Three distinct origins; browser same-origin policy blocks cross-frame access |
| Message allowlist | Supervisor drops any method not in the allowlist |
| Origin allowlist | Both apps check event.origin against build-time origin sets (__ALLOWED_HOST_ORIGINS__ / __ALLOWED_RUNTIME_ORIGINS__ in the Supervisor, __ALLOWED_SUPERVISOR_ORIGINS__ / __ALLOWED_RUNTIME_URL_ORIGINS__ in the Runtime) |
| Storage isolation | Extension Runtime blocks localStorage, sessionStorage, indexedDB, caches and BroadcastChannel via non-configurable Object.defineProperty throwing getters, and neuters document.cookie (returns "", writes silently dropped) |
| Worker isolation | Web Worker has no DOM, no document, no window.location navigation |
| Network isolation | Extensions use ctx.fetch() in server functions (egress allowlist enforced in Rust) |
| CSP | Served as an HTTP header from the dist/_headers file each app generates at build time (see Security headers) |
The Extension Runtime blocks browser storage because all extensions share one Runtime origin. Without the block, Extension A could read Extension B's localStorage. Extensions use useExtensionStorage (backed by Redis, scoped per extension) instead. A per-extension origin remains the robust fix; the blocks are the mitigation while the origin is shared.
Dev mode setup
Build both apps
just dev-supervisor / just dev-runtime serve the already-built dist/ directories, so build first:
pnpm --filter @lumio/extension-supervisor build
pnpm --filter @lumio/extension-runtime build
Each build runs tsup and copies index.html + style.css into dist/. With no ALLOWED_* env vars set, the build bakes in the defaults from tsup.config.ts, which already include http://localhost:3200 / http://localhost:3201 / http://localhost:4000.
Run both apps
# Terminal 1
just dev-supervisor # npx serve apps/extension-supervisor/dist -l 3200 --cors
# Terminal 2
just dev-runtime # npx serve apps/extension-runtime/dist -l 3201 --cors
Configure the web app
The webapp reads two env vars:
NEXT_PUBLIC_SUPERVISOR_URL=http://localhost:3200
NEXT_PUBLIC_EXTENSION_RUNTIME_URL=http://localhost:3201
Their fallback is http://localhost:3200 / http://localhost:3201, not the production URLs — a deployed environment that leaves them unset points its extension iframes at localhost. Set them explicitly in every deployed environment.
Start the full dev stack
just stack-up # PostgreSQL, TimescaleDB, Redis, RabbitMQ, Kafka
just run-api # Rust API
just dev-web # Next.js web app
just dev-supervisor # Extension Supervisor
just dev-runtime # Extension Runtime
Troubleshooting
Message not forwarded (blocked by Supervisor)
The Supervisor logs a warning:
[Supervisor] Blocked worker→host message: some.unknown.method
[Supervisor] Blocked host→worker message: some.unknown.method
Solution: add the method to the appropriate set in apps/extension-supervisor/src/supervisor.ts, rebuild, and redeploy the Supervisor.
Extension iframe does not load
- Verify
NEXT_PUBLIC_SUPERVISOR_URLandNEXT_PUBLIC_EXTENSION_RUNTIME_URLare set correctly in the environment. - Check that the CF Pages project exists and has the correct custom domain configured.
- In local dev, confirm
just dev-supervisorandjust dev-runtimeare running. - Check browser DevTools → Console for CORS errors. The static apps must send
Access-Control-Allow-Originheaders — CF Pages does this by default.
Worker fails to start
The Extension Runtime sends a ui.error JSON-RPC notification when the Worker cannot be created:
{ "jsonrpc": "2.0", "method": "ui.error", "params": { "code": "WORKER_CREATE_FAILED", "message": "..." } }
The Host's ExtensionWorkerManager surfaces this via the onError callback. Common causes:
- The
runtimeUrlis unreachable or blocked by CSP. The Host builds it as${NEXT_PUBLIC_EXTENSION_RUNTIME_URL}/lumio-runtime.js; that bundle is produced bypackages/sdk(tsup.runtime.ts, entrysrc/runtime/worker-entry.ts) and must be served from the Extension Runtime origin. - The Worker script fails to parse (syntax error in extension bundle).
Inspect the Extension Runtime iframe's console for more details (use the context switcher in Chrome DevTools).
Supervisor not ready (lifecycle.init never sent)
ExtensionWorkerManager waits for { type: "supervisor", status: "loaded" } before sending { type: "init", ... }. If the Supervisor iframe fails to load at all, the loaded signal is never emitted and the extension stays in a pending state.
Check:
- Network tab in DevTools shows the Supervisor URL loading successfully (HTTP 200).
- No CSP or mixed-content errors block the iframe.