Talking to the platform
The capability bridge and the SDK — files, jobs, the data store, notifications.
- 1 declared
- 2 granted
- 3 scoped
- 4 REST call
"reports"{ id: "r-01", title: "Q1", … }{ id: "r-02", title: "Q2", … }{ id: "r-03", title: "Q3", … }putgetlistquerydeletemodule-bridge.ts) receives the versioned HANABI_* envelopes and answers them with permission + scope enforcement; the SDK (module-sdk) provides the matching client. A working end-to-end reference lives in examples/modules/capability-demo. Note: HANABI_CREATE_JOB runs the module's server worker. First-party modules register one in-process; a third-party module's worker runs in the isolated worker sandbox once it holds the admin-approved worker.execute permission and a sandbox backend is enabled (Phase 9). A module with no available worker gets a clean error, never arbitrary server execution.A module UI runs in a sandboxed iframe with an opaque origin: it has no cookies, no access to the shell's DOM, and no direct API access. Everything it needs from the platform goes through `postMessage`, brokered by the host (the shell) which holds the user's authenticated session.
module UI (sandboxed iframe) Hanabi shell (trusted parent) ─────────────────────────── ───────────────────────────── postHanabiMessage(req) ───── postMessage ─────▶ capability bridge 1. verify event.source is this iframe 2. check manifest declares the permission 3. check the user granted it 4. scope to the module's folders 5. call the authenticated REST API result/event ◀───── postMessage ────── reply { rid, ok, data | error }
Envelope #
Every message is a JSON object with a protocol version and a type:
{ "v": 1, "type": "HANABI_<NAME>", "rid": "<correlation-id>", /* …payload… */ }
v— protocol version (currently1). The host supports a range and replies{ ok: false, error: { code: "UNSUPPORTED" } }for types/versions it doesn't know — never a silent drop.rid— correlation id the module generates per request; the host echoes it on the reply. Events pushed by the host (e.g. job progress) omitrid.
Module → host requests #
| Type | Payload | Requires | Host does |
|---|---|---|---|
HANABI_READY | — | — | Marks the module loaded; the reply is the module context (id, name, version, permissions, folders, window, openedFile, and launchAction). openedFile is set ({id, name, mime_type, size_bytes}) when launched via Open with — see File associations. launchAction is set to a quick-action id when launched from a Start-menu jump-list — see Jump-list actions. |
HANABI_GET_CONTEXT | — | — | Re-fetch the module context (same shape as the HANABI_READY reply) at any time. |
HANABI_PICK_FILE | accepts?, scope? | files.read (own) · consented files.read.all (scope: "user") | Default: returns the module's input folder listing. With scope: "user", opens a host file-open dialog over the user's workspace (never other modules' folders) and returns the single picked file. SDK: pickFile() / pickUserFile(). |
HANABI_READ_FILE | fileId | files.read (own folders) or consented files.read.all (broad) | Returns file bytes (base64) + metadata. Allowed for the module's own files, or — with a consented files.read.all grant — any user file outside other modules' folders. |
HANABI_OPEN_MEDIA | fileId | files.read (own folders) or consented files.read.all (broad) | Media streaming. Same scope gate as HANABI_READ_FILE, but instead of returning bytes it returns { url, name, mime_type, size_bytes, expires_in } — a short-lived, range-capable URL the module drops into <video>/<audio>/<img>. The host mints a signed per-file ticket; GET /runtime/media/{ticket} answers Range with 206, so playback can seek without any bytes crossing the bridge. Use this for large or seekable media; use HANABI_READ_FILE for small files you need the bytes of. |
HANABI_WRITE_FILE | folderRole, name, bytesB64 | files.write | Writes into the module's folder of that role (default output); returns the new file ref. |
HANABI_READ_OFFICE | fileId | files.read (own folders or the launch file) | In-place office editing. Parses a spreadsheet/document into structured content (sheets + rows, or paragraphs) using the platform's openpyxl/document engine — so a packaged editor can load the file it was opened with. Same scope gate as HANABI_READ_FILE. SDK: readOffice. |
HANABI_SAVE_OFFICE | fileId, payload | files.write | In-place office editing. Reserialises edited content and overwrites the file in place — the launched file, or one in the module's writable scope. payload is { kind: "spreadsheet", sheets } or { kind: "document", paragraphs }. The first capability that writes back to a file outside the module's own folders, gated strictly to the user-opened file. SDK: saveOffice. |
HANABI_FS_LIST | folderRole?, folderId? | files.read (own) · consented files.read.all (broad) | VFS management. Lists a folder's files + subfolders (defaults to the module's input/root folder). SDK: fs.list. |
HANABI_FS_STAT | fileId? / folderId? | files.read (own) · consented files.read.all (broad) | VFS management. Metadata for one file or folder. SDK: fs.stat. |
HANABI_FS_MKDIR | name, parentFolderRole?, parentFolderId? | files.manage (own) · consented files.write.all (broad) | VFS management. Creates a subfolder under a target folder (defaults to the module's workspace folder). SDK: fs.makeDir. |
HANABI_FS_RENAME | fileId? / folderId?, name | files.manage (own) · consented files.write.all (broad) | VFS management. Renames a file or folder. SDK: fs.rename. |
HANABI_FS_MOVE | fileId? / folderId?, destFolderRole?, destFolderId? | files.manage (own) · consented files.write.all (broad) | VFS management. Moves a file or folder into a target folder (default output). SDK: fs.move. |
HANABI_FS_COPY | fileId, destFolderRole?, destFolderId? | files.manage + files.read (own) · consented files.write.all (broad) | VFS management. Copies a file into a target folder (keeps both on a name clash). SDK: fs.copy. |
HANABI_FS_DELETE | fileId? / folderId? | files.manage (own) · consented files.write.all (broad) | VFS management. Moves a file or folder to the Trash (restorable; never a permanent delete). SDK: fs.remove. |
HANABI_CREATE_JOB | options, inputRefs?, stream? | jobs.create | Runs a module job and returns it. With stream: true (SDK: pass onProgress/onOutput to createJob), drives the module's progress worker and streams live HANABI_JOB_PROGRESS events and, when the worker emits them, HANABI_JOB_OUTPUT output chunks to the module. |
HANABI_LIST_JOBS | — | jobs.create | This module's own past jobs for the signed-in user (newest first; completed runs that produced outputs) — backs an in-module history / re-download view. SDK: listJobs. |
HANABI_QUEUE_TASK | options?, inputRefs? | jobs.queue | Background task queue (B1). Enqueues a module job that runs in the background (non-blocking) instead of synchronously in the request; returns the queued job immediately. Quota-bound (max in-flight tasks per module). SDK: queue.enqueue. |
HANABI_GET_JOB | jobId | jobs.queue | Polls one of this module's own jobs by id (queued/running/completed/failed). Scoped — a module can only ever read its own jobs. SDK: queue.get. |
HANABI_CANCEL_JOB | jobId | jobs.queue | Cancels a still-queued task (a task already running can't be interrupted mid-flight). SDK: queue.cancel. |
HANABI_SCHEDULE_LIST | — | jobs.schedule | Scheduled tasks (B2). Lists this module's recurring schedules for the user. SDK: schedules.list. |
HANABI_SCHEDULE_CREATE | name?, triggerType?, trigger, options?, inputRefs? | jobs.schedule (admin-approved) | Creates a schedule that fires a background job (B1) on an interval (trigger: { seconds }) or cron (trigger: { cron }) trigger — even while the UI is closed. Quota-bound (max schedules + minimum interval); the platform fires it server-side via APScheduler. SDK: schedules.create. |
HANABI_SCHEDULE_TOGGLE | scheduleId, enabled | jobs.schedule | Enables or disables a schedule (disabled stops firing but is kept). SDK: schedules.toggle. |
HANABI_SCHEDULE_DELETE | scheduleId | jobs.schedule | Deletes a schedule and its trigger. SDK: schedules.delete. |
HANABI_STORAGE_USAGE | — | storage.read | The signed-in user's storage footprint: { limit_mb, used_bytes, remaining_bytes } (limit_mb/remaining_bytes are null when unlimited). Read-only; the user's own aggregates only. SDK: storageUsage. |
HANABI_DATA_LIST_COLLECTIONS | — | data.store | Structured data store (C1). Lists this module's collections with document counts, plus its usage vs the resolved quota. Strictly scoped to (signed-in user, module). SDK: data.collections. |
HANABI_DATA_LIST | collection, limit?, offset? | data.store | A page of JSON documents in a collection, newest-updated first ({ total, limit, offset, docs }). SDK: data.list. |
HANABI_DATA_GET | collection, id | data.store | One document by id, or null if it doesn't exist. data is the raw JSON string the module stored. SDK: data.get. |
HANABI_DATA_PUT | collection, data, id? | data.store | Upserts a JSON-serialisable document. Omit id to create with a server-minted id; pass it to overwrite. Enforces the per-module collection / record / byte quotas. SDK: data.put. |
HANABI_DATA_QUERY | collection, where?, limit? | data.store | Equality filter over a collection's top-level JSON fields, evaluated over a bounded scan (truncated flags when the scan was capped — page large sets with HANABI_DATA_LIST). SDK: data.query. |
HANABI_DATA_DELETE | collection, id | data.store | Deletes one document; existed is false if it was already gone. SDK: data.delete. |
HANABI_GET_SETTING | key | settings.self | Returns a value from the module's durable namespaced store — per-user and server-side, so it survives a browser cache clear and follows the user across devices. Falls back to a local cache when the server is unreachable. |
HANABI_SET_SETTING | key, value | settings.self | Persists a value in the module's durable namespaced store (per-user, server-side, with a local write-through cache). Scoped to this module + user; values are capped at 256 KiB each. |
HANABI_OPEN_FOLDER | folderRole? | files.read or files.write | Opens File Explorer to one of the module's own folders (default output). Scoped — a module can only reveal its own declared folders. |
HANABI_NOTIFY | message, title?, tone? | notifications | Desktop integration. Raises a desktop notification (toast + notification centre). title defaults to the module's name; tone is info (default) / success / warning / error. Title/message are length-clamped so a module can't spoof another app or flood the UI. |
HANABI_SET_BADGE | count | notifications | Desktop integration. Sets a count badge on the module's own taskbar icon; count <= 0 clears it (large counts render as 99+). Affects only this module's icon. |
HANABI_LIST_MODULES | — | modules.use | Inter-module. Lists other installed, launchable modules as {id, name, icon, file_associations} (self excluded). Public catalog facts only — never another module's data, files, or settings. |
HANABI_SEND_TO_MODULE | moduleId, fileId | modules.use + files.read | Inter-module handoff. Opens another installed module (in a new, user-visible window) with one of this module's own scoped files delivered as its openedFile. The file must be readable by this module; the target gets a launch-scoped read of just that file. No silent invocation, no access to the target's own data. |
HANABI_DESKTOP_LIST | — | desktop.read (consent) | Desktop workspace. Read-only snapshot of the user's Desktop: app icons + items (files/folders on the Desktop). SDK: desktop.list. |
HANABI_DESKTOP_PLACE | fileId? / folderId?, copy? | desktop.workspace (consent) | Desktop workspace. Places one of the module's own files/folders onto the user's Desktop (copy: true keeps the original, files only; default moves it). The item must be readable by this module. SDK: desktop.place. |
HANABI_DESKTOP_REMOVE | fileId? / folderId? | desktop.workspace (consent) | Desktop workspace. Removes from the Desktop a file this module placed there (verified by source_module_id); goes to the Trash. Never touches the user's own desktop items. SDK: desktop.remove. |
HANABI_DESKTOP_SHORTCUT | label?, action?, fileId? | desktop.shortcuts (consent) | Desktop workspace. Pins a launcher on the Desktop that reopens this module, optionally to a jump-list action and/or one of the module's own files (fileId) as its openedFile. Returns the created shortcut. SDK: desktop.createShortcut. |
HANABI_DESKTOP_REMOVE_SHORTCUT | shortcutId | desktop.shortcuts (consent) | Desktop workspace. Removes a desktop shortcut this module created. SDK: desktop.removeShortcut. |
HANABI_DESKTOP_APPEARANCE | — | desktop.personalize (consent) | Desktop workspace. Reads the current workspace appearance — { wallpaper, accent, theme }. SDK: desktop.getAppearance. |
HANABI_DESKTOP_PERSONALIZE | wallpaper?, accent?, theme? | desktop.personalize (consent) | Desktop workspace. Sets the workspace wallpaper (a built-in name only — no module-supplied asset), accent (#rrggbb), and/or theme (light/dark). The host validates each field and returns the applied appearance, rejecting unknown values. SDK: desktop.setAppearance. |
HANABI_MODULE_REQUEST | method?, path, body?, stream? | first-party module with a server router | Module-owned routes. Calls one of this module's own server routes (e.g. a /state, /parse, or /generate route a first-party module ships in the API). The opaque-origin iframe can't reach /runtime itself, so the host proxies the call (adding the session cookie + CSRF) — clamping path to the module's own /runtime/modules/<id>/ subtree and blocking the generic capability routes (jobs/app/kv/media-ticket). With stream: true the route returns newline-delimited JSON and each line is relayed as a HANABI_MODULE_STREAM event. SDK: moduleRequest / moduleStream. |
Host → module replies & events #
- Reply to any request:
{ "v": 1, "type": "<TYPE>_RESULT", "rid": "...", "ok": true, "data": {...} }or{ "ok": false, "error": { "code": "...", "message": "..." } }. - Event, correlated by the
ridof the originating streaming request (HANABI_CREATE_JOBor astream: trueHANABI_MODULE_REQUEST). The originating request stays open until its terminal_RESULTreply resolves it: - progress —
{ "v": 1, "type": "HANABI_JOB_PROGRESS", "rid": "...", "jobId": "...", "progressPct": 50, "message": "…" }. - streamed output —
{ "v": 1, "type": "HANABI_JOB_OUTPUT", "rid": "...", "jobId": "...", "seq": 1, "chunk": "…", "encoding": "text" | "base64" }. A progress worker emits these by yielding{"output": "…", "encoding": "text"|"base64"}; use them for live logs, progressive/token output, or incremental results. Chunks are transient (not persisted) and arrive inseqorder, interleaved with progress. - streamed module-route line —
{ "v": 1, "type": "HANABI_MODULE_STREAM", "rid": "...", "event": { … } }— one parsed newline-delimited-JSON object from astream: trueHANABI_MODULE_REQUEST(moduleStream). The host reads the route's NDJSON response and relays each line as it lands; a terminal{ "error": "…" }line rejects the call.
Error codes
| Code | Meaning |
|---|---|
PERMISSION_DENIED | The manifest didn't declare, or the user didn't grant, the permission. |
OUT_OF_SCOPE | The request named a file/path outside the module's folders. |
NOT_FOUND | Target file/job doesn't exist for this user. |
UNSUPPORTED | Unknown message type or protocol version. |
INVALID | Malformed payload. |
File associations (Open with) #
A module can declare the file types it opens in its manifest:
"desktop": { "file_associations": ["md", "txt"] }
Extensions are lowercased and dot-stripped. An installed module that declares an extension then appears in File Explorer's Open with menu (and can be set as the default app) for matching files. Choosing it launches the module with the file delivered as openedFile in the module context (HANABI_READY).
Because the user explicitly chose “open this file with this module”, that single file is readable for the launch — via HANABI_READ_FILE or HANABI_OPEN_MEDIA using openedFile.id — even though it lives outside the module's own folders. No other out-of-scope file becomes reachable. The module still needs files.read.
Jump-list actions #
A module can contribute quick actions to the Start menu (a jump-list) in its manifest:
"desktop": { "jump_list": [{ "id": "new-note", "label": "New note" }] }
Each action appears as an indented sub-row under the module in All programs. Choosing one launches the module with the action's id delivered as launchAction in the module context (HANABI_READY / HANABI_GET_CONTEXT); the module reads it on load and runs the matching action (e.g. open a blank document). Entries are validated and clamped (up to 6 actions; ids and labels are length-limited). No permission is required — a jump-list only ever launches the module that declares it, with no file or data access implied.
Using the SDK (module side) #
The @hanabi/module-sdk ships createHanabiClient(), which handles the versioned envelope and rid correlation so you write await calls instead of wiring postMessage by hand:
import { createHanabiClient } from '@hanabi/module-sdk'; const hanabi = createHanabiClient(); // Read the grant + folder context (also answers HANABI_READY). const ctx = await hanabi.getContext(); // Pick and read an input file (requires files.read). const { files } = await hanabi.pickFile(['xlsx', 'csv']); const file = await hanabi.readFile(files[0].id); // file.bytesB64 // Write an output file (requires files.write). await hanabi.writeFile('result.txt', btoa('done'), 'output'); // Clean up the listener when your view tears down. // hanabi.dispose();
SDK client methods
createHanabiClient() returns a typed client — one method per capability, plus call(type, payload, onProgress?, onOutput?) as the escape hatch:
| Method | Message | Needs | Returns |
|---|---|---|---|
ready() · getContext() | HANABI_READY · HANABI_GET_CONTEXT | — | the module context (openedFile, launchAction, permissions, folders, window) |
pickFile(accepts?) | HANABI_PICK_FILE | files.read | { files } from the module's input folder |
pickUserFile(accepts?) | HANABI_PICK_FILE (scope:"user") | consented files.read.all | { file } from a host file-open dialog |
readFile(fileId) | HANABI_READ_FILE | files.read | { id, name, mime_type, size_bytes, bytesB64 } |
openMedia(fileId) | HANABI_OPEN_MEDIA | files.read | { url, … } — a range-capable stream URL |
writeFile(name, bytesB64, folderRole?, subpath?) | HANABI_WRITE_FILE | files.write | { file }; subpath nests it under auto-created sub-folders of the role folder |
readOffice(fileId) | HANABI_READ_OFFICE | files.read | parsed office content ({ kind, sheets } / { kind, paragraphs }) |
saveOffice(fileId, payload) | HANABI_SAVE_OFFICE | files.write | overwrites the file in place; returns the saved ref |
createJob(options?, inputRefs?, onProgress?, onOutput?) | HANABI_CREATE_JOB | jobs.create | { job }; pass onProgress/onOutput to stream |
listJobs() | HANABI_LIST_JOBS | jobs.create | this module's past jobs for the user (newest first) |
queue.enqueue(opts?, inputRefs?) · queue.get(id) · queue.cancel(id) · queue.list() | HANABI_QUEUE_TASK · HANABI_GET_JOB · HANABI_CANCEL_JOB · HANABI_LIST_JOBS | jobs.queue | background task queue: enqueue non-blocking work, poll, cancel a queued task |
schedules.list() · schedules.create(spec) · schedules.toggle(id, on) · schedules.delete(id) | HANABI_SCHEDULE_* | jobs.schedule | recurring schedules that fire a background job on an interval/cron trigger |
storageUsage() | HANABI_STORAGE_USAGE | storage.read | the user's storage { limit_mb, used_bytes, remaining_bytes } |
data.collections() · data.list(c, opts?) · data.get(c, id) · data.put(c, data, id?) · data.query(c, where?, limit?) · data.delete(c, id) | HANABI_DATA_* | data.store | structured JSON-document store scoped to (user, module); quota-bound collections |
moduleRequest(method, path, body?) | HANABI_MODULE_REQUEST | first-party server route | the route's JSON response |
moduleStream(method, path, body, onEvent) | HANABI_MODULE_REQUEST (stream) | first-party server route | resolves when the NDJSON stream ends; onEvent per line |
getSetting(key) · setSetting(key, value) | HANABI_GET_SETTING · _SET_SETTING | settings.self | durable per-user value |
openFolder(folderRole?) | HANABI_OPEN_FOLDER | files.read/files.write | { path, role } |
fs.list(target?) · fs.stat(ref) | HANABI_FS_LIST · HANABI_FS_STAT | files.read (own) · files.read.all (broad) | a folder's { folders, files }; a file/folder's metadata |
fs.makeDir(name, parent?) · fs.rename(ref, name) · fs.move(ref, dest?) · fs.copy(fileId, dest?) · fs.remove(ref) | HANABI_FS_* | files.manage (own) · files.write.all (broad) | VFS management — subfolders, rename/move/copy; remove trashes (restorable) |
notify(message, { title?, tone? }) | HANABI_NOTIFY | notifications | { ok } |
setBadge(count) | HANABI_SET_BADGE | notifications | { ok, count } |
onFilesDropped(cb) | HANABI_ENABLE_FILE_DROP | — | receive files the user drags onto the module's window (the host reads the folder and forwards them); returns an unsubscribe |
listModules() | HANABI_LIST_MODULES | modules.use | { modules } |
sendToModule(moduleId, fileId) | HANABI_SEND_TO_MODULE | modules.use + files.read | { ok, moduleId } |
desktop.list() | HANABI_DESKTOP_LIST | desktop.read | the Desktop's { icons, items } |
desktop.place(ref, { copy? }) · desktop.remove(ref) | HANABI_DESKTOP_PLACE · _REMOVE | desktop.workspace | put the module's files/folders on the Desktop; remove ones it placed |
desktop.createShortcut(spec?) · desktop.removeShortcut(id) | HANABI_DESKTOP_SHORTCUT · _REMOVE_SHORTCUT | desktop.shortcuts | pin/unpin a desktop launcher that reopens the module |
desktop.getAppearance() · desktop.setAppearance(patch) | HANABI_DESKTOP_APPEARANCE · _PERSONALIZE | desktop.personalize | read/set wallpaper, accent, theme (validated) |
dispose() | — | — | remove the message listener (call on teardown) |
// Stream a job's progress + live output, then raise a notification when done. await hanabi.createJob({ mode: 'convert' }, refs, (p) => setProgress(p.progressPct), (o) => appendLog(o.encoding === 'base64' ? atob(o.chunk) : o.chunk)); await hanabi.notify('Conversion complete', { tone: 'success' }); // Stream a scoped video without copying bytes over the bridge. const media = await hanabi.openMedia(fileId); videoEl.src = media.url; // supports HTTP range / seeking // Persist a durable, cross-device setting (survives a cache clear). await hanabi.setSetting('theme', 'dark'); // Edit a spreadsheet in place — the file this module was opened with. const wb = await hanabi.readOffice(ctx.openedFile.id); // { kind, sheets } await hanabi.saveOffice(ctx.openedFile.id, { kind: 'spreadsheet', sheets: wb.sheets }); // Call this (first-party) module's own server route; stream a long one. const preview = await hanabi.moduleRequest('POST', 'preview', { input_refs, options }); await hanabi.moduleStream('POST', 'generate-stream', { input_refs, options }, (e) => markDone(e.index, e.total)); // one NDJSON line per finished item
If you can't bundle the SDK (a plain-HTML module), copy the ~20-line inline client from examples/modules/capability-demo/ui/index.html — it speaks the exact same protocol.
Capability tiers & admin governance #
Every permission has a security tier — the single source of truth is the backend capability registry (services/capabilities.py):
| Tier | Granted by | Default | Examples |
|---|---|---|---|
| baseline | implied by install | on | files.read/write/manage, jobs.create, settings.self, notifications, storage.read, data.store, jobs.queue |
| consentA one-time prompt the user approves to allow an elevated permission like files.read.all. | per-user prompt at install / first use | off | files.read.all/write.all, desktop.read/workspace/shortcuts/personalize, client.webgl/gamepad/pointerlock/fullscreen/webcodecs |
| admin | publish review (binary) | off | network.fetch, worker.execute, client.wasm, jobs.schedule |
| admin-HIGH | publish review + an explicit per-capability admin toggle + quota | off | worker.native, worker.service, network.fetch.broad |
admin-HIGH capabilities are denied unless an admin individually grants them for a module and sets their quota (resource tier, egress budget, …). Grants are recorded on the module's developer draft and enforced at runtime by enforce_capability (capability_enforcement.py), which also audits each use. Adding or escalating any admin / admin-HIGH capability on an update forces a manual re-review (the module stays live on its prior approved version meanwhile). First-party / built-in modules are trusted.
The three enforcement checks (why this is safe) #
A capability is honored only if all three hold — this is the security spine, and it reuses the boundaries in ../security-model.md:
- Declared — the manifest lists the matching
permission. - Granted — the installing user consented (auto-granted for first-party; explicit consent UI for third-party — Phase 5).
- ScopedClamped to your own Modules/<Name>/ folders for the current user — never another module or user. — file operations are clamped to the module's own
Modules/<Name>/…folders for that user. A module can never name a file id or path belonging to another module or another user.
The bridge runs in the trusted parent and calls the same authenticated, CSRF-protected API the rest of the app uses; it never hands the iframe raw storage keys, cookies, or cross-scope data.
Isolation summary #
<iframe sandbox="allow-scripts">withoutallow-same-origin→ opaque origin, no cookie/DOM access.- Host validates
event.source === iframe.contentWindowand ignores all other senders. - Assets served read-only with
X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer, a locked-downPermissions-Policy, and a per-moduleContent-Security-Policy. With a distinct module-serving origin (HANABI_MODULE_ORIGIN, Phase 11) the CSP names that origin explicitly in the asset directives (default/script/style/img/media/font/frame-src/worker-src) — the iframe's opaque origin makes a bare'self'match nothing for sub-resources — while keepingconnect-srcstrict ('self'+ any declared network origins) andobject-src 'none'.frame-srcandworker-srcalso allowblob:, so a module can frame a blob document it built from its own bytes and run a web worker it bundles inline — e.g. PDF.js, which a module uses to render a PDF preview to a<canvas>because the browser's built-in PDF viewer is blocked in the sandbox;<embed>/<object>stay blocked. The module document is servedno-storeso a header change (like the CSP) propagates on the next load. - Outbound network from a module is denied by
connect-srcunless its manifest declares http(s)dependencies.servicesand holds the approvednetwork.fetchpermission — then only those origins are allowed (Phase 8).