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 (<label>.runtime.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 | <label>.runtime.ext.lumio.vision (one origin per extension) | 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 | runtime.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 |
The Extension Runtime column is the shared base host of each environment. Extensions load from per-extension label hosts below it (next section); the base host itself is only the legacy origin during the rollout. These are the origin lists baked into each build; see the tsup.config.ts of both apps (defaults) and the ALLOWED_* env vars in .github/workflows/deploy-extension-apps.yml (per-environment overrides). Hosting per environment is described in Hosting.
Per-extension runtime origins
Every extension gets its own Extension Runtime origin, <label>.<runtime host>, where the label is the extension's UUID as 32 lowercase hex characters. The builds carry one template per environment (EXTENSION_RUNTIME_ORIGIN_TEMPLATES):
| Environment | Runtime origin template |
|---|---|
| Production | https://{label}.runtime.ext.lumio.vision |
| Prod preview | https://{label}.lumio.ext.prod.zaflun.dev |
| Staging | https://{label}.lumio.ext.staging.zaflun.dev |
| Local dev | http://{label}.localhost:3201 |
@zaflun/lumio-protocol/origin is the single source for the label and the origin: extensionRuntimeLabel(extensionId), extensionRuntimeOrigin(extensionId, template) and extensionRuntimeOriginLabel(origin, template). Each returns null for a non-canonical id (uppercase, missing hyphens, not a UUID) or an invalid template, and an invalid template fails the app build.
During the rollout both apps still accept the shared Runtime origins in the table above as a legacy rule (ALLOWED_RUNTIME_ORIGINS in the Supervisor, LEGACY_RUNTIME_ORIGINS in the Runtime). The legacy rule has no per-extension binding and is removed once every host loads extensions from their own origin.
Hosting
Both apps are TypeScript bundled with tsup into a single IIFE (dist/supervisor.global.js, dist/runtime.global.js) alongside the checked-in index.html and style.css. The Runtime's dist/ also carries the extension Worker, dist/lumio-runtime.js: its build script first runs build:worker, which builds the SDK's dependencies (@zaflun/lumio-protocol, @zaflun/lumio-extension-types) and then @zaflun/lumio-sdk's build:runtime (packages/sdk/tsup.runtime.ts, entry src/runtime/worker-entry.ts), and copies the resulting lumio-runtime.global.js into dist/ under the name the Host requests. The deploy workflow at .github/workflows/deploy-extension-apps.yml runs pnpm install --frozen-lockfile, builds with pnpm --filter @lumio/extension-{supervisor,runtime} build and deletes any *.map files. It also runs on changes under packages/sdk, packages/protocol and packages/extension-types, because the Worker bundle inlines all three.
Supervisor and shared Runtime host: Cloudflare Pages
The Supervisor dist/ and the Runtime dist/ are deployed to separate CF Pages projects. The Runtime Pages project serves the shared base host from the Domain setup table (the legacy origin) until the rollout ends.
| 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 |
Per-extension Runtime hosts: Workers Static Assets
The label hosts are served by one assets-only Cloudflare Worker per environment, configured in apps/extension-runtime/wrangler.jsonc and deployed from the same dist/ with wrangler deploy --env <env> right after the Pages deploy. The Worker has no code, so _headers applies to every response and static-asset requests are free.
| Branch/Tag | wrangler env | Worker | Route (zone) |
|---|---|---|---|
next | staging | lumio-extension-runtime-staging | *.lumio.ext.staging.zaflun.dev/* (zaflun.dev) |
main | prod-preview | lumio-extension-runtime-prod-preview | *.lumio.ext.prod.zaflun.dev/* (zaflun.dev) |
20* tags | production | lumio-extension-runtime-prod | *.runtime.ext.lumio.vision/* (lumio.vision) |
- One Worker per environment, because
_headersand the origin lists are baked into each build. assets.not_found_handlingis"none": a missing file answers404, neverindex.html. The Host always loads/, which staysindex.html.workers_devandpreview_urlsare off, so the label hosts are the only way to reach the Worker.- A wildcard route matches only hosts below the subtree, never the base host itself, and the production subtree
runtime.ext.lumio.visionsits next tosupervisor.ext.lumio.vision, not above it. The route therefore never catches the Supervisor or the shared Pages host. - Each environment needs a proxied wildcard DNS record without an origin (for example
AAAA *.lumio.ext.staging.zaflun.dev→100::) and an edge certificate for the wildcard:*.lumio.ext.staging.zaflun.devand*.lumio.ext.prod.zaflun.devin an Advanced Certificate onzaflun.dev;runtime.ext.lumio.visionand*.runtime.ext.lumio.visionneed Advanced Certificate Manager onlumio.vision, because Universal SSL covers only one level below the apex. - The
CF_API_TOKENsecret needs Account · Workers Scripts · Edit, Zone · Workers Routes · Edit and Zone · Zone · Read onzaflun.devandlumio.vision, in addition to the Pages permissions.
Smoke checks
After each Runtime deploy, a smoke-check step fails the job unless the Worker bundle is served as JavaScript:
/lumio-runtime.json the Pages deployment URL (and, on staging, onhttps://lumio.ext.staging.zaflun.dev) must reportapplication/javascriptortext/javascript. Without the file, the Pages SPA fallback answers200 text/htmlwithindex.html,new Worker()fails and no extension runs.- On staging, the fixed test label host
https://00000000000000000000000000000001.lumio.ext.staging.zaflun.devmust answer/lumio-runtime.jswith200JavaScript whose CSP contains'unsafe-eval'and noframe-ancestors(the!detach worked),/with200 text/htmlwhose CSP containsframe-ancestorsand the Supervisor origin, and/does-not-exist.jswith404.
The prod-preview and production label hosts and the public production hosts are not checked yet because they do not resolve.
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 origin templates as wildcards, e.g. https://*.runtime.ext.lumio.vision} {legacy 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', connect-src https://sentry.smutje.dev, frame-ancestors {supervisor origins} |
Runtime Worker (/lumio-runtime.js) | script-src 'self' 'unsafe-eval' -- nothing else |
A dedicated Worker takes its CSP from its own script response, not from the page that created it. The Runtime's _headers therefore has a second rule for /lumio-runtime.js that detaches the page policy (! Content-Security-Policy) and sets the Worker policy. The detach is required: Pages and Workers Static Assets join a header that two matching rules set with a comma, which yields two policies that are both enforced. The Worker needs 'unsafe-eval' because the Host fetches the extension bundle and the Worker entry evaluates it ((0, eval)(bundleCode)). The code it evaluates is untrusted by design and is confined by the Worker-global lockdown. default-src 'none' blocks fetch, XMLHttpRequest, WebSocket and importScripts from foreign origins in extension code, so every request goes through the SDK bridge.
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 and template defaults in both tsup.config.ts files and the matching ALLOWED_*_ORIGINS / EXTENSION_RUNTIME_ORIGIN_TEMPLATES / LEGACY_RUNTIME_ORIGINS env vars in .github/workflows/deploy-extension-apps.yml, alongside DNS, the CF Pages configuration and the route in apps/extension-runtime/wrangler.jsonc.
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. A worker → host message that also carries type: "supervisor" is dropped too - that type is reserved for the Supervisor's own status messages. 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 | The Supervisor checks event.origin against __ALLOWED_HOST_ORIGINS__ and the Runtime against __ALLOWED_SUPERVISOR_ORIGINS__; messages from the Runtime frame must come from the exact origin the Supervisor bound it to |
| Origin binding | The Supervisor loads extensionUrl only if its origin equals extensionRuntimeOrigin(init.extensionId, template) for a build template, or is a legacy Runtime origin; otherwise it refuses with EXTENSION_ORIGIN_MISMATCH (Sentry, code only, plus a supervisor error to the host). A cssUrl is forwarded only from that same origin |
| Runtime self-check | At startup the Runtime starts a Worker only if location.origin is a 32-hex label origin under its own build's template or a legacy Runtime origin; otherwise it reports INVALID_RUNTIME_ORIGIN and starts nothing. The ?runtime= script must be same-origin with the page (INVALID_RUNTIME_URL) |
| 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-global lockdown | Before any extension bundle is evaluated, the Worker entry (packages/sdk/src/runtime/lockdown.ts) replaces indexedDB, caches, BroadcastChannel, Worker, SharedWorker, the legacy webkit*FileSystem* functions and navigator.storage / navigator.locks / navigator.serviceWorker / navigator.storageBuckets with non-configurable throwing getters - on the Worker global and on every prototype that defines them. The extension-author view is in the developer docs (SDK → Extension sandbox) |
| Host boundary | The host accepts a message only when event.source is its Supervisor iframe and event.origin is the Supervisor origin, and posts every message (init, lifecycle.init, responses, destroy) to that exact origin |
| Supervisor control channel | type: "supervisor" messages (loaded, error, css) come only from the Supervisor itself: it drops relayed Runtime messages that carry that type, and the host links a css stylesheet only if it is the cssUrl the host sent in init |
| API CORS | No extension sandbox origin (any host with an ext label: runtime, per-extension runtime, Supervisor) is ever honoured as an API CORS origin; lo_api::cors::effective_allowed_origins drops such an entry from cors.allowed_origins / LUMIO__CORS__ALLOWED_ORIGINS and logs an error |
| Worker isolation | Web Worker has no DOM, no document, no window.location navigation |
| Network isolation | The Worker's CSP (default-src 'none') blocks network requests from extension UI code; extensions use ctx.fetch() in server functions (egress allowlist enforced in Rust) |
| Error tracking | Sentry in both frames is consent-gated: it starts only when the host's init carries errorTracking: true. ExtensionWorkerManager sets it only on dashboard hosts (errorTracking prop: widget editor, designer) whose user's stored choice grants Functional consent; OBS overlays and widgets never set it, so Sentry stays off there. The Supervisor starts its own Sentry from that flag and passes it to the Runtime as errorTracking=1 on the Runtime frame URL (any such parameter in extensionUrl is dropped first); the Runtime starts Sentry only with that parameter. The decision is read when the frame starts. No Session Replay. See Error Tracking |
| CSP | Served as an HTTP header from the dist/_headers file each app generates at build time (see Security headers) |
The per-extension Runtime origin is what keeps Extension A away from Extension B's client-side state. The storage blocks and the Worker-global lockdown stay as defence in depth - they still cover the legacy shared origin during the rollout - and as the SDK contract: extensions keep no client-side storage and use useExtensionStorage (backed by Redis, scoped per extension) instead.
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/; the Runtime build also builds the SDK Worker and copies it to dist/lumio-runtime.js. 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 these env vars:
LUMIO_SUPERVISOR_URL=http://localhost:3200
LUMIO_EXTENSION_RUNTIME_URL=http://localhost:3201
# LUMIO_EXTENSION_RUNTIME_ORIGIN_TEMPLATE=https://{label}.lumio.ext.staging.zaflun.dev
The web server resolves them per request in apps/web/src/lib/config.ts (getSupervisorUrl(), getExtensionRuntimeUrl(), getExtensionRuntimeOriginTemplate(), together getExtensionHostConfig()). Each name falls back to its NEXT_PUBLIC_* twin (NEXT_PUBLIC_SUPERVISOR_URL, NEXT_PUBLIC_EXTENSION_RUNTIME_URL, NEXT_PUBLIC_EXTENSION_RUNTIME_ORIGIN_TEMPLATE), and empty values count as unset. The layouts of the dashboard, the fullscreen editors, overlays/widgets and popouts pass the result to client components through ExtensionHostConfigProvider (apps/web/src/contexts/extension-host-context.tsx); ExtensionWorkerManager reads it with useExtensionHostConfig(), never from process.env. The CSP frame-src takes the supervisor from the same chain. A deployed container therefore picks up a changed value on restart, without a rebuild.
LUMIO_EXTENSION_RUNTIME_ORIGIN_TEMPLATE gives every extension its own runtime origin. The Host builds it in apps/web/src/lib/extension-runtime.ts with extensionRuntimeOrigin() from @zaflun/lumio-protocol/origin, the same helper the Supervisor checks extensionUrl with: {label} becomes the extension id as 32 lowercase hex characters. When the template is set but no origin can be built (invalid template or extension id), the Host does not fall back to the shared origin; ExtensionWorkerManager reports INVALID_RUNTIME_ORIGIN and loads nothing. When the template is unset, every extension shares LUMIO_EXTENSION_RUNTIME_URL - the behaviour from before per-extension origins, kept as the rollback switch while the Supervisor and Runtime still accept the legacy shared origin.
The fallbacks are 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, as LUMIO_* names in the container env. The NEXT_PUBLIC_* fallbacks only take effect when they are present at next build, and the release image is built without build args.
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
LUMIO_SUPERVISOR_URL,LUMIO_EXTENSION_RUNTIME_URLandLUMIO_EXTENSION_RUNTIME_ORIGIN_TEMPLATEare set correctly in the web container's environment. With a template, the Supervisor must have been built with the same template, or it refuses the frame withEXTENSION_ORIGIN_MISMATCH. - Check that the CF Pages project exists and has the correct custom domain configured, and that the label host resolves and is routed to the environment's Runtime Worker (
curl -sI https://<label>.<runtime host>/answers200). - 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<runtime origin>/lumio-runtime.js, where the runtime origin is the extension's own origin fromLUMIO_EXTENSION_RUNTIME_ORIGIN_TEMPLATEor, without a template,LUMIO_EXTENSION_RUNTIME_URL; that bundle is produced bypackages/sdk(tsup.runtime.ts, entrysrc/runtime/worker-entry.ts), copied into the Runtime'sdist/by itsbuildscript, and must be served from the Extension Runtime origin as JavaScript.curl -sI <runtime origin>/lumio-runtime.jsmust reportcontent-type: application/javascript;text/htmlmeans the file is missing and Pages servedindex.html; on a label host a missing file answers404. BUNDLE_EVAL_FAILEDwith a CSP message about'unsafe-eval': the Worker was served with the page policy instead of its own. Check the/lumio-runtime.jsrule in the generateddist/_headers.- 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.