Frame plugins
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:plugin-sandbox·plugin-permissions. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
providePlugins(...) loads trusted, in-process weavers (Angular, composed at build time). A
distribution can also load a sandboxed plugin with provideFramePlugins(...): code it does not
fully trust, or that is not Angular. That code runs in an isolated <iframe sandbox> and receives
ctx over RPC (Penpal), through the same broker a trusted weaver uses:
// src/app/app.config.ts — in the providers arrayimport { provideFramePlugins } from '@loomweaver/shell';
// in providers:provideCapabilityGrants({ 'report-tool': ['contributions', 'ui', 'navigation'] }),...provideFramePlugins({ id: 'report-tool', name: 'Report tool', // what the user reads; omit it and the id is shown entryUrl: '/report-tool/plugin.html', // the plugin's entry document, served by the distribution capabilities: ['contributions', 'ui', 'navigation'],}),name is what the workbench calls the plugin wherever it names it to the user, the permissions
surface above all. Omit it and the id is shown unchanged, which is a poor name but a correct one:
nothing prettier is derived from it. Everything else keeps following the id, so naming a plugin
changes what is read and nothing about what it holds.
What the entry document itself must contain, the Penpal handshake that receives ctx, is worked
through with a complete example in
authoring a weaver → the sandbox bootstrap.
Grants work identically, from the same default-deny map that
Capabilities and trust explains. The plugin contributes content views with the
iframe route surface. A
plain-string, data-oriented ctx slice crosses the boundary; an Angular class cannot. Everything
arriving over the wire is re-validated at the RPC seam. Only the { iframe } surface form is
accepted. The surface URL must be same-origin (distribution-served). Foreign origins,
javascript: and data: URLs are rejected. What the sandboxed ctx carries, and how a surface
paints and talks back, is Sandboxed surfaces. The isolation
guarantee is the iframe sandbox: the plugin runs in its own JS context and origin, with no access to
the host DOM, variables or storage.
Because a sandboxed surface has none of the host’s --lw-* design tokens, the host pushes the resolved
token values to the surface (alongside the active locale and light/dark theme); the surface sets them as
CSS variables and paints with var(--lw-…) just like host chrome. The push carries the full --lw-*
vocabulary (every LW_TOKENS entry), and the values are the effective ones, so
a theme switch carries into the sandbox with no hardcoded colours or fonts to keep in sync. Any
tenant or product token override carries the same way, and so does the user’s text size.
The level a frame plugin runs at
How a plugin arrives and how much the browser holds it back are two questions. A frame plugin runs at one of two levels, and the composition chooses:
isolated (the default) |
embedded |
|
|---|---|---|
| The frame | <iframe sandbox="allow-scripts">, no origin of its own |
a plain <iframe>, it keeps its origin |
| Storage, cookies, the session they carry | none | whatever the browser grants that origin |
| Can reach the hosting document | no | yes when served from the same origin; a sibling subdomain keeps it out |
| Written for | code you did not write | your own teams, deploying separately |
...provideFramePlugins({ id: 'treaties', entryUrl: '/treaties/plugin.html', capabilities: ['contributions'], level: 'embedded', // omit it and you get 'isolated' origins: ['https://treaties.example.com'], // where its own surfaces may come from}),The field’s type is PluginIsolationLevel, exported by the shell for a composition that decides the
level in code of its own.
origins names the origins this plugin’s own surfaces may be served from, beyond the application’s
own; omit it and the application’s origin is the only one, which is the right answer for a plugin
whose files you serve yourself. A sibling subdomain belongs there: it gives an embedded application
its own storage and keeps it out of the hosting document, while a session cookie scoped to the shared
domain still reaches it.
embedded is not a weaker sandbox. It is not a sandbox. An embedded application served from your
origin can reach the hosting document, its storage and your session. The level exists to separate deployments, so
several teams can ship independently into one workbench, and it is a decision about trust that the
composition makes on the operator’s behalf. Compose only code you would ship yourself, exactly as
with a trusted weaver.
A plugin never decides this for itself. A catalogue entry may ask for a level, and the wiring for
that catalogue carries the highest one it may confer: providePluginCatalog(source, { maxLevel }),
strict by default. An entry at or below the cap runs at what it asked for. One above it is refused
and reported rather than started lower, because something running below what it needs fails in ways
nobody traces back to a line of configuration.
Where to serve an embedded application from
This is a deployment decision with consequences the platform cannot take back for you, so it is worth making deliberately. The freeze row below was measured with a child frame burning CPU for 1.5 s:
| Re-hosted under one origin | Sibling subdomain | Cross-site origin | |
|---|---|---|---|
| Session from a domain-wide cookie | yes | yes, same site | no |
| Storage of its own | no | yes | yes |
| Can corrupt the hosting document | yes | no | no |
| Can seize the origin’s service worker | yes, unless you prevent it | no | no |
| Survives a frozen application | no | Chromium only | yes |
The sibling subdomain is the recommendation, and fault isolation is not the reason. It buys the
three properties that hold in every engine while leaving single sign-on free. Surviving a frozen
application needs the Origin-Agent-Cluster: ?1 response header and Chromium: Firefox honours
the header and freezes anyway, Safari does not implement it. If that matters everywhere, it costs a
cross-site origin and with it the easy session.
Two things the workbench cannot enforce for you, and which belong in your serving layer:
- Service-worker scope. A worker’s scope is limited to its script’s path unless the
Service-Worker-Allowedheader widens it. Never pass that header through for an application’s path, and no team can take over the whole origin. - Storage keys. Under one origin every team shares one store. Prefix per application, by convention and review; there is no technical separation to lean on.
If you set Origin-Agent-Cluster, set it on every response from that origin. Whether an origin
is origin-keyed is decided once per browsing context group, so a single load without the header
settles it for everything that follows.
Frame UI kit (@loomweaver/frame-kit)
Frame surfaces paint with the host’s primitives through the frame UI kit, a small
npm package of static assets: lw-elements.global.js (the whole <lw-*> element family + the built-in
icon set + the LwFrame helper API), lw-frame.css (the .lw-* class contracts compiled to plain
CSS on var(--lw-*)) and penpal.global.js (the RPC transport). The distribution serves it
same-origin under the well-known path /frame-kit/ with an assets glob:
// project.json → build.options.assets{ "glob": "**", "input": "node_modules/@loomweaver/frame-kit/dist", "output": "frame-kit" }Plugins reference those paths instead of vendoring copies, so the kit’s version always matches the
@loomweaver/shell your distribution actually runs (@loomweaver/frame-kit shares the platform’s version line;
keep the two in lockstep when you update). If you host sandboxed plugins, composed or through the
plugin store, serving the kit is part of the contract those plugins rely on.
The session is pushed the same way, but only when you grant it. A surface whose plugin holds the
session capability receives { authenticated, roles } and can gate its own UI; without the grant the
host omits the field entirely, so the grant governs the push as well as ctx. Revoking the
capability in the Permissions settings stops the push live, with no reload.
Ship a Content-Security-Policy <meta> in your index.html with at least frame-src 'self'. The
scaffold writes one that also sets default-src 'self', object-src 'none' and base-uri 'self'.
It pairs with the same-origin surface check at the RPC seam as defence in depth for sandboxed
surfaces. Angular’s component styles need style-src 'unsafe-inline'.
frame-src is yours to decide, and it is the real gate for trusted embeds. A sandboxed plugin can
never choose the origin, because the RPC seam rejects anything not same-origin. A trusted weaver,
however, may point an iframe surface at a foreign origin on purpose
(a metrics dashboard, a docs site, a video), and that is an intended capability, not a defect: the platform does not second-guess code you compiled in. What stops it is your CSP, which the
browser enforces and no plugin can talk around. So with frame-src 'self' such a surface is simply
blocked. If you want it, widen frame-src deliberately to the origins you trust, and no further.
CSP × production build:
inlineCriticalmust be off. With a strictscript-src 'self'(no'unsafe-inline'/'unsafe-hashes'), Angular’s critical-CSS inlining has to be off in the production build:optimization.styles.inlineCritical: false, in the build target’sproductionconfiguration. Otherwise Angular emits the full stylesheet as<link media="print" onload="this.media='all'">and the inlineonloadhandler is blocked by the CSP, so the stylesheet never activates and the app renders unstyled. The scaffold sets this for you; it is written down here because it is invisible inng servedev builds, which do not inline critical CSS, so a hand-wired policy meets it for the first time in production. Verify against a production build.
Where next
- Sandboxed surfaces: the bootstrap, the pushed tokens and the frame kit from the plugin’s side.
- Plugin store: the same sandboxed plugins, installed by the user at runtime instead of composed.
- Capabilities and trust: default-deny, the three rungs and where the sandbox sits among them.
- The plugin system: the four ways a plugin arrives and what the user may revoke, disable or remove.