Heavy-JS plugin platform (framework/pluginhost)
framework/pluginhost lets a GoFastr app mount a heavy-JavaScript
plugin — a megabyte-class client bundle like a WYSIWYG editor or a
diagram renderer — as a genuinely third-party, isolated module. It is
the client-side mirror of the process-isolation track (#37): the same
question ("what can code we didn't audit actually reach?") answered for
untrusted DOM-touching JavaScript.
It was not designed up front. It was distilled from the first such
plugin (the gofastr-plugins WYSIWYG editor) after the isolation model
survived a measured go/no-go gate (p99 keystroke latency ≤ 16 ms inside
the sandbox), then proven general by a second plugin (mermaid) that
reused it without modification.
The isolation model — secure by default
The plugin bundle runs inside an opaque-origin sandboxed iframe:
sandbox="allow-scripts", and allow-same-origin is never added.
Two independent, authoritative enforcement points guarantee this:
- The sandbox derivation (
Manifest.SandboxStringserver-side and
sandboxForin the broker JS) always stripsallow-same-origin
and forcesallow-scripts— a mis-configured or tampered manifest
cannot produce a de-opaqued frame.Manifest.Validate(run by
NewClientModule) additionally rejects such a manifest loudly at
construction. - The framed asset's
Content-Security-Policycarriessandbox allow-scripts, so even a top-level load of the frame document
(not just an embed) is forced into an opaque sandbox by the browser.
Consequences the browser enforces (not our code, not review):
document.cookie,localStorage,sessionStorage, the host DOM and
globals, the CSRF token, and other plugins' data are unreachable
from the frame.- The frame has no network capability of its own; its only channel to
the app ispostMessage, brokered by the host. - A crashed or malicious bundle cannot deface the page or exfiltrate a
session — including via a compromised transitive npm dependency, which
is the realistic threat: the app owner deliberately installs the
plugin, but nobody audits megabytes of dependency tree per upgrade.
Assets are served same-origin from the plugin's route prefix via
pluginhost.NewAssetServer, so the app's strict CSP needs zero edits.
Framed assets get a scoped relaxation (framing headers + a CSP keyed to
the explicit request origin — inside an opaque frame, 'self' resolves
to null and spec-correct browsers like Safari refuse subresources).
The protocol
One versioned envelope in both directions:
{v, id, type: request|response|event, src, method, params, result, error}.
- Handshake: the frame speaks first (
ready), the host answersinit
with the document, theme tokens, and the capability grant set. - host→plugin:
init,themeChanged,requestSave,uploadResult,
teardown,hostPointerdown(interaction-outside relay so in-frame
overlays can dismiss). - plugin→host:
ready,docChanged,save,requestUpload,resize,
focusChanged,metric,themeApplied,bootError. - Source validation:
event.source === iframe.contentWindow, never
event.origin— an opaque frame's origin is the literal string
"null", so origin-string checks are a trap. - Unknown methods are ignored, so additive events are non-breaking.
The host side is framework/pluginhost/host/pluginhost.js, served at
its own route (pluginhost.RegisterBrokerRoute, idempotent across
plugins). It is not part of runtime.js — pages without plugins
ship zero extra bytes and the core payload budgets are untouched.
Capabilities — reuse the scope registry, don't invent one
Grants use the same resource:verb grammar as battery/auth token
scopes (document:read, document:write, upload:images,
theme:read) and are enforced server-side with default-deny:
pluginhost.Allow(ctx, granted, required) permits an action only when
required is covered by the plugin's granted set (the ceiling — via
auth.ScopeMatch, the same wildcard matcher as token scopes) AND the
caller's own authority permits it. A plugin can therefore never exceed
its granted capabilities, even under a session cookie (where an unscoped
auth.HasScope alone would pass everything). Mount privileged plugin
routes behind pluginhost.Guard(granted, required, next), which fails
closed with 403 E_CAPABILITY_DENIED. This is the reconciliation
#37 calls for — one permission vocabulary across process-isolated modules,
API tokens, and client plugins. Do not build a parallel capability
catalog for plugins; extend the scope vocabulary.
The client half is advisory UX (the editor hides upload UI without
upload:images); the server half is the enforcement (the upload route
403s without the scope). Never trust the frame's own claim of its
grants.
Mounting
pluginhost.MountMarker emits the mount marker the broker scans for:
data-fui-plugin="<name>" plus data-fui-plugin-docid / -doc /
-minheight / -capabilities (all documented in the
core-ui/ARCHITECTURE.md attribute table and the
runtime contract). A plugin adds its own
adapter script (registered via window.__gofastrPluginHost.register)
that supplies its Manifest and handles its plugin-specific events —
the generic broker owns everything protocol-level.
Opting out — the trusted mount
Isolation is the default; a loud, host-side opt-out exists for
plugins the app owner compiles in and vouches for (code the team wrote
itself, where the geometry/theming costs of the frame aren't worth
paying).
The wysiwyg plugin's WithTrustedMount() is the reference: same plugin
API and protocol envelopes, transport swapped from postMessage to
direct calls, no iframe. The opt-out is never a default and never
selectable by the plugin itself — only the host can grant it.
The registry
Discovery is a convention, not a service: the gofastr-plugins
repo carries a curated plugins.json (module path, version,
frameworkCompat, isolation, sandbox, capabilities, entry route,
schema version per plugin). An app imports a plugin package directly
and mounts it with app.RegisterPlugin(...); the registry file is the
human/tooling index, updated in the same change as a plugin's version
or capability set.
Common mistakes
- Adding
allow-same-originto "fix" a frame that can't load its
assets. That de-opaques the frame and deletes the entire isolation
guarantee. The real fix is the framed-asset CSP relaxation the
AssetServeralready applies —'self'meansnullinside an
opaque frame, so framed responses carry an origin-keyed CSP instead. - Checking
event.originin the broker or the frame. The opaque
frame's origin is the string"null"; string checks either always
fail or get written asorigin === "null", which any sandboxed frame
on any site satisfies. Compareevent.sourceidentity instead. - Treating the client capability list as enforcement. It is UX.
Enforcement ispluginhost.Guard/pluginhost.Allow(default-deny,
grant-set ∩ caller-authority) on the plugin's server routes; a hostile
frame can claim any grants it likes. - Putting the broker into
runtime.js. It belongs on its own
route: plugin pages are rare, the core payload budget (12.5 KB gz) is
load-bearing, andRegisterBrokerRouteis already idempotent. - Inventing plugin-only permission names. Use the
resource:verb
scope grammar so token scoping, wildcards, and admin tooling keep
working; a parallel vocabulary drifts immediately. - Letting a plugin choose its own trust tier.
isolationin the
manifest describes the sandboxed default; the trusted in-page mount
is granted only by host-side code the app owner writes.