All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 3m18s
CI / Auth e2e pack (push) Successful in 4m3s
CI / Import/export fidelity gate (push) Successful in 54s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m9s
CD / Promote to Int (push) Has been skipped
The SDK is the contract every other M7 story builds on (ADR 0008,
plugin-architecture.md). New package `@dorfteich/plugin-sdk`, standalone
(only depends on zod) so a plugin author needs nothing else.
- Zod manifest schema (`validateManifest`/`parseManifest`) with actionable
`{ path, message }` issues and cross-field rules (extension-point/kind
match, unique ids, section_style declares no permissions). Fixtures:
3 valid + 14 invalid variants, asserted individually.
- `checkApiVersion` compatibility helper against the host's supported range.
- Capability names + method→capability map as the single source of truth
for the permission gate.
- Transport-agnostic postMessage RPC engine (`createRpcEndpoint`) with
request/response ids, per-request timeouts, unknown-method and
endpoint-disposed handling, plus a `windowTransport` adapter.
- Host side (`createHostBridge`): routes plugin capability calls through
the manifest permission gate; drives plugin lifecycle (render/edit/destroy).
- Plugin side (`createPlugin`): answers lifecycle calls, exposes a typed
`host` proxy. RPC roundtrip verified in a jsdom MessageChannel test
(roundtrip, args, timeout, unknown method, undeclared capability, dispose).
- README documents the protocol with a mermaid sequence diagram.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
151 lines
5.6 KiB
Markdown
151 lines
5.6 KiB
Markdown
# @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](../../docs/architecture/adr/0008-plugin-sandbox.md) and
|
|
[plugin-architecture.md](../../docs/architecture/plugin-architecture.md) first;
|
|
the manifest example there is normative and mirrored by the fixtures under
|
|
[`fixtures/manifests/`](./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
|
|
|
|
```ts
|
|
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` → `sectionStyle` only; `code` → `block`/`pageTool`);
|
|
- extension point `id`s 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.
|
|
|
|
```jsonc
|
|
// 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
|
|
|
|
```mermaid
|
|
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:
|
|
|
|
```ts
|
|
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:
|
|
|
|
```ts
|
|
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 typecheck` — `tsc --noEmit`.
|