dorfteich/packages/plugin-sdk
Claude Fable 5 0003063c39
All checks were successful
CI / Auth e2e pack (push) Successful in 4m37s
CI / Import/export fidelity gate (push) Successful in 43s
CI / Lint, typecheck, test (push) Successful in 2m54s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m14s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 9s
Add pageTool plugins with toc and page-index references (#77)
The read-only widget surface over page/pond data (ADR 0008 extension
point `pageTool`):

- Host: PageToolsPanel lists the pond's active pageTool surfaces behind
  disclosures — each sandbox iframe mounts lazily on first open and tears
  down on close. The same surfaces are insertable as plugin_block embeds
  (#76's insert picker now offers pageTool points too; the sandbox drives
  both through the same render lifecycle).
- New `ui.scrollToHeading(headingId)` capability: outline ids are derived
  from the doc and never stamped into the DOM, so the host resolves the id
  to its heading position via the shared extractOutline and scrolls the
  matching rendered heading.
- `readPond.listPages` now carries label *names* per summary
  (PagesService.pluginPageSummaries) — the page-index filter chips work on
  data the viewer could resolve anyway; per-page permission filtering
  stays in the service as before.
- Reference plugins packages/plugins/toc and packages/plugins/page-index:
  real SDK consumers (createPlugin + windowTransport), bundled with
  esbuild into the package ZIP; i18n de/en is inlined at build time — the
  sandbox CSP forbids runtime fetches, the i18n/ files stay the single
  source. The toc re-fetches its outline on a slow poll, so live heading
  edits appear once the collab server has re-derived the content cache.
- e2e page-tools.spec.ts covers the acceptance criteria: live outline
  updates after the persistence debounce, heading click scrolls, embedded
  page-index navigates via ui.openPage, and a label-restricted reader
  never sees the denied page in the index.
- CI: the auth-e2e job now runs the section-styles (missed in #75),
  plugin-blocks, and page-tools packs, with login-rate-limit resets.
- plugins.e2e.db.test clears the plugin registry up front: a local dev DB
  is shared with the e2e stack, whose installed real `toc` would otherwise
  collide with the fixture of the same id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 13:27:30 +02:00
..
fixtures/manifests Add plugin SDK: manifest schema, capabilities, and RPC protocol (#70) 2026-07-10 16:12:00 +02:00
src Add pageTool plugins with toc and page-index references (#77) 2026-07-11 13:27:30 +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.