dorfteich/docs/architecture/plugin-architecture.md
Claude Fable 5 9b7acab294
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
#232: plugin allowlist with SHA-256 hash pinning
The install path records the SHA-256 of the delivered bundle ZIP
(plugins.bundle_hash; pre-#232 installs show it as unknown until
reinstalled). plugins.allowlist in instance_settings names permitted
ids with their pinned hashes: empty (default) = not enforced, existing
instances unchanged; non-empty = installs of unlisted or deviating
bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403),
and an installed plugin outside the list or with a deviating hash does
not load — absent from pond mount lists, frame/assets 404. Every
rejection is audited (plugin.rejected, catalogue v1.5). A version bump
changes the hash and therefore requires an explicit re-pin — the
intended friction (ADR 0025). Admin UI shows observed vs pinned hash
per plugin with pin/re-pin/unpin. Scope stated honestly in
plugin-architecture.md: the pin answers "is this the reviewed bundle";
post-install disk tampering is platform integrity (ADR 0019), sandbox
containment stays the sandbox's job. Hardening guide row + catalog
advisory triage; residual risk R-03 resolved. e2e: empty-allowlist
compatibility, pinned load, unpinned and tampered installs rejected and
audited, pin drift blocks loading while the admin still sees the
mismatch, version bump needs re-pin. Full api suite 101 files / 561
green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 21:26:36 +02:00

12 KiB

Plugin architecture

Extends ADR 0008 with the concrete contracts implementers need.

Package format

A plugin is a ZIP archive:

my-plugin.zip
├── manifest.json      (required)
├── plugin.js          (required for kind=code; single ES module bundle)
├── styles.css         (optional; required for kind=section_style)
├── i18n/de.json       (optional UI strings)
├── i18n/en.json
└── assets/…           (optional images etc.)

manifest.json

{
  "id": "toc",
  "name": "Table of Contents",
  "version": "1.2.0",
  "apiVersion": "1",
  "kind": "code",
  "extensionPoints": [
    {
      "type": "pageTool",
      "id": "toc",
      "title": { "de": "Inhaltsverzeichnis", "en": "Table of contents" }
    }
  ],
  "permissions": ["readCurrentPage"],
  "fallback": { "type": "text", "value": "[Table of contents]" },
  "license": "MIT",
  "homepage": "https://…"
}
  • apiVersion: host checks against its supported range at install time.
  • permissions: the capabilities the plugin may call (see API below); shown to the Site Admin at install time. Requests outside the declared set are rejected at runtime.
  • fallback: static representation used in Word/PDF exports and when the plugin is disabled but its blocks still exist in documents.

Kinds and extension points

Kind Extension point What it does Sandbox
section_style sectionStyle declares named styles (name, i18n label, CSS class body) applicable to container blocks — e.g. colored background boxes none needed: CSS is sanitized (no @import, no url() to external hosts) and scoped under .dt-style-<pluginId>-<styleId>
code block a custom editor block (diagram, embed, …); host registers a ProseMirror node plugin_block instance with pluginId, blockType, data attrs sandboxed iframe per block
code pageTool read-only widget rendered in the page tools panel or embedded as a block (TOC, page index, cross-page block embed) sandboxed iframe

styles.css is served into the host page, so the install gate (#75) enforces its scoping instead of rewriting it: every rule must be written under one of the plugin's own .dt-style-<pluginId>-<styleId> classes (each <styleId> a declared sectionStyle extension point; grouping at-rules like @media are checked inside, only @font-face/@keyframes are exempt). Positioning out of the content flow (position other than static/relative) is rejected — a fixed overlay could shadow the whole app. A rule that violates the contract fails the install with plugin_css_unsafe.

Sandbox runtime

  • Each code-plugin surface runs in <iframe sandbox="allow-scripts"> without allow-same-origin → opaque origin: no cookies, storage, or parent DOM. The iframe document is generated by the host and loads only the plugin bundle + its assets from the plugin's static path.
  • CSP on plugin frames: default-src 'none'; script-src <plugin path>; img-src <plugin path> blob: data:; style-src <plugin path> 'unsafe-inline'; connect-src <plugin path>; frame-src <plugin path>. Network and child frames are pinned to the plugin's OWN version-pinned asset path — bundled sub-apps (the drawio editor) may lazy-load their resources and run in a child iframe of the plugin's assets, but nothing can reach the api or any external host. HTML assets are served with the same CSP, so a packaged page cannot widen the rules; child frames also inherit the sandbox attribute (opaque origin, no storage).
  • Host ↔ plugin communication: postMessage RPC with structured-clone payloads. packages/plugin-sdk provides both sides:
    • plugin side: createPlugin({ onRender, onEdit, … }), typed host.* calls;
    • host side: frame lifecycle, request routing, permission filtering, timeouts (a hung plugin never blocks the app).

Plugin API (v1 capabilities)

All calls are mediated by the host and executed against the REST API with the viewing user's session — a plugin can never read more than the person looking at it could.

Capability Methods
readCurrentPage getOutline(), getContent() (Markdown), getMeta()
readPond listPages(), getPageOutline(pageId), getPageContent(pageId)
readBlock getBlock(pageId, blockId) — cross-page block embedding
blockData getData() / setData(data) for the plugin's own block instance (writes go through the editor as a normal document change — requires the viewer to have write permission)
ui resize(height), openPage(pageId) (host navigates), toast(msgKey), scrollToHeading(headingId) (host scrolls to an outline entry, #77), enterFullscreen()/exitFullscreen() (the frame becomes a viewport-covering overlay — drawio-class editors; destroy always restores)

Lifecycle & administration

  1. Install (Site Admin): upload ZIP in the admin UI or drop it into the plugins/ volume directory (a watcher picks it up). The API validates: ZIP structure, manifest schema, apiVersion, CSS sanitation, bundle size limit. Invalid packages are rejected with a precise error.
  2. Instance mode (Site Admin): disabled | optional | required (vision: Site Admin activates plugins optionally or mandatorily).
  3. Pond activation (Pond Admin): toggle optional plugins per pond.
  4. Update: uploading the same id with a higher version replaces the package after the same validation; open clients use the new version on next load.
  5. Uninstall: blocked while required; otherwise the package is removed, existing plugin_block nodes render the manifest fallback (documents are never mutated by plugin removal).

Instance kill switch (issue #200, ADR 0025): plugins.enabled (instance setting, default on; the VS-NfD reference configuration turns it off) sits above the whole lifecycle. While off, every plugin surface answers 404 — admin install/list/mode, pond activation, the sandbox frame and asset routes — and the dropzone watcher quarantines instead of installing. Only the authenticated fallback-metadata route stays alive: it serves no plugin code, and existing plugin_block nodes use it to render their declared fallback (an image fallback degrades to the neutral placeholder, because its bytes live on the disabled asset surface — in the reference configuration no plugin is installed, so nothing degrades). The editor offers no plugin blocks because the pond plugin list is one of the 404ing surfaces. Like every instance setting it is cached in-process: flipping it is followed by an api restart to take full effect. This single, verifiable off-switch is what answers "code execution inside the zone?" at the offer stage — cheaper than per-plugin trust machinery (#232) and sufficient because it removes the surface entirely.

Trust: hash-pinning allowlist (issue #232, ADR 0025)

Real code signing is unavailable without a legal entity to hold a signing identity, so bundle trust is hash pinning:

  • At install the api records the SHA-256 of the delivered bundle ZIP on the plugin row (plugins.bundle_hash; plugins installed before #232 show it as unknown until reinstalled).
  • plugins.allowlist in instance_settings names permitted plugin ids with their pinned hashes. Empty (the default) = pinning is not enforced — plugins load as before. Non-empty = enforcement for every plugin: installs of unlisted ids or deviating bundles are rejected (plugin_not_pinned / plugin_hash_mismatch), and an installed plugin outside the list or with a deviating hash does not load — it disappears from the pond mount lists and its frame/asset routes answer 404. Every rejection is audited (plugin.rejected, catalogue v1.5).
  • A version bump changes the bundle, hence the hash, hence requires an explicit re-pin in the admin UI — deliberate friction (ADR 0025).
  • Scope, stated honestly: the pin answers "is this the reviewed bundle". The observed hash is recorded at install; tampering with the unpacked files on disk afterwards is platform integrity (ADR 0019), and what the loaded code may do remains the sandbox's job — neither substitutes for the other.
  • The VS-NfD reference configuration keeps the allowlist empty because it turns plugins off entirely (plugins.enabled=false, #200); the allowlist is for deployments that deviate and run plugins.

Reference plugins (shipped with the product, also serving as examples)

  • section-styles-basic (section_style): a set of colored callout/box styles — proves the declarative path.
  • toc (pageTool): table of contents from the page outline.
  • page-index (pageTool): filtered page list by label.
  • mermaid (block): diagram block rendering Mermaid source — proves the code-block path end to end (editing UI inside the sandbox).
  • drawio (block): draw.io diagrams — proves the bundled-app path: the official draw.io editor ships as plugin assets (pinned release fetched at build time into vendor/, gitignored) and runs fullscreen in the sandbox; blocks store { xml, svg }, render mode and exports use the SVG snapshot.
  • chordpro (block): ChordPro leadsheets — a dependency-free code block: its own minimal parser renders chords above lyrics into a static SVG; blocks store { source, svg }, render mode and exports use the SVG snapshot.
  • excalidraw (block): hand-drawn sketches — proves the npm-library flavor of the bundled-app path: the Excalidraw React editor is bundled straight into plugin.js (esbuild) with its font/locale assets shipped alongside and loaded via EXCALIDRAW_ASSET_PATH; blocks store { scene, svg }, render mode and exports use the SVG snapshot (with subsetted fonts embedded as data: URIs).

These live in packages/plugins/ in the monorepo, are built by CI, and double as the plugin-SDK integration tests.