Documentation
¶
Overview ¶
Package pluginhost is the reusable, plugin-agnostic host glue for GoFastr heavy-JS plugins that run inside an opaque-origin sandboxed iframe.
It distils the platform machinery out of the wysiwyg plugin (the first such plugin) so a second heavy-JS plugin can reuse it instead of reimplementing the iframe / broker / manifest / capability / framing-header plumbing. See ../docs/design/protocol-v1.md for the authoritative protocol contract this package implements.
What lives here (the platform's job):
- Manifest / ClientModule: the declarative description of a plugin's client module (entry document, sandbox policy, capabilities, schema).
- AssetServer: serves a plugin's embedded client assets with the correct Content-Types AND the framing/CORP/CSP header relaxation GoFastr's global security middleware otherwise blocks (the client-side isolation contract).
- Allow: the capability gate reusing battery/auth's resource:verb scopes.
- MountMarker: the generic mount-marker + hidden-field HTML the generic broker scans for.
- CheckHostRequirements: boot-time warning when a plugin's declared host-page permission needs (Manifest.HostRequirements) are denied by the app's Permissions-Policy. Logs, never fails.
- BrokerScriptURL / RegisterBrokerRoute / UIHostOption: serving and injection of the generic host broker (host/pluginhost.js).
A plugin (wysiwyg, and future plugins from Worker G) composes these pieces in its Init and ships a thin JS adapter that registers its plugin-specific event handlers with the generic broker (see BrokerRegistration).
Index ¶
- Constants
- func Allow(ctx context.Context, granted []string, required string) bool
- func CheckHostRequirements(log *slog.Logger, permissionsPolicy string, modules ...ClientModule)
- func FrameClientJS() []byte
- func Guard(granted []string, required string, next http.Handler) http.Handler
- func MountMarker(cfg MountConfig) render.HTML
- func RegisterBrokerRoute(rt *router.Router)
- func RegisterFrameClientRoute(rt *router.Router)
- func UIHostOption() uihost.Option
- func WriteCapabilityDenied(w http.ResponseWriter, capability string)
- type AssetServer
- type AssetSpec
- type Attribute
- type BrokerRegistration
- type ClientModule
- type Field
- type Manifest
- type MountConfig
Constants ¶
const ( IsolationSandboxOpaque = "sandbox-iframe-opaque" // DefaultSandbox is the v1 sandbox policy: scripts only. The same-origin // token is NEVER added, that would de-opaque the frame and collapse the // isolation guarantee. DefaultSandbox = "allow-scripts" )
Isolation constants. v1 has a single fixpoint: the plugin client runs in an opaque-origin sandboxed iframe (sandbox="allow-scripts" WITHOUT allow-same-origin), served same-origin. See protocol-v1.md §1.
const ( DefaultDocID = "demo" DefaultMinHeight = "240px" )
Default mount-marker attribute values; plugins override via MountConfig.
const BrokerRouteMethod = "GET"
BrokerRouteMethod is the router method the broker route is registered under.
const BrokerScriptURL = "/__gofastr/plugin/host/pluginhost.js"
BrokerScriptURL is the platform route serving the generic host broker (host/pluginhost.js). It is shared by every heavy-JS plugin, the same script is injected once per host page and dispatches to per-plugin adapters.
const FrameClientRouteMethod = "GET"
FrameClientRouteMethod is the router method the frame client route is registered under.
const FrameClientScriptURL = "/__gofastr/plugin/frame/frameclient.js"
FrameClientScriptURL is the platform route serving the frame-side channel client (frame/frameclient.js), the mirror of the host broker. Plugin frame documents hot-link it to get the same envelope, source check, and request/response semantics as the host side without hand-rolling a postMessage RPC per plugin.
const HostRequirementPrefix = "permissions-policy:"
HostRequirementPrefix is the grammar of a Manifest.HostRequirements token: "permissions-policy:<feature>", e.g. "permissions-policy:camera".
The prefix names WHERE the requirement lives — the app's Permissions-Policy response header — so the vocabulary can grow to other host-page surfaces later without repurposing bare words (a bare "camera" token would read as a frame capability, the opposite of what it is).
Variables ¶
This section is empty.
Functions ¶
func Allow ¶
Allow reports whether a plugin action requiring the `required` capability is permitted. It is the intersection of two sides and DEFAULT-DENIES:
- Module grant: `granted` is the capability set THIS plugin mount was granted by the host. It is the ceiling: a plugin can never exceed its declared grants, even under a session cookie. A nil/empty `granted` denies everything. Matching uses auth.ScopeMatch (the same resource:verb wildcard grammar as token scopes. NOT a weaker parallel matcher), so a granted "*:*" is the explicit "grant everything" (dev/trusted) form.
- Caller authority: the request's own scopes must also permit it. A scoped API token restricts BELOW the plugin grant; a session/JWT caller is unscoped, so the plugin grant is the binding limit.
This inverts the old behavior, where an unscoped session caller passed every capability (auth.HasScope returns true with no token): the plugin grant set now confines the untrusted plugin regardless of how the user authenticated.
func CheckHostRequirements ¶ added in v0.74.0
func CheckHostRequirements(log *slog.Logger, permissionsPolicy string, modules ...ClientModule)
CheckHostRequirements is the boot-time check for Manifest.HostRequirements. Pass the Permissions-Policy the app configures (the PermissionsPolicy field of core/middleware.SecurityHeadersConfig); an empty value is treated as the framework default, which denies camera, microphone and geolocation. There is no central ClientModule registry to hang this on — an app that mounts plugins calls it once at startup with its modules:
pluginhost.CheckHostRequirements(slog.Default(), secCfg.PermissionsPolicy, scanner, charts)
It LOGS and never fails: a plugin declaring a requirement the host has not satisfied is a developer-facing warning, and a plugin must not be able to take an app down by declaring something. It never returns an error and never panics for any input; tokens that do not parse (a module built as a struct literal, bypassing NewClientModule) are skipped silently — Manifest.Validate is the loud gate for those.
The "not satisfied" rule is deliberately narrow: warn only when EVERY directive naming the feature carries the empty allowlist "()" — the one Permissions-Policy shape that unambiguously denies the feature to every context, including the host page itself, and the shape GoFastr's default header uses. Anything else stays silent: "(self)" and "*" grant the page, a directive that does not name the feature leaves it at its default allowlist, and an origin list "(https://a.example)" cannot be decided at boot at all (it depends on the app's own origin, which no request has supplied yet). A warning that fired on grants would train developers to ignore the check; missing an exotic denial costs one console error, which is the status quo this check improves on.
func FrameClientJS ¶ added in v0.74.0
func FrameClientJS() []byte
FrameClientJS returns a copy of the embedded frame client, for plugins that bundle the script into their own frame document instead of hot-linking FrameClientScriptURL.
func Guard ¶
Guard is the enforcement chokepoint a plugin's privileged RPC/route mounts so a forgotten check fails CLOSED. It runs next only when Allow permits `required` for the `granted` set; otherwise it writes 403 with the E_CAPABILITY_DENIED code and does not call next.
func MountMarker ¶
func MountMarker(cfg MountConfig) render.HTML
MountMarker renders the generic mount marker div plus any hidden inputs. The generic host broker scans for `[data-fui-plugin]` and, for each marker, looks up the registered adapter by the plugin name to build the sandboxed iframe.
All interpolated values are HTML-escaped via render.Escape. The marker is intentionally a plain div (the broker creates the iframe inside it) so it drops cleanly into any form.
func RegisterBrokerRoute ¶
RegisterBrokerRoute serves the generic host broker at BrokerScriptURL on the given router. It is IDEMPOTENT: multiple plugins may call it from their Init and only the first registration lands (the router would otherwise panic on a duplicate pattern). Every plugin that mounts a sandboxed client should call this in Init so the host page can load the broker regardless of plugin load order.
func RegisterFrameClientRoute ¶ added in v0.74.0
RegisterFrameClientRoute serves the frame client at FrameClientScriptURL on the given router. Like RegisterBrokerRoute it is IDEMPOTENT: multiple plugins may call it from their Init and only the first registration lands.
Unlike the broker (a HOST-page script served same-origin, framed=false), this script is fetched BY opaque-origin frame documents: the global security middleware's Cross-Origin-Resource-Policy: same-origin would refuse the "null"-origin frame's <script src>, so it is served with framed=true to apply exactly that CORP relaxation. The framedCSP header that writeAsset also emits is inert on a script response (CSP governs documents, not script bytes) — harmless there; the CORP relaxation is the point. The platform's own route serves no per-plugin manifest, so it passes no CSP extensions: the default framed policy.
func UIHostOption ¶
UIHostOption returns the uihost.Option that injects the generic host broker into every UIHost-rendered page. Apps using a UIHost pass this to uihost.New. A plugin that ships its own adapter should compose this with its adapter script (adapter LAST, so the generic broker has defined its registry first):
uihost.WithExtraScripts(pluginhost.BrokerScriptURL, myPlugin.BrokerScriptURL)
func WriteCapabilityDenied ¶
func WriteCapabilityDenied(w http.ResponseWriter, capability string)
WriteCapabilityDenied writes the canonical 403 capability-denial response (JSON body carrying the E_CAPABILITY_DENIED code and the offending capability) so every plugin route denies uniformly.
Types ¶
type AssetServer ¶
type AssetServer struct {
// contains filtered or unexported fields
}
AssetServer serves a plugin's embedded client assets with the correct Content-Types and the platform framing/CORP/CSP policy on framed assets. It is the client-side isolation contract, factored out of the wysiwyg plugin so every heavy-JS plugin reuses it instead of hand-rolling the header relaxation.
func NewAssetServer ¶
func NewAssetServer(fsys fs.FS, prefix string, specs []AssetSpec) *AssetServer
NewAssetServer builds an AssetServer that reads the named specs lazily from fsys (an embed.FS sub or any fs.FS) and serves them under prefix. Files missing from fsys at request time yield a 404; for go:embed'd bundles that never happens. Call AssetServer.AddBytes for host-page scripts that live in a different embed root, then AssetServer.Register.
func (*AssetServer) AddBytes ¶
func (s *AssetServer) AddBytes(path, contentType string, framed bool, b []byte)
AddBytes registers an asset from pre-loaded bytes at an explicit full route path. Use it for host-page scripts (the broker adapter) that are not part of the framed FS. framed should be false for host scripts.
func (*AssetServer) Register ¶
func (s *AssetServer) Register(rt *router.Router)
Register mounts every asset on the router. It is safe to register multiple AssetServers on the same router as long as their paths do not collide (the router panics on duplicate patterns otherwise).
func (*AssetServer) WithCSP ¶ added in v0.74.0
func (s *AssetServer) WithCSP(tokens []string) *AssetServer
WithCSP sets the per-plugin CSP keyword extensions (from Manifest.CSP) appended to framed assets' script-src, returning s for chaining:
srv := pluginhost.NewAssetServer(fsys, prefix, specs).WithCSP(mod.Manifest.CSP)
Tokens are re-filtered through [allowedCSPKeywords] when the header is assembled, so a slice that skipped Manifest.Validate cannot smuggle a keyword. nil (the default, i.e. no call) produces the same header as a plugin without the tier.
type AssetSpec ¶
type AssetSpec struct {
// Name is the filename within the AssetServer's fs.FS (e.g. "editor.html").
// The route is registered at prefix + "/" + Name.
Name string
// ContentType is the exact Content-Type header (e.g.
// "text/html; charset=utf-8").
ContentType string
// Framed marks the assets that make up the sandboxed plugin frame (the
// frame document and its sub-resources). Framed assets get the
// framing/CORP/CSP relaxation GoFastr's global security middleware
// otherwise blocks (DECISIONS.md "Phase 0 — DONE" gotcha #1); non-framed
// host-page scripts (the broker / adapter) are served plain.
Framed bool
}
AssetSpec describes one asset served from a filesystem by AssetServer.
type Attribute ¶
Attribute is a single HTML attribute on the mount marker. Plugins use it to add their own data-* attributes (e.g. wysiwyg's data-fui-plugin-for listing the hidden field names to sync).
type BrokerRegistration ¶
type BrokerRegistration struct {
Manifest Manifest `json:"manifest"`
Config any `json:"config,omitempty"`
OnEvent func(method string, params any, api any) `json:"-"`
}
BrokerRegistration documents the JavaScript shape a plugin adapter passes to window.__gofastrPluginHost.register(name, registration). It is defined here for reference (Worker G, IDE hover, and the platform contract); the generic broker consumes it on the client side, not Go.
window.__gofastrPluginHost.register("wysiwyg", {
manifest: { // → [Manifest], serialised to JS
entry: "/…/editor.html",
isolation: "sandbox-iframe-opaque",
sandbox: ["allow-scripts"],
capabilities: ["document:read", …],
minHeight: "240px",
schema: "wysiwyg-v1",
title: "WYSIWYG editor"
},
config: { … }, // plugin blob bridged in init.config
onEvent: function (method, params, api) {
// api = { request, sendEvent, iframe, marker, form }
// handle plugin-specific events: docChanged, save, requestUpload, …
// and mirror the generic hooks if the e2e depends on plugin-named ones.
},
onRequest: function (method, params, api) {
// static fallback for frame → host requests with no explicit
// api.onRequest handler; return a value or a Promise (a throw
// becomes an E_HANDLER response). Neither exists → E_NO_HANDLER.
}
});
The generic broker itself handles the protocol-level events (ready, resize, focusChanged, themeApplied, metric, bootError), runs the envelope + source check + ready→init handshake, and stashes the generic hooks (iframe.__pluginReady / __pluginProbes / __pluginTheme / __pluginLastMetric). It calls registration.onEvent for EVERY inbound event after its own handling, so an adapter can both handle its own methods and mirror the generic hooks under plugin-specific names the tests read. Inbound frame → host requests dispatch to a per-method api.onRequest handler first, with registration.onRequest as the fallback, and are ALWAYS answered.
type ClientModule ¶
type ClientModule struct {
// Name is the plugin name, also the data-fui-plugin attribute value the
// mount marker carries and the generic broker dispatches on.
Name string
// Manifest describes the client module.
Manifest Manifest
// Assets is the (sub)filesystem holding the framed client assets
// (editor.html / editor.js / editor.css). May be nil if the plugin serves
// its assets itself.
Assets fs.FS
}
ClientModule bundles a plugin name with its Manifest and the embedded asset filesystem the AssetServer serves. It is the unit a plugin registers with the platform. Worker G builds one of these per plugin.
func NewClientModule ¶
NewClientModule is the validating constructor for a ClientModule: it runs Manifest.Validate so a mis-configured plugin fails loudly at registration instead of silently mounting a bad frame. Plugins should build their module through this rather than a struct literal.
type Field ¶
Field is a hidden input emitted after the mount marker. The generic broker creates the iframe inside the marker; plugins use the hidden inputs so a normal form POST / data-fui-rpc submit round-trips the canonical doc and its markdown sibling (protocol-v1.md §9).
type Manifest ¶
type Manifest struct {
// Entry is the frame document URL the broker loads into the iframe, e.g.
// "/__gofastr/plugin/wysiwyg/editor.html". Required.
Entry string `json:"entry"`
// ScriptHash is an optional bundle hash used for cache-busting / SRI. v1
// does not enforce it; the broker appends its own cache-buster.
ScriptHash string `json:"scriptHash,omitempty"`
// Isolation is the isolation model identifier. The v1 fixpoint is
// [IsolationSandboxOpaque]. Empty defaults to it; any other value is
// rejected by [Manifest.Validate].
Isolation string `json:"isolation"`
// Sandbox is the iframe sandbox token list. MUST contain "allow-scripts" and
// MUST NOT contain "allow-same-origin" (enforced by [Manifest.Validate]).
// Defaults to ["allow-scripts"] when empty.
Sandbox []string `json:"sandbox"`
// CSP lists opt-in Content-Security-Policy keywords appended to the framed
// policy's script-src. Declaring it alone changes nothing: the host that
// builds the plugin's [AssetServer] must pass it through
// [AssetServer.WithCSP], or the manifest validates, the frame still
// refuses WebAssembly, and nothing reports why. The allowlist
// is closed and has exactly one member: 'wasm-unsafe-eval', which lets a
// plugin compile WebAssembly inside the sandboxed frame without granting
// string eval ('unsafe-eval' stays forbidden) and without touching any
// other directive — the frame keeps its opaque origin, sandbox
// allow-scripts, and connect-src 'none', so a wasm engine still exchanges
// data only over the postMessage bridge. Anything outside the allowlist
// (a host source, 'unsafe-inline', 'unsafe-eval', '*', or a token
// carrying ';', whitespace, or mismatched quotes — these values are
// interpolated into a response header, where ';' could splice an
// arbitrary directive such as re-enabled connect-src) is rejected by
// [Manifest.Validate] and dropped at header assembly. Matching is EXACT,
// byte-for-byte: unlike the HTML sandbox attribute the CSP header neither
// case-folds nor whitespace-tokenises source expressions, so a variant
// like 'WASM-UNSAFE-EVAL' grants nothing — and exact match rejects every
// smuggle shape with the one comparison.
CSP []string `json:"csp,omitempty"`
// Capabilities is the default resource:verb grant set advertised to the
// client in init.capabilities when the mount marker does not override it.
Capabilities []string `json:"capabilities,omitempty"`
// HostRequirements names browser features the HOST PAGE around the plugin
// must be allowed to use, as "permissions-policy:<feature>" tokens (e.g.
// "permissions-policy:camera" for a scanner whose host page captures and
// whose sandboxed frame decodes). The frame itself is opaque-origin and
// can never hold these permissions; this declares what the page embedding
// it needs, so [CheckHostRequirements] can turn an unsatisfied token into
// a boot-time warning instead of the runtime console error a user would
// otherwise hit first. Validated against a closed feature registry.
HostRequirements []string `json:"hostRequirements,omitempty"`
// MinHeight is the initial iframe height before the first resize event.
// Defaults to "240px" when empty.
MinHeight string `json:"minHeight,omitempty"`
// Schema is the interchange schema version bridged in init.schemaVersion
// (e.g. "wysiwyg-v1").
Schema string `json:"schema"`
// Title is the iframe title attribute (accessibility). Defaults to
// "Plugin" when empty.
Title string `json:"title,omitempty"`
}
Manifest is the declarative description of a plugin's client module. It is generalised from the wysiwyg plugin's Phase-0 manifest (protocol-v1.md §1/§5) and doubles as the JSON blob the generic host broker reads to build the sandboxed iframe for each mount marker.
Fields are kept minimal and forward-compatible: unknown params on the wire are ignored by both sides (protocol-v1.md §3 envelope is frozen; method param payloads may grow).
func (Manifest) SandboxString ¶
SandboxString returns the iframe `sandbox` attribute value. It is AUTHORITATIVE, not advisory: it always includes "allow-scripts" and always strips "allow-same-origin" (and any other same-origin-collapsing token), regardless of what the manifest carries. A mis-configured or tampered manifest therefore cannot produce a de-opaqued frame, the isolation invariant does not depend on anyone having called Manifest.Validate.
func (Manifest) Validate ¶
Validate enforces the v1 isolation invariants, failing loudly at registration on a mis-configured manifest. It is called by NewClientModule. Note the frame's actual sandbox attribute is derived by Manifest.SandboxString / the broker's sandboxFor, both of which are authoritative (they strip allow-same-origin regardless), so Validate is a fail-fast nicety, not the sole line of defense. It does not mutate the receiver.
type MountConfig ¶
type MountConfig struct {
// Plugin is the plugin name, the data-fui-plugin attribute the generic
// broker dispatches on to find the registered adapter. Required.
Plugin string
// DocID is the persistence key (data-fui-plugin-docid). Defaults to
// [DefaultDocID].
DocID string
// MinHeight is the initial iframe height (data-fui-plugin-minheight).
// Defaults to [DefaultMinHeight].
MinHeight string
// Capabilities is an optional CSV grant override
// (data-fui-plugin-capabilities). Empty ⇒ adapter manifest default.
Capabilities string
// Doc is an optional initial document JSON server-rendered into the marker
// (data-fui-plugin-doc) for reload round-trip.
Doc string
// Fallback is server-rendered HTML placed inside the marker as the
// pre-hydration state (wrapped in a div carrying
// data-fui-plugin-fallback). The broker hides the iframe until the
// frame reports ready, then hides the fallback — hidden, never
// removed — and shows the frame; on bootError it swaps back, so a
// plugin with a Go-side renderer (the chart plugin's SSR SVG)
// degrades to its static output instead of an empty box, and works
// with JavaScript off. Teardown restores it until SPA nav replaces
// the marker.
//
// TRUST: this is render.HTML in the host page's own trust domain,
// emitted verbatim like every render.HTML — the plugin's Go-side
// Mount() builds it server-side; it never comes from the frame.
Fallback render.HTML
// Attributes are extra attributes appended to the marker (plugin-specific).
Attributes []Attribute
// Fields are hidden inputs emitted after the marker.
Fields []Field
}
MountConfig configures MountMarker. It is the generic, plugin-agnostic shape; a plugin's own Mount wraps it (see the wysiwyg plugin for the pattern).