dorfteich/packages/plugin-sdk
Claude Opus 4.8 621aa47244
Some checks failed
CI / Auth e2e pack (push) Waiting to run
CI / Import/export fidelity gate (push) Waiting to run
CI / Build container images (push) Waiting to run
CD / Build and push images (push) Failing after 1m33s
CD / Deploy to Test (push) Has been skipped
CD / Smoke tests against Test (push) Has been skipped
CD / Promote to Int (push) Has been skipped
CI / Lint, typecheck, test (push) Has been cancelled
Add plugin storage, install API, and directory watcher (#71)
Backend for installing plugin ZIPs (ADR 0008, plugin-architecture.md
§Lifecycle, security.md §Plugins). Consumes the #70 SDK for validation.

- Schema: `plugins` (id, name, version, apiVersion, kind, mode, manifest
  jsonb, removedAt soft-delete) + `pond_plugins` (per-pond activation) +
  `PluginInstanceMode` enum; migration 20260710130000_plugins.
- `PluginPackageService`: pure, stateless ZIP → validated package via
  fflate — structure check, manifest validation (SDK), apiVersion gate,
  kind/bundle/styles rules, CSS sanitation (no @import / external url() /
  expression()), zip-slip and unpacked-size guards. Each failure carries a
  stable PluginErrorCode; manifest issues travel as ApiError details.
- `PluginStorageService`: on-disk layout `<PLUGINS_DIR>/<id>/<version>/`;
  atomic writeVersion (staging dir + rename, no 404 window mid-update),
  removeVersion/removePlugin, traversal-safe asset resolution, dropzone +
  quarantine dirs.
- `PluginsService`: install/update (update only to a strictly higher
  version, preserving the admin's instance mode; files land before the
  metadata pointer flips) / uninstall (refused while required; soft-delete
  + files removed + pond activations dropped) / list / get.
- `POST/GET/DELETE /admin/plugins` (SiteAdminGuard, multer memory upload),
  error→HTTP-status mapping. Public version-pinned static serving at
  `GET /plugins/:id/:version/*rest` with immutable cache + nosniff, only for
  the installed current version.
- `PluginWatcherService`: watches `<PLUGINS_DIR>/_dropzone/`, runs the same
  validation, installs valid drops and quarantines invalid ones with the
  error logged; inert under NODE_ENV=test (tests drive processDropped).
- SDK: `compareVersions`/`isHigherVersion`. shared: `PluginView`,
  `PluginInstanceMode`, `PLUGIN_ERROR_CODES`, `PLUGINS_DIR` env, plugin
  error i18n (de+en). Compose: `plugins` volume + `PLUGINS_DIR`.
- Tests: package unit test (valid + each invalid class) and an e2e DB test
  (GUI install + immutable serving, non-admin 403, invalid-manifest details,
  dropzone install + quarantine, atomic higher-only update, required-guarded
  uninstall that removes files and tombstones metadata).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 16:55:26 +02:00
..
fixtures/manifests Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70) 2026-07-10 16:12:00 +02:00
src Add plugin storage, install API, and directory watcher (#71) 2026-07-10 16:55:26 +02:00
package.json Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70) 2026-07-10 16:12:00 +02:00
README.md Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70) 2026-07-10 16:12:00 +02:00
tsconfig.json Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70) 2026-07-10 16:12:00 +02:00
vitest.config.ts Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70) 2026-07-10 16:12:00 +02:00

@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_stylesectionStyle only; codeblock/pageTool);
  • extension point ids are unique within the manifest;
  • section_style plugins run no JavaScript and must not declare permissions.

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 with capability_not_permitted unless 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 real MessageChannel.
  • pnpm typechecktsc --noEmit.