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
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
188 lines
12 KiB
Markdown
188 lines
12 KiB
Markdown
# 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`
|
|
|
|
```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.
|