|
All checks were successful
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 2m57s
CI / Import/export fidelity gate (push) Successful in 46s
CD / Build and push images (push) Successful in 3m16s
CD / Deploy to Test (push) Successful in 8s
CI / Auth e2e pack (push) Successful in 4m8s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Successful in 10s
Implements the security core of the plugin system: code-plugin surfaces run in opaque-origin iframes (sandbox="allow-scripts", never allow-same-origin) with a capability-filtered RPC bridge. - api: serve a per-plugin sandbox frame document at /plugins/:id/:version/frame with a CSP that pins every load to the plugin's own asset path (built from APP_BASE_URL, not the request Host, so a Host-rewriting proxy cannot break it) and forbids network access (connect-src 'none'). Plugin assets get Access-Control-Allow-Origin: * so the null-origin frame can load its own module bundle. - web: sandbox-host creates the frame, wires the SDK host bridge over a source-filtered postMessage transport, drives render under a 5 s deadline (hung/failed plugin -> placeholder, never a frozen page), and tears down on unmount. PluginFrame/PluginPreviewPage surface it; the built-in ui.resize handler clamps plugin-requested heights. - plugin-sdk: host bridge reports gate violations via onViolation and registers a gated handler for every v1 method, so an undeclared capability is rejected with capability_not_permitted (not unknown_method). - tests: SDK gate unit test; web sandbox unit tests (opaque origin, source filtering, timeout); and the e2e security pack with a permanent malicious fixture plugin proving no escape (DOM/cookies/storage/fetch/ undeclared capability all blocked) plus well-behaved and hung cases. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1 |
||
|---|---|---|
| .. | ||
| fixtures/manifests | ||
| src | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vitest.config.ts | ||
@dorfteich/plugin-sdk
The contract between the Dorfteich host app and a plugin bundle: the
manifest schema, the capability names, and the typed postMessage RPC
protocol. It is the one package a plugin author needs — it builds standalone
and pulls in only zod.
Read ADR 0008 and
plugin-architecture.md first;
the manifest example there is normative and mirrored by the fixtures under
fixtures/manifests/.
What's in here
| Module | Purpose |
|---|---|
manifest.ts |
Zod schema for manifest.json + validateManifest / parseManifest. |
api-version.ts |
checkApiVersion — is a plugin's apiVersion within the host's supported range? |
capabilities.ts |
Capability names and the method → capability map that gates RPC calls. |
rpc.ts |
Transport-agnostic RPC engine (createRpcEndpoint) + a windowTransport adapter. |
host.ts |
createHostBridge — host end: routes plugin calls through the permission gate. |
plugin.ts |
createPlugin — plugin end: answers lifecycle calls, exposes a typed host proxy. |
Manifest
import { validateManifest } from '@dorfteich/plugin-sdk';
const result = validateManifest(JSON.parse(raw));
if (!result.success) {
// result.issues: [{ path: 'extensionPoints.0.type', message: '…' }, …]
}
validateManifest never throws — it returns a flat list of { path, message }
issues so the install path (#71) can show a Site Admin every problem at once.
parseManifest is the throwing variant. Cross-field rules enforced beyond the
field shapes:
- extension point types must match the plugin
kind(section_style→sectionStyleonly;code→block/pageTool); - extension point
ids are unique within the manifest; section_styleplugins run no JavaScript and must not declarepermissions.
RPC protocol
Every code-plugin surface runs in a sandboxed <iframe> with an opaque
origin (ADR 0008): no cookies, no host DOM, no storage, no network. Host and
plugin talk only through postMessage with structured-clone payloads.
Message shapes
All messages carry protocol: "dorfteich.plugin.rpc/1"; anything else on the
channel is ignored.
// request (either direction)
{ "protocol": "dorfteich.plugin.rpc/1", "type": "request",
"id": "rpc-…", "method": "getContent", "params": { } }
// success response
{ "protocol": "dorfteich.plugin.rpc/1", "type": "response",
"id": "rpc-…", "ok": true, "result": "# Hello" }
// error response
{ "protocol": "dorfteich.plugin.rpc/1", "type": "response",
"id": "rpc-…", "ok": false,
"error": { "code": "capability_not_permitted", "message": "…" } }
Both directions are symmetric — the same engine answers incoming requests and
correlates outgoing ones by id:
- plugin → host: capability calls (
getContent,listPages, …). The host gate resolves each method to its capability, rejects it withcapability_not_permittedunless the manifest declared that capability, then executes it against the REST API with the viewing user's session — so a plugin can never read more than the person looking at it could. - host → plugin: lifecycle calls (
render,edit,destroy).
Error codes: unknown_method, capability_not_permitted, handler_error,
timeout, endpoint_disposed. Every outgoing request has a timeout (default
10 s), so a hung plugin never blocks the app.
Sequence
sequenceDiagram
participant H as Host (parent window)
participant P as Plugin (sandboxed iframe)
Note over H,P: mount
H->>P: request render { extensionPointId, locale, data }
activate P
P->>H: request getContent
activate H
H-->>P: response ok "# Hello"
deactivate H
P-->>H: response ok (rendered)
deactivate P
Note over H,P: undeclared capability
P->>H: request listPages
H-->>P: response error capability_not_permitted
Note over H,P: hung call
P->>H: request getPageContent
Note right of P: no response within timeout
P--xP: reject timeout
Wiring the transport
The engine is transport-agnostic; hand it a post/listen pair. In the host
app the sandbox runtime (#73) builds it from the iframe boundary:
import { createHostBridge, windowTransport } from '@dorfteich/plugin-sdk';
const bridge = createHostBridge({
manifest,
capabilities: { getContent: () => currentPageMarkdown() /* … */ },
transport: windowTransport({ target: iframe.contentWindow!, source: window, targetOrigin: '*' }),
});
await bridge.invoke('render', { extensionPointId, locale, data });
Inside the plugin bundle:
import { createPlugin, windowTransport } from '@dorfteich/plugin-sdk';
const { host } = createPlugin({
transport: windowTransport({ target: window.parent, source: window, targetOrigin: '*' }),
onRender: async ({ locale }) => {
const md = await host.readCurrentPage.getContent();
// …render into document.body…
},
});
targetOrigin: '*' is intentional for sandbox frames: an allow-scripts
iframe without allow-same-origin has an opaque origin there is nothing to
pin, and the CSP already blocks it from reaching anywhere else.
Scripts
pnpm build— bundle ESM + CJS + types via tsup.pnpm test— Vitest (jsdom); the RPC suite drives a realMessageChannel.pnpm typecheck—tsc --noEmit.