Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,83 @@
|
||||
# Capability Matrix
|
||||
|
||||
Every host function exposed to a plugin sits behind a capability. The
|
||||
manifest must declare a capability before the runtime binds the
|
||||
corresponding host functions; calling an undeclared host-fn throws
|
||||
`CapabilityDeniedError` and audits as `outcome=denied`.
|
||||
|
||||
## Closed enum
|
||||
|
||||
The runtime maintains a closed enum of capability names in
|
||||
`packages/plugin-runtime/src/capabilities.ts`. Anything outside this
|
||||
list is rejected at manifest parse time.
|
||||
|
||||
| Capability | Risk | Host binding availability | Unlocks / intended binding | Scope shape |
|
||||
| ---------------- | -------- | ------------------------- | --------------------------------------------------------------- | ----------------------------------------- |
|
||||
| `log` | none | available | `ctx.log.{info,warn,error}` - structured logging into host pino | none |
|
||||
| `audit` | none | available | `ctx.audit.record({ action, target?, meta? })` | none |
|
||||
| `tenant.info` | low | reserved / fail-closed | Reserved tenant metadata binding; not bound today | none |
|
||||
| `secret.read` | medium | reserved / fail-closed | Reserved per-tenant secret reads; not bound today | `{ keyPattern: "^slack-" }` planned |
|
||||
| `storage.read` | medium | reserved / fail-closed | Reserved tenant-scoped key/value reads; not bound today | per-prefix locking planned |
|
||||
| `storage.write` | medium | reserved / fail-closed | Reserved tenant-scoped key/value writes; not bound today | per-prefix locking planned |
|
||||
| `http.outbound` | high | reserved / fail-closed | Reserved outbound fetch binding; not bound today | `{ allowedHosts: ["hooks.slack.com"] }` |
|
||||
| `db.read` | high | reserved / fail-closed | Reserved DB binding for marketplace work; not bound today | `{ tablePrefix: "tx_" }` planned |
|
||||
| `db.write` | critical | reserved / fail-closed | Reserved DB binding for marketplace work; not bound today | `{ tablePrefix: "tx_" }` planned |
|
||||
|
||||
`PLUGIN_CAPABILITY_NAMES` is intentionally broader than the production host
|
||||
binding set. As of this foundation, `PLUGIN_AVAILABLE_HOST_CAPABILITY_NAMES`
|
||||
contains only `log` and `audit`; declaring higher-risk capabilities parses, but
|
||||
production install/grant/enable paths reject them until their bindings, tests,
|
||||
and security review ship.
|
||||
|
||||
## Risk legend
|
||||
|
||||
- **none** — no tenant data, no outbound — safe for any plugin.
|
||||
- **low** — tenant identity only; no payload exposure.
|
||||
- **medium** — touches per-tenant data inside the host; operator
|
||||
review recommended.
|
||||
- **high** — issues network requests OR reads tenant business data;
|
||||
audit-trail required and operator must explicitly approve at install.
|
||||
- **critical** — mutates tenant business data; install requires
|
||||
out-of-band operator sign-off; auto-disable on the slightest
|
||||
anomaly.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- **Declaring `http.outbound` without `allowedHosts`** is the most
|
||||
common authoring mistake. The runtime denies every request unless
|
||||
the host appears in the allowlist. The lint command warns on this
|
||||
shape.
|
||||
- **Assuming `db.read` is bound today** — the DB capability is
|
||||
reserved-only in the foundation. A plugin that declares it parses
|
||||
fine but the host returns `no_host_implementation` on call.
|
||||
- **Using `audit` as a payload queue** — see the async-task example;
|
||||
the audit body has a cap. Pass identifiers, not blobs.
|
||||
|
||||
## How to request a new capability
|
||||
|
||||
1. Propose the name (lowercase, dot-separated).
|
||||
2. Write a one-paragraph threat model: what data can leak, what
|
||||
privileges are gained, what reverse-direction trust does the
|
||||
capability imply?
|
||||
3. Add to `PLUGIN_CAPABILITY_NAMES` in `capabilities.ts`.
|
||||
4. Add the host-fn implementation in `capability-gateway.ts` via the
|
||||
provided `hostFns` map; never bypass the gateway.
|
||||
5. Update this table.
|
||||
6. Operator-side: external security review when the new capability
|
||||
ships in production (Phase 06 #23 sign-off — see audit-docs.md).
|
||||
|
||||
## Audit trail mapping
|
||||
|
||||
Every successful or failed call to a host-fn produces one row in
|
||||
`PluginCapabilityUse`:
|
||||
|
||||
| Column | Meaning |
|
||||
| ------------------- | ---------------------------------------------------- |
|
||||
| `capability` | Capability name (e.g. `http.outbound`) |
|
||||
| `outcome` | `success` / `denied` / `error` |
|
||||
| `payloadHash` | SHA-256 of stable call descriptor; never the body |
|
||||
| `durationMs` | Time spent in the host-fn (excluding gateway frame) |
|
||||
| `errorCode` | Short code on `denied` / `error` (e.g. `not_declared_in_manifest`) |
|
||||
|
||||
Tenant admins see this list under
|
||||
`/app/:tenant/settings/plugins/:id/audit`.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Plugin CLI Reference
|
||||
|
||||
`toolctrl-cli plugins ...` — the author + operator command surface
|
||||
for the plugin pipeline. Operator subcommands manage installed-plugin
|
||||
rows; author subcommands scaffold / build / sign / lint / publish.
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins --help
|
||||
```
|
||||
|
||||
## Author subcommands
|
||||
|
||||
### `scaffold`
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins scaffold \
|
||||
--name slack-notifier \
|
||||
--publisher de.acme \
|
||||
--description "Send tenant events to Slack" \
|
||||
[--target-dir ./my-plugins] [--dry-run]
|
||||
```
|
||||
|
||||
Generates a new plugin skeleton under `<targetDir>/<name>/`:
|
||||
|
||||
```
|
||||
slack-notifier/
|
||||
├── plugin.toml # manifest seed (placeholder sig/hash)
|
||||
├── src/index.ts # HookHandler example
|
||||
├── package.json # @toolctrl/plugin-sdk devDependency
|
||||
├── tsconfig.json # ES2022 strict
|
||||
└── README.md # next-step checklist
|
||||
```
|
||||
|
||||
`--dry-run` prints the file list without writing anything.
|
||||
|
||||
### `build`
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins build [--cwd ./slack-notifier] [--output plugin.wasm]
|
||||
```
|
||||
|
||||
Detects the project's wasm toolchain (AssemblyScript via `asconfig.json`,
|
||||
TinyGo via `go.mod`, Rust via `Cargo.toml`) and prints the canonical
|
||||
build command. Does NOT auto-execute — pipe the command into your CI or
|
||||
run it yourself. Exits non-zero when no toolchain marker is found.
|
||||
|
||||
### `sign`
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins sign \
|
||||
--wasm plugin.wasm \
|
||||
--manifest plugin.json \
|
||||
--key ./keys/publisher.priv \
|
||||
[--key-id staging-2026-05] \
|
||||
[--out-sig plugin.wasm.sig]
|
||||
```
|
||||
|
||||
1. SHA-256-hashes the wasm.
|
||||
2. Builds the same canonical manifest projection used by the runtime verifier
|
||||
(sorted-keys JSON minus `signature`/`signedBy`/`wasmSha256`).
|
||||
3. Signs `<canonical>:<wasmSha256>` with the ed25519 private key.
|
||||
4. Writes the raw signature hex to `<wasm>.sig` (or `--out-sig`).
|
||||
5. Prints the `wasmSha256` / `signature` / `signedBy` lines to splice
|
||||
back into the manifest.
|
||||
|
||||
The private key is read from disk and never echoed. `--key-id` is
|
||||
logged only — useful for operators to track which signing key produced
|
||||
which artifact.
|
||||
|
||||
### `lint`
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins lint --manifest plugin.json
|
||||
```
|
||||
|
||||
Runs the host Zod schema, then layers author checks on top:
|
||||
|
||||
- `manifest_invalid` (error) — Zod rejected the manifest.
|
||||
- `unknown_capability` (error) — capability not in the host registry.
|
||||
- `invalid_host` (error) — `allowedHosts` entry is not a hostname.
|
||||
- `unsigned_placeholder` (warn) — signature/signedBy still zeros.
|
||||
- `unsigned_wasm` (warn) — wasmSha256 still zeros.
|
||||
- `http_outbound_unrestricted` (warn) — `http.outbound` declared but
|
||||
`allowedHosts` is empty.
|
||||
- `high_risk_capability` (warn) — `db.write` declared.
|
||||
|
||||
Exit codes:
|
||||
|
||||
| Code | Meaning |
|
||||
| ---- | -------------------- |
|
||||
| 0 | Clean |
|
||||
| 1 | At least one error |
|
||||
| 2 | Warnings only |
|
||||
|
||||
### `publish`
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins publish \
|
||||
--wasm plugin.wasm \
|
||||
--manifest plugin.json \
|
||||
[--signature plugin.wasm.sig] \
|
||||
[--out ./dist]
|
||||
```
|
||||
|
||||
Packs `plugin.wasm` + `plugin.json` (+ `plugin.wasm.sig` if present)
|
||||
into `<pluginId>-<version>.tar.gz` ready for operator upload.
|
||||
**Local-only — no HTTP push.** Operators must inspect what they
|
||||
upload; auto-uploading would hide bad signatures from review.
|
||||
|
||||
## Operator subcommands
|
||||
|
||||
| Command | Action |
|
||||
| ---------------------------- | -------------------------------------------------------------- |
|
||||
| `plugins list` | List installed plugin rows (filter by tenantId + enabled state) |
|
||||
| `plugins enable <tenantId> <pluginId>` | Enable an installed plugin |
|
||||
| `plugins disable <tenantId> <pluginId>` | Disable an installed plugin |
|
||||
| `plugins uninstall <tenantId> <pluginId>` | Operator-only deletion of the tenant Plugin row (cascades user configs) |
|
||||
| `plugins verify-audit-chain <tenantId> [--pluginId <pluginId>] [--from <iso>] [--to <iso>] [--json]` | Verify the tenant-wide PluginCapabilityUse hash chain; `--pluginId` reports matching rows without slicing the chain |
|
||||
|
||||
These operate against the host database directly; they are intended
|
||||
for incident-response and don't substitute the tenant-admin UI. Uninstall is
|
||||
deliberately available only through this operator CLI path until plugin-owned
|
||||
data cleanup/down-migration semantics are reviewed.
|
||||
|
||||
Operator lifecycle commands use the same production gates as the service path:
|
||||
production enables require `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`, reject
|
||||
`PLUGIN_REQUIRE_CAPABILITY_CONSENT=false`, reject capabilities whose host
|
||||
bindings are not production-reviewed, check current grants before enabling, and
|
||||
write lifecycle audit events transactionally with the mutation. Disable remains
|
||||
available as the emergency containment path.
|
||||
|
||||
Permission split: tenant routes and UI use `app.plugins.use` for installed
|
||||
plugin reads, per-user non-secret settings, and settings/audit surfaces.
|
||||
`app.plugins.manage` is still required for tenant API/UI install,
|
||||
grant/revoke, enable/disable, and future update flows. CLI commands are operator
|
||||
escape hatches against the host database and do not replace the tenant
|
||||
permission model; CLI uninstall is not exposed as a tenant permissioned
|
||||
API/UI flow.
|
||||
|
||||
## Round-trip smoke
|
||||
|
||||
```bash
|
||||
toolctrl-cli plugins scaffold --name smoke --publisher de.smoke --dry-run
|
||||
toolctrl-cli plugins build --cwd ./smoke
|
||||
# (run the printed build command in your wasm toolchain)
|
||||
toolctrl-cli plugins lint --manifest ./smoke/plugin.json
|
||||
toolctrl-cli plugins sign --wasm ./smoke/plugin.wasm --manifest ./smoke/plugin.json --key ./keys/dev.priv
|
||||
toolctrl-cli plugins publish --wasm ./smoke/plugin.wasm --manifest ./smoke/plugin.json
|
||||
```
|
||||
|
||||
The output is the file you upload through the tenant-admin plugins
|
||||
settings page.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Plugin Manifest Reference
|
||||
|
||||
> The plugin manifest is the contract between an installable WASM
|
||||
> binary and the host runtime. The host parses the manifest with the
|
||||
> Zod schema in `packages/plugin-runtime/src/manifest.ts` and rejects
|
||||
> anything that does not validate — adding a field here without
|
||||
> updating the schema does nothing.
|
||||
|
||||
The manifest may be TOML or JSON (whichever your toolchain emits). The
|
||||
runtime works against the parsed object, not the source bytes — except
|
||||
during signing, where the canonical form is the sorted-keys JSON
|
||||
projection (see `cli-reference.md#sign`).
|
||||
|
||||
## Top-level fields
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| --------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `schemaVersion` | `1` | yes | Bumped on breaking schema changes. Host rejects unknown versions. |
|
||||
| `id` | reverse-DNS string | yes | Pattern `^[a-z]+(\.[a-z0-9-]+){1,}$`. Globally unique across hosts. |
|
||||
| `version` | semver | yes | `MAJOR.MINOR.PATCH[-prerelease]`. Host enforces SemVer for upgrades. |
|
||||
| `name` | string ≤ 256 chars | yes | Display name. |
|
||||
| `description` | string ≤ 2048 chars | no | Free-form for the marketplace. |
|
||||
| `entry` | string ≤ 256 chars | yes | Path of the wasm relative to the manifest, e.g. `plugin.wasm`. |
|
||||
| `wasmSha256` | 64-char hex | yes | SHA-256 of the binary at signing time. Re-checked on every load. |
|
||||
| `signature` | 128-char hex | yes | Ed25519 signature over `canonicalManifest:wasmSha256`. |
|
||||
| `signedBy` | 64-char hex | yes | Raw 32-byte ed25519 public key (hex). Registered with operator. |
|
||||
| `allowedHosts` | array of public hostnames | no | Outbound HTTP allowlist for `http.outbound`. Empty disables HTTP. No scheme, port, localhost, IP literal, `.local`, or `.internal`. |
|
||||
| `capabilities` | array of capability entries | no | Capability declarations. See below. |
|
||||
| `hooks` | array of hook entries (≥1) | yes | At least one hook must be declared; a zero-hook plugin never runs. |
|
||||
| `ui.settings.entry` | relative bundle path | no | Optional settings HTML entry rendered by the host in a sandboxed `PluginSettingsFrame`. Absolute paths, URL schemes, and `..` segments are rejected. When present, the settings entry must also declare `ui.settings.sha256` or `ui.settings.integrity` so the host can verify the bundled HTML before rendering it. |
|
||||
|
||||
Settings HTML communicates with the host through the versioned
|
||||
`plugin.settings.*` `postMessage` protocol documented in
|
||||
[`sdk-reference.md#settings-iframe-protocol`](sdk-reference.md#settings-iframe-protocol).
|
||||
|
||||
## Capability entries
|
||||
|
||||
```toml
|
||||
[[capabilities]]
|
||||
name = "http.outbound"
|
||||
scope = { allowedHosts = ["hooks.slack.com"] }
|
||||
|
||||
[[capabilities]]
|
||||
name = "secret.read"
|
||||
scope = { keyPattern = "slack-webhook-.*" }
|
||||
```
|
||||
|
||||
Each entry has:
|
||||
|
||||
- `name` — capability name from the closed enum (see
|
||||
`capability-matrix.md`).
|
||||
- `scope` — optional, capability-specific narrowing. Permitted shape:
|
||||
|
||||
| Field | Allowed for | Meaning |
|
||||
| -------------- | --------------------- | --------------------------------------------- |
|
||||
| `allowedHosts` | `http.outbound` | Hostname allowlist (no scheme, no port) |
|
||||
| `keyPattern` | `secret.read` | Regex (1-128 chars) the key must match |
|
||||
| `tablePrefix` | `db.read`, `db.write` | Required prefix; pattern `^[a-z][a-z0-9_]*_$` |
|
||||
|
||||
Other keys are rejected by `.strict()`.
|
||||
|
||||
## Hook entries
|
||||
|
||||
```toml
|
||||
[[hooks]]
|
||||
name = "entity.created"
|
||||
export = "handleEntityCreated"
|
||||
```
|
||||
|
||||
- `name` — host hook event name; must match an entry the host emits.
|
||||
- `export` — function name exported from the wasm binary that handles
|
||||
the hook.
|
||||
|
||||
Both fields cap at 128 characters.
|
||||
|
||||
### Foundation Hook Payloads
|
||||
|
||||
The SDK exports matching payload types from `@toolctrl/plugin-sdk`
|
||||
(`TypedHookHandler<"entity.updated">`, `PluginHookWorkflowExecutedPayload`,
|
||||
and friends). Runtime validation is strict, so handlers should rely on
|
||||
these field names instead of ad-hoc shapes.
|
||||
|
||||
| Hook | Payload summary |
|
||||
| --- | --- |
|
||||
| `tenant.created` | `tenantId`, `slug`, optional `ownerEmail`, optional `plan`, `createdAt` |
|
||||
| `user.created` | `userId`, optional nullable `tenantId`, `email`, optional `locale`, `createdAt` |
|
||||
| `entity.created` / `entity.updated` / `entity.deleted` | `entityType`, `entityId`, nullable `tenantId`, optional `userId`, optional `changedFields`, `occurredAt` |
|
||||
| `workflow.created` / `workflow.updated` / `workflow.deleted` | nullable `tenantId`, `workflowId`, nullable `userId`, `status`, `appliesToAllTenants`, optional `changedFields`, `occurredAt`; workflow blocks, credentials, and raw input are intentionally not included. |
|
||||
| `workflow.executed` | `tenantId`, `workflowId`, `workflowExecutionId`, optional nullable `userId`, `type`, `status`, `durationMs`, `errorCode` (`null` for `success`, required for `error`), `appliesToAllTenants`, `occurredAt`; raw execution input, output, and error bodies are intentionally not included. |
|
||||
| `workflow.failed` | `tenantId`, `workflowId`, `workflowExecutionId`, optional nullable `userId`, `type`, fixed `status: "error"`, required `errorCode`, `durationMs`, `appliesToAllTenants`, `occurredAt`; raw execution input, output, and error bodies are intentionally not included. |
|
||||
|
||||
## Strictness
|
||||
|
||||
The schema uses Zod's `.strict()` everywhere — unknown fields cause
|
||||
parse failure. This is intentional: extending the manifest is a
|
||||
deliberate, host-side operation, never a plugin-author hack.
|
||||
|
||||
## Canonical bytes (for signing)
|
||||
|
||||
The CLI and runtime verifier both sign and verify
|
||||
`<canonicalManifestJSON>:<wasmSha256>` where the canonical form is the manifest
|
||||
minus `signature`, `signedBy`, and `wasmSha256`, projected to JSON with deeply
|
||||
sorted keys (see `canonicalPluginSignaturePayload` in
|
||||
`packages/plugin-runtime/src/signature.ts`).
|
||||
|
||||
This means adding / removing capabilities, hooks, or any other field
|
||||
invalidates the signature — re-signing is required for every authoring
|
||||
change.
|
||||
|
||||
## Example (TOML)
|
||||
|
||||
```toml
|
||||
schemaVersion = 1
|
||||
id = "de.acme.slack-notifier"
|
||||
version = "1.2.0"
|
||||
name = "Slack Notifier"
|
||||
description = "Sends tenant events to a Slack channel."
|
||||
entry = "plugin.wasm"
|
||||
wasmSha256 = "a1b2..."
|
||||
signature = "c3d4..."
|
||||
signedBy = "e5f6..."
|
||||
allowedHosts = ["hooks.slack.com"]
|
||||
|
||||
[[capabilities]]
|
||||
name = "log"
|
||||
|
||||
[[capabilities]]
|
||||
name = "secret.read"
|
||||
scope = { keyPattern = "slack-webhook-.*" }
|
||||
|
||||
[[capabilities]]
|
||||
name = "http.outbound"
|
||||
scope = { allowedHosts = ["hooks.slack.com"] }
|
||||
|
||||
[[hooks]]
|
||||
name = "entity.created"
|
||||
export = "handleEntityCreated"
|
||||
|
||||
[[hooks]]
|
||||
name = "entity.updated"
|
||||
export = "handleEntityUpdated"
|
||||
```
|
||||
|
||||
## Versioning + compatibility
|
||||
|
||||
Plugins may carry a `minHostVersion` / `maxHostVersion` constraint
|
||||
(see `versioning.md`). Install rejects manifests whose declared host-version
|
||||
range does not include the current host version.
|
||||
@@ -0,0 +1,210 @@
|
||||
# Plugin SDK Reference
|
||||
|
||||
> `@toolctrl/plugin-sdk` — the typed surface plugin authors compile
|
||||
> against. Mirrors the runtime capability set so an SDK upgrade is a
|
||||
> straight version bump on the author side.
|
||||
|
||||
## Installing
|
||||
|
||||
The SDK lives in the host monorepo today as `workspace:*`. External
|
||||
plugin authors install it as a regular npm dependency:
|
||||
|
||||
```bash
|
||||
pnpm add -D @toolctrl/plugin-sdk
|
||||
```
|
||||
|
||||
## Top-level exports
|
||||
|
||||
| Export | Kind | Purpose |
|
||||
| ----------------------------------------- | ----- | --------------------------------------------------------------------------- |
|
||||
| `defineHook` | fn | Identity wrapper that captures `Input`/`Output` types at the call site |
|
||||
| `createPluginSettingsClient` | fn | Settings iframe helper around the host `postMessage` protocol |
|
||||
| `HookHandler` | type | `(input, ctx) => Output \| Promise<Output>` |
|
||||
| `PluginContext` | type | Per-invocation context (logger, capabilities, tenant info) |
|
||||
| `HostApi` | type | Aggregate of optional bindings; only the declared ones are bound |
|
||||
| `PluginCapabilityName` | type | Closed enum of capabilities |
|
||||
| `PluginLogger` | type | Pino-subset (info/warn/error) |
|
||||
| `PluginHttpClient` | type | `http.outbound` capability surface |
|
||||
| `PluginStorage` | type | `storage.read` / `storage.write` capability surface |
|
||||
| `PluginAudit` | type | `audit` capability surface; production-available but still capability-gated |
|
||||
| `PluginTenantInfo` | type | Read-only tenant metadata (`tenant.info`) |
|
||||
| `PluginSettingsClient` | type | Settings iframe helper returned by `createPluginSettingsClient` |
|
||||
| `PluginSettingsClientError` | class | Host-declared settings request failure |
|
||||
| `PluginSettingsClientTimeoutError` | class | Settings request timeout failure |
|
||||
| `PluginSettingsFrameMessage` | type | Settings iframe `postMessage` request union |
|
||||
| `PluginSettingsFrameResultMessage` | type | Host response union for settings iframe requests |
|
||||
| `PluginSettingValue` | type | JSON-safe value shape accepted by per-user plugin settings |
|
||||
| `PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION` | const | Current settings iframe protocol version (`1`) |
|
||||
| `PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME` | const | Meta tag name that carries the host-generated channel id |
|
||||
|
||||
## Authoring shape
|
||||
|
||||
```ts
|
||||
import { defineHook, type HookHandler } from "@toolctrl/plugin-sdk";
|
||||
|
||||
export const handleHello: HookHandler<{ name: string }, { greeting: string }> = defineHook((input, ctx) => {
|
||||
ctx.log.info({ name: input.name }, "hello");
|
||||
return { greeting: `Hello, ${input.name}!` };
|
||||
});
|
||||
```
|
||||
|
||||
Every hook export resolves to a single WebAssembly function with a
|
||||
`(arg) -> result` shape. The runtime serialises both directions as
|
||||
JSON; complex types fall through the standard `JSON.stringify` lens.
|
||||
|
||||
## Payload ABI
|
||||
|
||||
The foundation runtime exposes the active invocation payload as UTF-8
|
||||
JSON through two raw WebAssembly imports:
|
||||
|
||||
| Import | Signature | Purpose |
|
||||
| --------------------------- | --------------------- | ------------------------------------------------- |
|
||||
| `env.toolctrl_payload_len` | `() -> i32` | Length in bytes of the JSON payload |
|
||||
| `env.toolctrl_payload_byte` | `(index: i32) -> i32` | Byte value at `index`, or `0` outside the payload |
|
||||
|
||||
The SDK exports `PLUGIN_PAYLOAD_ABI_VERSION` and
|
||||
`PLUGIN_PAYLOAD_ABI_IMPORTS` so toolchain bindings can assert the host
|
||||
contract. Higher-level AssemblyScript/Rust helpers are still follow-up
|
||||
work; raw WASM plugins can already decode the JSON bytes themselves.
|
||||
|
||||
## Settings iframe protocol
|
||||
|
||||
When a manifest declares `ui.settings.entry`, the host renders that HTML
|
||||
inside a sandboxed `PluginSettingsFrame`. The iframe has an opaque
|
||||
origin (`sandbox="allow-scripts"`, no `allow-same-origin`) and a deny-by-default
|
||||
CSP, so settings UIs communicate with the host only through the versioned
|
||||
`postMessage` protocol exported by the SDK.
|
||||
|
||||
Use `createPluginSettingsClient()` to read the injected channel id, send
|
||||
`ready`, `get`, and `set` messages to `window.parent`, and accept responses
|
||||
only when the message came from `window.parent` and matches the expected
|
||||
protocol version, plugin id, channel id, and request id:
|
||||
|
||||
```ts
|
||||
import { createPluginSettingsClient, PluginSettingsClientError, PluginSettingsClientTimeoutError } from "@toolctrl/plugin-sdk";
|
||||
|
||||
const settings = createPluginSettingsClient({
|
||||
pluginId: "com.acme.settings",
|
||||
});
|
||||
|
||||
settings.ready();
|
||||
|
||||
try {
|
||||
const theme = await settings.get("theme");
|
||||
applyTheme(theme);
|
||||
|
||||
await settings.set("theme", "dark");
|
||||
} catch (error) {
|
||||
if (error instanceof PluginSettingsClientError) {
|
||||
console.warn("setting request failed", error.code, error.message);
|
||||
} else if (error instanceof PluginSettingsClientTimeoutError) {
|
||||
console.warn("setting request timed out", error.message);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The host validates `pluginId`, `channelId`, `requestId`, keys, and values.
|
||||
Values must be JSON-safe (`PluginSettingValue`) and stay within the host
|
||||
size/depth budgets. Persistent writes are CSRF-protected, tenant/user scoped,
|
||||
and rate-limited by the route action.
|
||||
|
||||
## Per-capability bindings on `ctx`
|
||||
|
||||
Each capability bound to the invocation appears as an optional field on
|
||||
`ctx`. The runtime sets a field only when the manifest declared it AND
|
||||
the gateway has a host implementation for it. Always null-check before
|
||||
use:
|
||||
|
||||
```ts
|
||||
if (!ctx.http) {
|
||||
ctx.log.warn({}, "http capability not bound — manifest must declare http.outbound");
|
||||
return { ok: false };
|
||||
}
|
||||
await ctx.http.fetch({ url: "https://example.com", method: "GET" });
|
||||
```
|
||||
|
||||
This shape gives you compile-time documentation: every capability the
|
||||
plugin uses is visible at the top of the handler.
|
||||
|
||||
## Logger
|
||||
|
||||
`ctx.log` is a structured logger. The host writes the message to its
|
||||
own logging pipeline (pino under the hood). The signature mirrors pino:
|
||||
|
||||
```ts
|
||||
ctx.log.info({ tenantId, recordId }, "entity created");
|
||||
ctx.log.warn({ retries: 3 }, "retrying");
|
||||
ctx.log.error({ err: errSerializable }, "fatal");
|
||||
```
|
||||
|
||||
Never log secrets — output sanitisation is best-effort, not a guarantee.
|
||||
|
||||
## HTTP client (`ctx.http`)
|
||||
|
||||
Reserved SDK shape for `http.outbound`. It is not bound in the current
|
||||
production/foundation runtime; manifests may declare it only as a reviewed
|
||||
future capability, and calls fail closed until the host binding is explicitly
|
||||
enabled after security review.
|
||||
|
||||
```ts
|
||||
const response = await ctx.http!.fetch({
|
||||
url: "https://hooks.slack.com/services/T0/B0/abc",
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: "hello" }),
|
||||
});
|
||||
```
|
||||
|
||||
When enabled, the host validates the URL against `allowedHosts` from the manifest's
|
||||
`http.outbound` scope before issuing the request. Any other host
|
||||
results in a denied audit row.
|
||||
|
||||
## Storage (`ctx.storage`)
|
||||
|
||||
Reserved SDK shape for `storage.read` and `storage.write`. It is not bound in
|
||||
the current production/foundation runtime; tenant-scoped storage remains
|
||||
fail-closed until the capability receives a dedicated security review.
|
||||
|
||||
```ts
|
||||
const channel = await ctx.storage!.get(`slack:${ctx.tenantId}:default-channel`);
|
||||
await ctx.storage!.put(`slack:${ctx.tenantId}:default-channel`, "#alerts", {
|
||||
ttlSeconds: 86_400,
|
||||
});
|
||||
```
|
||||
|
||||
Keys are byte-strings (no length cap today, but treat 256B as a soft
|
||||
budget). Values are byte-strings; encode JSON yourself if you need
|
||||
structured data.
|
||||
|
||||
## Audit (`ctx.audit`)
|
||||
|
||||
Production-available when the plugin manifest declares `audit` and the tenant
|
||||
has granted that capability. Records a tenant-visible audit event:
|
||||
|
||||
```ts
|
||||
await ctx.audit.record({
|
||||
action: "slack.notification-sent",
|
||||
target: `entity:${recordId}`,
|
||||
meta: { channel: "#alerts" },
|
||||
});
|
||||
```
|
||||
|
||||
Use for state-change observability. Do not abuse as a queue —
|
||||
metadata payloads have a hard cap.
|
||||
|
||||
## Errors
|
||||
|
||||
Throwing from a handler aborts the invocation. The runtime captures the
|
||||
exception, records `outcome=error` against every in-flight capability,
|
||||
flushes the audit batch, and marks the invocation `failed`. The plugin
|
||||
crash-tracker auto-disables a plugin after repeated failures.
|
||||
|
||||
## Versioning
|
||||
|
||||
The SDK follows SemVer. Major bumps may add or remove capability
|
||||
surfaces; consult `docs/plugins/versioning.md` before pinning to a new
|
||||
major. The runtime advertises its host range through `minHostVersion` /
|
||||
`maxHostVersion` in the manifest — when in doubt, lint your manifest
|
||||
against the running host.
|
||||
@@ -0,0 +1,135 @@
|
||||
# Plugin Versioning + Compatibility Policy
|
||||
|
||||
The plugin runtime supports three independent version axes. This
|
||||
document defines the rules each axis follows and how they compose into
|
||||
the compatibility checks the host enforces at install time.
|
||||
|
||||
## Axes
|
||||
|
||||
| Axis | Scope | Policy |
|
||||
| ----------------------- | ----------------------------------------------------- | ----------------------------------------------- |
|
||||
| Manifest schema version | `schemaVersion` field on every plugin.toml | Integer-bumped on breaking schema changes |
|
||||
| Plugin SDK version | `@toolctrl/plugin-sdk` npm package | NPM SemVer |
|
||||
| Host runtime version | The host binary running the plugin | NPM SemVer (toolctrl-boilerplate `package.json`) |
|
||||
|
||||
Each plugin pins:
|
||||
|
||||
```toml
|
||||
schemaVersion = 1 # required
|
||||
minHostVersion = "1.0.0" # optional, inclusive lower host bound
|
||||
maxHostVersion = "1.9.9" # optional, inclusive upper host bound
|
||||
```
|
||||
|
||||
## SemVer policy for plugin authors
|
||||
|
||||
| Change | Bump |
|
||||
| ----------------------------------------------------- | ------------- |
|
||||
| Add a new hook subscription | minor |
|
||||
| Drop a hook subscription | major |
|
||||
| Add a new declared capability | minor |
|
||||
| Tighten an `allowedHosts` / `keyPattern` scope | major |
|
||||
| Loosen an `allowedHosts` / `keyPattern` scope | minor |
|
||||
| Change `entry` filename | major |
|
||||
| Logic-only change inside the wasm | patch |
|
||||
| Re-sign with a new key (`signedBy` change) | minor (audit) |
|
||||
|
||||
Side-by-side updates are not implemented in the foundation. The
|
||||
`Plugin` table has a unique `(tenantId, pluginId)` constraint, and
|
||||
`installPlugin` rejects a duplicate `pluginId` for the tenant with
|
||||
`already_installed`. To move a tenant to a new plugin version today,
|
||||
operators must disable/remove the existing install path first, then
|
||||
install the replacement.
|
||||
|
||||
## SemVer policy for the host
|
||||
|
||||
| Change | Bump |
|
||||
| ----------------------------------------------------- | ------------- |
|
||||
| Add a capability to the closed enum | minor |
|
||||
| Remove a capability from the closed enum | major |
|
||||
| Add a new optional manifest field | minor |
|
||||
| Make an optional manifest field required | major |
|
||||
| Bump `schemaVersion` (breaking manifest change) | major |
|
||||
| Reduce plugin fuel limit | major |
|
||||
| Increase plugin fuel limit | minor |
|
||||
| Rework hook payload shape (breaking) | major |
|
||||
|
||||
## Compatibility matrix template (reserved)
|
||||
|
||||
Reserved for a future boot-time compatibility sweep. The foundation
|
||||
does not evaluate an installed plugin set against this table on worker
|
||||
boot. Current enforcement is per install: the manifest must parse, the
|
||||
`schemaVersion` must be accepted, and optional `minHostVersion` /
|
||||
`maxHostVersion` constraints must include the running host version.
|
||||
|
||||
| Host version | Manifest `schemaVersion` accepted | Plugin SDK majors known to work | Status |
|
||||
| ------------ | --------------------------------- | ------------------------------- | ---------- |
|
||||
| `1.x` | `1` | `0.x` | current |
|
||||
| `2.x` | `1`, `2` | `0.x`, `1.x` | planned |
|
||||
|
||||
When a host crosses a major boundary it must:
|
||||
|
||||
1. Bump the rejected `schemaVersion` set in the runtime.
|
||||
2. Update this table.
|
||||
3. Send a deprecation banner to every plugin author 2 majors in
|
||||
advance (the marketplace surface owns the comms — backlog).
|
||||
|
||||
## Install-time check
|
||||
|
||||
`PluginLifecycleService.installPlugin` validates:
|
||||
|
||||
1. Manifest parses with the current Zod schema.
|
||||
2. `schemaVersion` is in the host's accepted set.
|
||||
3. Production runtime operations are explicitly enabled in production
|
||||
with `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`.
|
||||
4. Optional `minHostVersion` / `maxHostVersion` include the running
|
||||
host version. Bounds are inclusive; omitting both means "any host".
|
||||
5. `signaturePublicKey` matches `manifest.signedBy`.
|
||||
6. `manifest.signedBy` is present in `PLUGIN_TRUSTED_PUBLIC_KEYS`.
|
||||
7. The pre-positioned disk binary verifies against the submitted
|
||||
`wasmSha256`, manifest signature, manifest signing key, and trusted
|
||||
key set.
|
||||
8. The submitted `wasmSha256` matches `manifest.wasmSha256`.
|
||||
9. No plugin with the same `(tenantId, pluginId)` is already installed.
|
||||
10. The tenant has not exceeded `PLUGIN_MAX_INSTALLS_PER_TENANT`.
|
||||
|
||||
Foundation: any of these failing rejects the install with a
|
||||
`PluginLifecycleError`.
|
||||
|
||||
## Boot-time check (reserved)
|
||||
|
||||
The boot-time compatibility matrix remains reserved. If implemented,
|
||||
worker boot would:
|
||||
|
||||
1. Read every installed `Plugin` row.
|
||||
2. Compare against the running host version.
|
||||
3. Mark out-of-range plugins `incompatible_host_version` and skip them
|
||||
on hook fan-out until the operator updates the host or the plugin.
|
||||
|
||||
The foundation does not enforce this yet; plugins remain `enabled`
|
||||
across host upgrades. Host-version constraints are enforced when a
|
||||
plugin is installed, not during worker boot.
|
||||
|
||||
## Upgrade guidance for plugin authors
|
||||
|
||||
- **Don't ship a patch that changes capability declarations.** Patches
|
||||
must be wasm-only; manifest changes are at least a minor.
|
||||
- **Re-sign on every release** — the signature covers `wasmSha256` so
|
||||
any binary change requires re-signing anyway.
|
||||
- **Test against the host's matching SDK major** before publishing.
|
||||
The host SDK ships in the same monorepo and is pinned to the host
|
||||
release.
|
||||
|
||||
## Deviations from SemVer
|
||||
|
||||
Two cases break strict SemVer:
|
||||
|
||||
1. **Security re-signs**: changing `signedBy` (e.g. publisher key
|
||||
rotation) bumps minor + records an audit-event flag so operators
|
||||
notice. The change is non-functional, but operators must re-trust
|
||||
the new key.
|
||||
2. **Capability-scope tightening**: tightening an `allowedHosts`
|
||||
list is a *user-visible* break (some calls now deny) even though
|
||||
the runtime API is identical. Major bump required.
|
||||
|
||||
Both are tracked through the audit-log so a tenant admin can correlate
|
||||
upgrades with denial-row spikes.
|
||||
Reference in new issue
Block a user