Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,30 @@
# @toolctrl/plugin-sdk
Phase 06 Task #21 foundation — TypeScript SDK plugin authors import against.
## Scope (foundation)
- Type definitions: `HostApi`, `PluginContext`, `HookHandler`
- Example plugin scaffold under `examples/hello-world/` (TypeScript source + manifest)
## How plugin authors use this
1. `pnpm add -D @toolctrl/plugin-sdk`
2. Implement a hook handler:
```ts
import type { HookHandler } from "@toolctrl/plugin-sdk";
export const handler: HookHandler<{ name: string }, { greeting: string }> = (input, ctx) => {
ctx.log.info({ name: input.name }, "hello");
return { greeting: `Hello, ${input.name}!` };
};
```
3. Build to WASM with `wasm-pack`, AssemblyScript, or similar (operator-side CLI tooling is **deferred**, see `docs/improvement-plan/06-plugin-system/wasm-sandbox.md`).
4. Sign the resulting `.wasm` and ship to operator alongside the `plugin.toml` manifest.
## Deferred
- AssemblyScript/Rust toolchain bindings → Phase 06 Task #23 docs follow-up
- Plugin build/sign/publish CLI → backlog
@@ -0,0 +1,49 @@
# async-task
The long-running-task pattern. Plugin handlers should never do heavy
work inline — the sandbox has a fuel ceiling and the hook fan-out is
held while the handler runs. This example records an audit event
representing the work-request and returns immediately; a host-side
worker consumes the event and runs the actual work outside the
sandbox.
## Why not just block in the handler
- Sandbox fuel ceiling: handlers that exceed the budget trap and the
invocation is logged as a failure.
- Hook fan-out: each plugin subscriber serialises the host event loop.
A slow plugin slows every later subscriber.
- Audit clarity: a fast "work-requested" row in the trail is more
useful than a 30-second-blocking row that may or may not have
completed by the time the page loads.
## Pattern
```
Host emits entity.updated
│
▼
Plugin handler runs in sandbox
│
├── ctx.audit.record({ action: "async-task.work-requested", ... })
│ (writes one PluginCapabilityUse audit row + an audit event)
│
└── returns { enqueued: true } immediately
│
▼
Outbox dispatcher reads audit event
│
▼
worker subscribes to action="async-task.work-requested",
runs the long work outside the sandbox, emits a new hook
event on completion if a follow-up is needed.
```
## Caveats
- The audit body is not a payload queue — it has a small size cap and
the runtime sanitises it. Pass IDs through it, fetch the full record
from the worker side via `db.read` if needed.
- The audit row arrives *eventually* (transactional outbox), not
synchronously. If you need synchronous behaviour, plugins are the
wrong tool — call out to a regular host RPC instead.
@@ -0,0 +1,44 @@
/**
* Async-task example — Phase 06 #23.
*
* Long-running side effects should not run inside the plugin invocation
* (the sandbox has a fuel ceiling and the hook fan-out is blocked
* while a handler runs). Instead, the plugin records an audit event
* and returns immediately; a host worker picks up the event from the
* outbox and does the heavy lifting outside the sandbox.
*
* The plugin never blocks waiting on the worker — that is the whole
* point of the pattern. If the host wants a callback, it emits a new
* hook event when the worker is done; the plugin can subscribe to
* that too.
*/
import { defineHook, type PluginHookEntityPayload } from "../../src/index.js";
export const handleEntityUpdated = defineHook<PluginHookEntityPayload, { enqueued: boolean }>(
async (input, ctx) => {
ctx.log.info(
{
tenantId: input.tenantId,
entity: input.entityType,
changedFieldCount: input.changedFields?.length ?? 0,
},
"async-task.handleEntityUpdated received update"
);
// Record one structured audit event — the host worker subscribes
// to plugin-emitted audit events and runs the actual work. Body
// is bounded so we don't accidentally turn the audit log into a
// queue (the audit sink rejects payloads above its cap).
await ctx.audit.record({
action: "async-task.work-requested",
target: `${input.entityType}:${input.entityId}`,
meta: {
tenantId: input.tenantId,
changedFieldCount: input.changedFields?.length ?? 0,
},
});
ctx.log.info({}, "async-task enqueued work-request, returning fast");
return { enqueued: true };
}
);
@@ -0,0 +1,32 @@
# Phase 06 #23 example — async-task plugin.
#
# Demonstrates the long-running-task pattern: instead of doing the work
# inline (which blocks the hook fan-out and risks fuel exhaustion), the
# plugin enqueues the work through `audit` (the capability for emitting
# host events) and returns immediately. The host outbox picks up the
# event and a worker handles the actual work outside the sandbox.
#
# The benefit: the plugin invocation stays fast and traceable while the
# heavy lifting happens in regular BullMQ. The plugin still has clear
# audit rows for the trigger; the worker handles its own observability.
schemaVersion = 1
id = "de.toolctrl.examples.async-task"
version = "0.1.0"
name = "Async Task Demo"
description = "Enqueues a long-running task via the audit capability, returns immediately."
entry = "plugin.wasm"
wasmSha256 = "0000000000000000000000000000000000000000000000000000000000000000"
signature = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
signedBy = "0000000000000000000000000000000000000000000000000000000000000000"
allowedHosts = []
[[capabilities]]
name = "log"
[[capabilities]]
name = "audit"
[[hooks]]
name = "entity.updated"
export = "handleEntityUpdated"
@@ -0,0 +1,35 @@
# capability-use
Three host capabilities, one handler. Designed as the reference for
the audit-trail page: every invocation produces a non-trivial set of
`PluginCapabilityUse` rows you can browse from
`/app/:tenant/settings/plugins/:id/audit`.
## Capabilities used
| Capability | Used for |
| --------------- | ----------------------------------------------- |
| `log` | Structured logging through `ctx.log` |
| `storage.read` | Look up the tenant's Slack webhook URL |
| `http.outbound` | POST a notification to `hooks.slack.com` |
The manifest restricts `http.outbound` via `allowedHosts =
["hooks.slack.com"]` — any other host triggers a denied row instead of
egress.
## Why each call shows up in the audit trail
The capability gateway emits an `onCall` event per host-fn call. The
runtime wires that to `PluginCapabilityUseRecorder`, which buffers
entries per invocation then flushes one outbox batch at sandbox close.
The worker `plugin-capability-use` job persists each row. The audit
page filters by capability + UTC date and pages by keyset cursor.
## Tenant-admin pre-requisites
Before this plugin can post:
1. Install + enable the plugin via the plugins settings page.
2. Write the Slack webhook URL under storage key
`slack:<tenantId>:webhook-url` (operator action — see
`docs/plugins/security-best-practices.md`).
@@ -0,0 +1,64 @@
/**
* Capability-use example — Phase 06 #23.
*
* One handler that touches every "regular" capability so the audit-
* trail page shows a non-trivial set of rows per invocation:
* 1. storage.read to fetch the tenant's Slack webhook URL
* 2. http.outbound to POST a notification to Slack
* 3. log everywhere for structured tracing
*
* When run with the host audit recorder wired in, each capability call
* emits one PluginCapabilityUse row with outcome=success / error and a
* sha256(payloadDescriptor). Tenant admins see exactly which channel
* received which event without the message body ever being persisted.
*/
import { defineHook } from "../../src/index.js";
export interface EntityCreatedInput {
readonly tenantId: string;
readonly entityName: string;
readonly recordId: string;
}
export const handleEntityCreated = defineHook<EntityCreatedInput, { posted: boolean }>(
async (input, ctx) => {
ctx.log.info(
{ tenantId: input.tenantId, entity: input.entityName },
"capability-use.handleEntityCreated invoked"
);
if (!ctx.storage) {
ctx.log.warn({}, "storage capability not bound — manifest must declare storage.read");
return { posted: false };
}
const webhookUrl = await ctx.storage.get(`slack:${input.tenantId}:webhook-url`);
if (!webhookUrl) {
ctx.log.info({ tenantId: input.tenantId }, "no slack webhook configured — skipping");
return { posted: false };
}
if (!ctx.http) {
ctx.log.warn({}, "http capability not bound — manifest must declare http.outbound");
return { posted: false };
}
const body = JSON.stringify({
text: `New ${input.entityName} created (id ${input.recordId})`,
});
const response = await ctx.http.fetch({
url: webhookUrl,
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
if (response.status >= 200 && response.status < 300) {
ctx.log.info({ status: response.status }, "slack webhook posted");
return { posted: true };
}
ctx.log.error(
{ status: response.status, body: response.body.slice(0, 256) },
"slack webhook returned non-2xx"
);
return { posted: false };
}
);
@@ -0,0 +1,35 @@
# Phase 06 #23 example — capability-use plugin.
#
# Demonstrates multiple capabilities working together inside a single
# hook handler:
# - http.outbound for a webhook call (restricted to hooks.slack.com)
# - storage.read to look up the tenant's per-user channel mapping
# - log for structured tracing throughout
# Each host-fn call gets one row in PluginCapabilityUse, so the tenant
# admin's audit-trail page surfaces exactly what this plugin did per
# invocation.
schemaVersion = 1
id = "de.toolctrl.examples.capability-use"
version = "0.1.0"
name = "Capability-Use Demo"
description = "Calls http.outbound + storage.read + log on every invocation. Exercises the audit trail."
entry = "plugin.wasm"
wasmSha256 = "0000000000000000000000000000000000000000000000000000000000000000"
signature = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
signedBy = "0000000000000000000000000000000000000000000000000000000000000000"
allowedHosts = ["hooks.slack.com"]
[[capabilities]]
name = "log"
[[capabilities]]
name = "storage.read"
[[capabilities]]
name = "http.outbound"
scope = { allowedHosts = ["hooks.slack.com"] }
[[hooks]]
name = "entity.created"
export = "handleEntityCreated"
@@ -0,0 +1,37 @@
# Hello-World example plugin
Minimum-viable plugin demonstrating the `@toolctrl/plugin-sdk` shape.
## Files
- `plugin.toml` — the manifest the host parses + verifies. The `wasmSha256`, `signature`, and `signedBy` fields are placeholders; the operator overwrites them after signing the compiled `.wasm` against a trusted Ed25519 keypair.
- `handler.ts` — the plugin source. Exports `handleHello`, the hook the manifest references via `[[hooks]] export = "handleHello"`.
## Build
The plugin build CLI is **deferred** to Phase 06 Task #23 (see `docs/improvement-plan/06-plugin-system/wasm-sandbox.md`). Until then, plugin authors compile to WASM with their preferred toolchain. Two practical paths today:
1. **AssemblyScript** — write the handler in AssemblyScript syntax and run `asc handler.ts -o plugin.wasm --optimize`.
2. **Rust** — port the handler to a `#[no_mangle]` extern function and build with `wasm-pack build --target nodejs`.
Both produce a `.wasm` binary the runtime can instantiate.
## Sign + install
Once the binary is built:
```bash
# Compute the hash and update the manifest in-place.
sha256sum plugin.wasm
# Sign the .wasm with the publisher's Ed25519 private key.
# (Sign helper for foundation: use Node's crypto.sign with no algorithm.)
node -e "const c=require('crypto'); const fs=require('fs'); \
const key=c.createPrivateKey({key: fs.readFileSync('publisher.priv'), format:'pem'}); \
process.stdout.write(c.sign(null, fs.readFileSync('plugin.wasm'), key).toString('hex'))"
# Drop the signed binary + manifest into the host's plugin volume.
cp plugin.wasm plugin.toml <plugin-volume>/<plugin-id>/1.0.0/
```
The operator then ensures the Ed25519 public key matching the signature is in `PLUGIN_TRUSTED_PUBLIC_KEYS` and inserts a row into the `Plugin` table.
@@ -0,0 +1,23 @@
/**
* Hello-World example plugin source.
*
* Plugin authors write TypeScript / AssemblyScript / Rust against the
* `@toolctrl/plugin-sdk` types, then compile to a single `.wasm` binary
* with their toolchain of choice (wasm-pack, AssemblyScript's `asc`,
* etc.). The host runtime treats the .wasm as opaque — only its
* exports (`handleHello` here) and the `plugin.toml` manifest matter.
*
* Building this file to WASM is intentionally outside the scope of
* the foundation: the `toolctrl plugin build` CLI is deferred (see
* `docs/improvement-plan/06-plugin-system/wasm-sandbox.md` Deviations).
* Operators wanting a working hello-world today can hand-compile via
* AssemblyScript or use any other WASM toolchain that produces a flat
* function export — the runtime contract is just `(arg) -> result`.
*/
import { defineHook } from "../../src/index.js";
export const handleHello = defineHook<{ name: string }, { greeting: string }>((input, ctx) => {
ctx.log.info({ name: input.name }, "hello-plugin.invoked");
return { greeting: `Hello, ${input.name}!` };
});
@@ -0,0 +1,28 @@
# Phase 06 #21 — Hello-World example plugin manifest.
#
# A plugin author ships exactly this file alongside the .wasm binary.
# The runtime parses it via @toolctrl/plugin-runtime's PluginManifestSchema
# (note: foundation supports both TOML and JSON shape — the schema works
# off the parsed object so authors may choose the format their toolchain
# emits). The values below are illustrative; the wasmSha256 / signature /
# signedBy fields are placeholders that an operator replaces after
# signing the actual binary with `toolctrl plugin sign` (deferred CLI,
# see Deviations in wasm-sandbox.md).
schemaVersion = 1
id = "de.toolctrl.hello"
version = "1.0.0"
name = "Hello World"
description = "Returns a greeting. Demonstrates the minimum viable plugin."
entry = "plugin.wasm"
wasmSha256 = "0000000000000000000000000000000000000000000000000000000000000000"
signature = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
signedBy = "0000000000000000000000000000000000000000000000000000000000000000"
allowedHosts = []
[[capabilities]]
name = "log"
[[hooks]]
name = "onHello"
export = "handleHello"
@@ -0,0 +1,25 @@
# simple-hook-listener
The minimum-viable plugin. Subscribes to one hook, logs when invoked,
returns. No external IO.
## What it demonstrates
- Manifest with exactly one capability (`log`)
- One hook subscription (`tenant.created`) and one export
- `defineHook` for compile-time-typed handler arguments
## How to run
1. Compile `handler.ts` to wasm with your toolchain of choice (see
`docs/plugins/cli-reference.md#build`).
2. `toolctrl-cli plugins sign --wasm plugin.wasm --manifest plugin.json --key <ed25519-priv>`.
3. `toolctrl-cli plugins publish --wasm plugin.wasm --manifest plugin.json` → upload the resulting `.tar.gz`.
4. Install through the tenant-admin plugins page.
5. Create a new tenant; observe the host log line.
## Pattern
Hook-only plugins are the safest plugin shape — they cannot leak data
or make outbound requests because they declared no such capabilities.
Start here when you only need to *react* to host events.
@@ -0,0 +1,25 @@
/**
* Simple hook listener — Phase 06 #23 example.
*
* One handler, one capability (`log`), no external IO. The host emits
* `tenant.created` whenever a new workspace is provisioned; this
* plugin records the event through the host logger. Useful for
* smoke-testing the full invoke pipeline without committing to any
* outbound capability.
*/
import { defineHook, type PluginHookTenantCreatedPayload } from "../../src/index.js";
export const handleTenantCreated = defineHook<
PluginHookTenantCreatedPayload,
{ logged: true }
>((input, ctx) => {
ctx.log.info(
{
tenantId: input.tenantId,
tenantSlug: input.slug,
plugin: "simple-hook-listener",
},
"simple-hook-listener received tenant.created"
);
return { logged: true };
});
@@ -0,0 +1,25 @@
# Phase 06 #23 example — simple hook listener.
#
# Demonstrates the minimum-viable hook subscription: declare ONE hook
# in the manifest, register ONE export, log when invoked. Requires only
# the `log` capability — nothing leaves the host. A new plugin author
# can copy this directory, rename, and have a working invocation
# pipeline in minutes.
schemaVersion = 1
id = "de.toolctrl.examples.simple-hook-listener"
version = "0.1.0"
name = "Simple Hook Listener"
description = "Logs tenant.created events. Demonstrates the smallest viable plugin."
entry = "plugin.wasm"
wasmSha256 = "0000000000000000000000000000000000000000000000000000000000000000"
signature = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
signedBy = "0000000000000000000000000000000000000000000000000000000000000000"
allowedHosts = []
[[capabilities]]
name = "log"
[[hooks]]
name = "tenant.created"
export = "handleTenantCreated"
@@ -0,0 +1,20 @@
{
"name": "@toolctrl/plugin-sdk",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts"
},
"scripts": {
"build": "pnpm run typecheck",
"test": "vitest run src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/node": "^25.0.0",
"typescript": "^5.3.2",
"vitest": "^3.2.4"
}
}
@@ -0,0 +1,461 @@
import { describe, expect, expectTypeOf, test } from "vitest";
import {
createPluginSettingsClient,
defineHook,
type HookHandler,
type PluginContext,
type PluginHookEntityPayload,
type PluginHookPayloads,
type PluginHookWorkflowDefinitionPayload,
type PluginHookWorkflowExecutedPayload,
type PluginHookWorkflowFailedPayload,
type PluginSettingsClientMessageEvent,
type PluginSettingsClientMessageTarget,
PluginSettingsClientError,
PluginSettingsClientTimeoutError,
type PluginSettingsFrameGetMessage,
type PluginSettingsFrameMessage,
type PluginSettingsFrameReadyMessage,
type PluginSettingsFrameResultMessage,
type PluginSettingsFrameSetMessage,
type TypedHookHandler,
PLUGIN_PAYLOAD_ABI_IMPORTS,
PLUGIN_PAYLOAD_ABI_VERSION,
PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME,
PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
PLUGIN_SDK_PACKAGE_VERSION,
} from "./index.js";
function makeContext(overrides: Partial<PluginContext> = {}): PluginContext {
const log = {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return {
pluginId: "de.toolctrl.test",
pluginVersion: "1.0.0",
tenantId: "t-1",
hookName: "ping",
capabilities: ["log"],
log,
audit: { record: async () => undefined },
...overrides,
};
}
function makeSettingsFrameWindow() {
const posted: Array<{ message: PluginSettingsFrameMessage; targetOrigin: string }> = [];
const listeners = new Set<(event: PluginSettingsClientMessageEvent) => void>();
const parent: PluginSettingsClientMessageTarget = {
postMessage: (message, targetOrigin) => {
posted.push({ message, targetOrigin });
},
};
return {
parent,
posted,
emit: (event: PluginSettingsClientMessageEvent) => {
for (const listener of listeners) {
listener(event);
}
},
listenerCount: () => listeners.size,
addEventListener: (type: "message", listener: (event: PluginSettingsClientMessageEvent) => void) => {
expect(type).toBe("message");
listeners.add(listener);
},
removeEventListener: (type: "message", listener: (event: PluginSettingsClientMessageEvent) => void) => {
expect(type).toBe("message");
listeners.delete(listener);
},
};
}
describe("@toolctrl/plugin-sdk", () => {
test("exports a stable version constant", () => {
expect(PLUGIN_SDK_PACKAGE_VERSION).toBe("0.0.0");
});
test("exports the structured payload ABI contract", () => {
expect(PLUGIN_PAYLOAD_ABI_VERSION).toBe(1);
expect(PLUGIN_PAYLOAD_ABI_IMPORTS).toEqual(["env.toolctrl_payload_len", "env.toolctrl_payload_byte"]);
});
test("exports the settings iframe postMessage protocol contract", () => {
expect(PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION).toBe(1);
expect(PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME).toBe("plugin-settings-channel");
const getMessage: PluginSettingsFrameGetMessage = {
type: "plugin.settings.get",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "get-1",
key: "theme",
};
const setMessage: PluginSettingsFrameSetMessage = {
type: "plugin.settings.set",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "set-1",
key: "theme",
value: { mode: "dark", compact: true },
};
const resultMessage: PluginSettingsFrameResultMessage = {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "get-1",
ok: true,
key: "theme",
value: "dark",
};
expectTypeOf<PluginSettingsFrameMessage>().toEqualTypeOf<
PluginSettingsFrameReadyMessage | typeof getMessage | typeof setMessage
>();
expectTypeOf<PluginSettingsFrameResultMessage>().toEqualTypeOf<typeof resultMessage | Extract<PluginSettingsFrameResultMessage, { ok: false }>>();
expect(getMessage.type).toBe("plugin.settings.get");
expect(setMessage.value).toEqual({ mode: "dark", compact: true });
expect(resultMessage.ok).toBe(true);
});
test("createPluginSettingsClient posts ready, get, and set messages", async () => {
const frameWindow = makeSettingsFrameWindow();
const client = createPluginSettingsClient({
pluginId: "de.toolctrl.test",
channelId: "channel-1",
targetOrigin: "https://app.example.test",
requestId: () => "request-1",
window: frameWindow,
});
client.ready({ requestId: "ready-1" });
const getPromise = client.get("theme");
const setPromise = client.set("mode", "dark", { requestId: "set-1" });
expect(frameWindow.posted).toEqual([
{
targetOrigin: "https://app.example.test",
message: {
type: "plugin.settings.ready",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "ready-1",
},
},
{
targetOrigin: "https://app.example.test",
message: {
type: "plugin.settings.get",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "request-1",
key: "theme",
},
},
{
targetOrigin: "https://app.example.test",
message: {
type: "plugin.settings.set",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "set-1",
key: "mode",
value: "dark",
},
},
]);
frameWindow.emit({
source: frameWindow.parent,
data: {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "request-1",
ok: true,
key: "theme",
value: { mode: "dark" },
},
});
frameWindow.emit({
source: frameWindow.parent,
data: {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "set-1",
ok: true,
key: "mode",
value: "dark",
},
});
await expect(getPromise).resolves.toEqual({ mode: "dark" });
await expect(setPromise).resolves.toBe("dark");
client.destroy();
expect(frameWindow.listenerCount()).toBe(0);
});
test("createPluginSettingsClient reads the injected channel meta tag", () => {
const frameWindow = makeSettingsFrameWindow();
const client = createPluginSettingsClient({
pluginId: "de.toolctrl.test",
window: frameWindow,
document: {
querySelector: (selector) => {
expect(selector).toBe(`meta[name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}"]`);
return { getAttribute: () => "channel-from-meta" };
},
},
});
expect(client.channelId).toBe("channel-from-meta");
client.destroy();
});
test("createPluginSettingsClient ignores result messages that do not match the parent and request scope", async () => {
const frameWindow = makeSettingsFrameWindow();
const client = createPluginSettingsClient({
pluginId: "de.toolctrl.test",
channelId: "channel-1",
timeoutMs: 0,
window: frameWindow,
});
const promise = client.get("theme", { requestId: "get-1" });
for (const data of [
{ version: 2, pluginId: "de.toolctrl.test", channelId: "channel-1", requestId: "get-1" },
{ version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION, pluginId: "other", channelId: "channel-1", requestId: "get-1" },
{ version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION, pluginId: "de.toolctrl.test", channelId: "other", requestId: "get-1" },
{ version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION, pluginId: "de.toolctrl.test", channelId: "channel-1", requestId: "other" },
]) {
frameWindow.emit({
source: frameWindow.parent,
data: {
type: "plugin.settings.result",
ok: true,
key: "theme",
value: "spoofed",
...data,
},
});
}
frameWindow.emit({
source: { postMessage: () => undefined },
data: {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "get-1",
ok: true,
key: "theme",
value: "spoofed-source",
},
});
frameWindow.emit({
source: frameWindow.parent,
data: {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "get-1",
ok: true,
key: "theme",
value: "dark",
},
});
await expect(promise).resolves.toBe("dark");
client.destroy();
});
test("createPluginSettingsClient rejects host errors and mismatched response keys", async () => {
const frameWindow = makeSettingsFrameWindow();
const client = createPluginSettingsClient({
pluginId: "de.toolctrl.test",
channelId: "channel-1",
timeoutMs: 0,
window: frameWindow,
});
const failed = client.set("theme", "dark", { requestId: "set-1" });
frameWindow.emit({
source: frameWindow.parent,
data: {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "set-1",
ok: false,
error: "settings_update_failed",
message: "Write failed",
},
});
await expect(failed).rejects.toBeInstanceOf(PluginSettingsClientError);
await expect(failed).rejects.toMatchObject({ code: "settings_update_failed", message: "Write failed" });
const mismatched = client.get("theme", { requestId: "get-1" });
frameWindow.emit({
source: frameWindow.parent,
data: {
type: "plugin.settings.result",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId: "de.toolctrl.test",
channelId: "channel-1",
requestId: "get-1",
ok: true,
key: "other",
value: "dark",
},
});
await expect(mismatched).rejects.toThrow("Plugin settings response key mismatch");
client.destroy();
});
test("createPluginSettingsClient rejects timed out requests", async () => {
const frameWindow = makeSettingsFrameWindow();
const client = createPluginSettingsClient({
pluginId: "de.toolctrl.test",
channelId: "channel-1",
timeoutMs: 1,
window: frameWindow,
});
await expect(client.get("theme", { requestId: "get-1" })).rejects.toBeInstanceOf(PluginSettingsClientTimeoutError);
client.destroy();
});
test("defineHook preserves the typed handler", async () => {
const handler = defineHook<{ name: string }, { greeting: string }>((input) => ({
greeting: `Hello, ${input.name}!`,
}));
const result = await handler({ name: "World" }, makeContext());
expect(result.greeting).toBe("Hello, World!");
});
test("HookHandler accepts async handlers", async () => {
const handler: HookHandler<number, number> = async (n) => n * 2;
expect(await handler(21, makeContext())).toBe(42);
});
test("TypedHookHandler exposes canonical entity hook payloads", async () => {
expectTypeOf<PluginHookPayloads["entity.updated"]>().toEqualTypeOf<PluginHookEntityPayload>();
const handler: TypedHookHandler<"entity.updated", { target: string }> = (input) => ({
target: `${input.entityType}:${input.entityId}`,
});
const result = await handler(
{
entityType: "contact",
entityId: "row_1",
tenantId: "tenant_1",
userId: "user_1",
changedFields: ["email"],
occurredAt: "2026-05-27T12:00:00.000Z",
},
makeContext({ hookName: "entity.updated" })
);
expect(result).toEqual({ target: "contact:row_1" });
});
test("TypedHookHandler exposes workflow.executed metadata without raw execution bodies", async () => {
expectTypeOf<PluginHookPayloads["workflow.executed"]>().toEqualTypeOf<PluginHookWorkflowExecutedPayload>();
expectTypeOf<PluginHookWorkflowExecutedPayload>().not.toHaveProperty("input");
expectTypeOf<PluginHookWorkflowExecutedPayload>().not.toHaveProperty("output");
expectTypeOf<PluginHookWorkflowExecutedPayload>().not.toHaveProperty("error");
const handler: TypedHookHandler<"workflow.executed", { terminal: true }> = (input) => {
expect(input.status).toBe("success");
expect(input.errorCode).toBeNull();
return { terminal: true };
};
const result = await handler(
{
tenantId: "tenant_1",
workflowId: "workflow_1",
workflowExecutionId: "execution_1",
userId: "user_1",
type: "trigger",
status: "success",
durationMs: 42,
errorCode: null,
appliesToAllTenants: false,
occurredAt: "2026-05-27T12:00:00.000Z",
},
makeContext({ hookName: "workflow.executed" })
);
expect(result).toEqual({ terminal: true });
});
test("TypedHookHandler exposes workflow.failed failure-only metadata", async () => {
expectTypeOf<PluginHookPayloads["workflow.failed"]>().toEqualTypeOf<PluginHookWorkflowFailedPayload>();
expectTypeOf<PluginHookWorkflowFailedPayload>().not.toHaveProperty("input");
expectTypeOf<PluginHookWorkflowFailedPayload>().not.toHaveProperty("output");
expectTypeOf<PluginHookWorkflowFailedPayload>().not.toHaveProperty("error");
const handler: TypedHookHandler<"workflow.failed", { failureCode: string }> = (input) => ({
failureCode: input.errorCode,
});
const result = await handler(
{
tenantId: "tenant_1",
workflowId: "workflow_1",
workflowExecutionId: "execution_1",
userId: null,
type: "trigger",
status: "error",
durationMs: 42,
errorCode: "workflow_execution_failed",
appliesToAllTenants: false,
occurredAt: "2026-05-27T12:00:00.000Z",
},
makeContext({ hookName: "workflow.failed" })
);
expect(result).toEqual({ failureCode: "workflow_execution_failed" });
});
test("TypedHookHandler exposes workflow definition metadata without workflow bodies", async () => {
expectTypeOf<PluginHookPayloads["workflow.updated"]>().toEqualTypeOf<PluginHookWorkflowDefinitionPayload>();
expectTypeOf<PluginHookWorkflowDefinitionPayload>().not.toHaveProperty("blocks");
expectTypeOf<PluginHookWorkflowDefinitionPayload>().not.toHaveProperty("credentials");
expectTypeOf<PluginHookWorkflowDefinitionPayload>().not.toHaveProperty("input");
const handler: TypedHookHandler<"workflow.updated", { changed: number }> = (input) => ({
changed: input.changedFields?.length ?? 0,
});
const result = await handler(
{
tenantId: "tenant_1",
workflowId: "workflow_1",
userId: "user_1",
status: "live",
appliesToAllTenants: false,
changedFields: ["status"],
occurredAt: "2026-05-27T12:00:00.000Z",
},
makeContext({ hookName: "workflow.updated" })
);
expect(result).toEqual({ changed: 1 });
});
});
@@ -0,0 +1,483 @@
/**
* @toolctrl/plugin-sdk — Phase 06 Task #21 foundation.
*
* Type definitions plugin authors `import` against. The package ships
* type-only declarations in this foundation step; the AssemblyScript /
* Rust toolchain bindings that resolve to actual WASM imports are
* deferred to Phase 06 Task #23 (audit + docs).
*
* Authoring shape:
*
* import type { HookHandler, PluginContext } from "@toolctrl/plugin-sdk";
*
* export const handleHello: HookHandler<{ name: string }, { greeting: string }> = (
* input,
* ctx,
* ) => {
* ctx.log.info({ name: input.name }, "hello");
* return { greeting: `Hello, ${input.name}!` };
* };
*/
export const PLUGIN_SDK_PACKAGE_VERSION = "0.0.0";
export const PLUGIN_PAYLOAD_ABI_VERSION = 1;
export const PLUGIN_PAYLOAD_ABI_IMPORTS = ["env.toolctrl_payload_len", "env.toolctrl_payload_byte"] as const;
export const PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION = 1;
export const PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME = "plugin-settings-channel";
/**
* Capability names a host exposes to a plugin — mirrors the closed enum
* in `@toolctrl/plugin-runtime`. Kept duplicated here so plugin authors
* don't need to install the runtime package as a build-time dep.
*/
export type PluginCapabilityName =
| "http.outbound"
| "db.read"
| "db.write"
| "storage.read"
| "storage.write"
| "log"
| "audit"
| "tenant.info"
| "secret.read";
/**
* Foundation hook names the host can emit. Mirrors the runtime hook
* registry so plugin authors can type their handlers without importing
* `@toolctrl/plugin-runtime`.
*/
export type PluginHookName =
| "tenant.created"
| "user.created"
| "entity.created"
| "entity.updated"
| "entity.deleted"
| "workflow.created"
| "workflow.updated"
| "workflow.deleted"
| "workflow.executed"
| "workflow.failed";
export interface PluginHookTenantCreatedPayload {
readonly tenantId: string;
readonly slug: string;
readonly ownerEmail?: string | null;
readonly plan?: string | null;
readonly createdAt: string;
}
export interface PluginHookUserCreatedPayload {
readonly userId: string;
readonly tenantId?: string | null;
readonly email: string;
readonly locale?: string | null;
readonly createdAt: string;
}
export interface PluginHookEntityPayload {
readonly entityType: string;
readonly entityId: string;
readonly tenantId: string | null;
readonly userId?: string | null;
readonly changedFields?: ReadonlyArray<string>;
readonly occurredAt: string;
}
export interface PluginHookWorkflowExecutedPayload {
readonly tenantId: string;
readonly workflowId: string;
readonly workflowExecutionId: string;
readonly userId?: string | null;
readonly type: "manual" | "api" | "stream" | "trigger";
readonly status: "success" | "error";
readonly durationMs: number;
readonly errorCode: string | null;
readonly appliesToAllTenants: boolean;
readonly occurredAt: string;
}
export interface PluginHookWorkflowFailedPayload extends Omit<PluginHookWorkflowExecutedPayload, "status" | "errorCode"> {
readonly status: "error";
readonly errorCode: string;
}
export interface PluginHookWorkflowDefinitionPayload {
readonly tenantId: string | null;
readonly workflowId: string;
readonly userId: string | null;
readonly status: "draft" | "live" | "archived";
readonly appliesToAllTenants: boolean;
readonly changedFields?: ReadonlyArray<string>;
readonly occurredAt: string;
}
export type PluginHookPayloads = {
readonly "tenant.created": PluginHookTenantCreatedPayload;
readonly "user.created": PluginHookUserCreatedPayload;
readonly "entity.created": PluginHookEntityPayload;
readonly "entity.updated": PluginHookEntityPayload;
readonly "entity.deleted": PluginHookEntityPayload;
readonly "workflow.created": PluginHookWorkflowDefinitionPayload;
readonly "workflow.updated": PluginHookWorkflowDefinitionPayload;
readonly "workflow.deleted": PluginHookWorkflowDefinitionPayload;
readonly "workflow.executed": PluginHookWorkflowExecutedPayload;
readonly "workflow.failed": PluginHookWorkflowFailedPayload;
};
/** Subset of pino's logger surface the host exposes via the `log` cap. */
export interface PluginLogger {
info(fields: Record<string, unknown>, msg: string): void;
warn(fields: Record<string, unknown>, msg: string): void;
error(fields: Record<string, unknown>, msg: string): void;
}
/** HTTP host binding — gated on the `http.outbound` capability. */
export interface PluginHttpClient {
fetch(input: { url: string; method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
headers: Record<string, string>;
body: string;
}>;
}
/** Tenant-scoped KV store — gated on `storage.read` / `storage.write`. */
export interface PluginStorage {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: { ttlSeconds?: number }): Promise<void>;
}
/** Audit-event sink — always allowed. */
export interface PluginAudit {
record(input: { action: string; target?: string; meta?: Record<string, unknown> }): Promise<void>;
}
/** Read-only tenant metadata — always allowed. */
export interface PluginTenantInfo {
id: string;
slug: string;
plan: string;
}
/**
* Aggregate host API surface, partial because each plugin only sees
* the bindings whose capabilities it declared in `plugin.toml`.
*/
export interface HostApi {
log: PluginLogger;
audit: PluginAudit;
tenant?: PluginTenantInfo;
http?: PluginHttpClient;
storage?: PluginStorage;
secret?: { get(key: string): Promise<string | null> };
}
export type PluginSettingValue = null | boolean | number | string | ReadonlyArray<PluginSettingValue> | { readonly [key: string]: PluginSettingValue };
export type PluginSettingsFrameReadyMessage = {
readonly type: "plugin.settings.ready";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId?: string;
};
export type PluginSettingsFrameGetMessage = {
readonly type: "plugin.settings.get";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly key: string;
};
export type PluginSettingsFrameSetMessage = {
readonly type: "plugin.settings.set";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly key: string;
readonly value: PluginSettingValue;
};
export type PluginSettingsFrameMessage = PluginSettingsFrameReadyMessage | PluginSettingsFrameGetMessage | PluginSettingsFrameSetMessage;
export type PluginSettingsFrameResultMessage =
| {
readonly type: "plugin.settings.result";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly ok: true;
readonly key: string;
readonly value: PluginSettingValue;
}
| {
readonly type: "plugin.settings.result";
readonly version: typeof PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION;
readonly pluginId: string;
readonly channelId: string;
readonly requestId: string;
readonly ok: false;
readonly error: "invalid_payload" | "settings_unavailable" | "settings_update_failed";
readonly message: string;
};
export interface PluginSettingsClientMessageTarget {
postMessage(message: PluginSettingsFrameMessage, targetOrigin: string): void;
}
export interface PluginSettingsClientMessageEvent {
readonly source: unknown;
readonly data: unknown;
}
export interface PluginSettingsClientWindow {
readonly parent: PluginSettingsClientMessageTarget;
addEventListener(type: "message", listener: (event: PluginSettingsClientMessageEvent) => void): void;
removeEventListener(type: "message", listener: (event: PluginSettingsClientMessageEvent) => void): void;
}
export interface PluginSettingsClientDocument {
querySelector(selector: string): { getAttribute(name: string): string | null } | null;
}
export interface PluginSettingsClientRequestOptions {
readonly requestId?: string;
readonly timeoutMs?: number;
}
export interface PluginSettingsClientOptions {
readonly pluginId: string;
readonly channelId?: string;
readonly targetOrigin?: string;
readonly timeoutMs?: number;
readonly requestId?: () => string;
readonly window?: PluginSettingsClientWindow;
readonly document?: PluginSettingsClientDocument;
}
export interface PluginSettingsClient {
readonly pluginId: string;
readonly channelId: string;
ready(options?: { readonly requestId?: string }): void;
get(key: string, options?: PluginSettingsClientRequestOptions): Promise<PluginSettingValue>;
set(key: string, value: PluginSettingValue, options?: PluginSettingsClientRequestOptions): Promise<PluginSettingValue>;
destroy(): void;
}
type PluginSettingsClientPendingRequest = {
readonly key: string;
readonly timeout: ReturnType<typeof setTimeout> | null;
resolve(value: PluginSettingValue): void;
reject(error: Error): void;
};
export class PluginSettingsClientError extends Error {
readonly code: PluginSettingsFrameResultMessage extends infer Message
? Message extends { readonly ok: false; readonly error: infer Code }
? Code
: never
: never;
constructor(code: PluginSettingsClientError["code"], message: string) {
super(message);
this.name = "PluginSettingsClientError";
this.code = code;
}
}
export class PluginSettingsClientTimeoutError extends Error {
constructor(requestId: string) {
super(`Plugin settings request timed out: ${requestId}`);
this.name = "PluginSettingsClientTimeoutError";
}
}
function getDefaultPluginSettingsWindow(): PluginSettingsClientWindow | undefined {
const maybeWindow = (globalThis as { window?: PluginSettingsClientWindow }).window;
return maybeWindow?.parent && typeof maybeWindow.addEventListener === "function" && typeof maybeWindow.removeEventListener === "function"
? maybeWindow
: undefined;
}
function getDefaultPluginSettingsDocument(): PluginSettingsClientDocument | undefined {
const maybeDocument = (globalThis as { document?: PluginSettingsClientDocument }).document;
return maybeDocument && typeof maybeDocument.querySelector === "function" ? maybeDocument : undefined;
}
function readPluginSettingsChannelId(documentRef: PluginSettingsClientDocument | undefined): string | undefined {
return documentRef?.querySelector(`meta[name="${PLUGIN_SETTINGS_FRAME_CHANNEL_META_NAME}"]`)?.getAttribute("content") ?? undefined;
}
function isPluginSettingsFrameResultMessage(value: unknown): value is PluginSettingsFrameResultMessage {
return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "plugin.settings.result";
}
/**
* Build a small settings iframe client around the host postMessage protocol.
* Responses are accepted only from `window.parent` and only when protocol
* version, plugin id, channel id, and request id match a pending request.
*/
export function createPluginSettingsClient(options: PluginSettingsClientOptions): PluginSettingsClient {
const frameWindow = options.window ?? getDefaultPluginSettingsWindow();
if (!frameWindow) {
throw new Error("Plugin settings client requires a window with a parent postMessage target");
}
const pluginId = options.pluginId;
const channelId = options.channelId ?? readPluginSettingsChannelId(options.document ?? getDefaultPluginSettingsDocument());
if (!channelId) {
throw new Error("Missing plugin settings channel");
}
const targetOrigin = options.targetOrigin ?? "*";
const defaultTimeoutMs = options.timeoutMs ?? 10_000;
const nextRequestId =
options.requestId ??
(() => {
let next = 0;
return () => `plugin-settings-${++next}`;
})();
const pending = new Map<string, PluginSettingsClientPendingRequest>();
const onMessage = (event: PluginSettingsClientMessageEvent) => {
if (event.source !== frameWindow.parent || !isPluginSettingsFrameResultMessage(event.data)) {
return;
}
const message = event.data;
if (message.version !== PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION || message.pluginId !== pluginId || message.channelId !== channelId) {
return;
}
const request = pending.get(message.requestId);
if (!request) {
return;
}
pending.delete(message.requestId);
if (request.timeout) {
clearTimeout(request.timeout);
}
if (message.ok) {
if (message.key !== request.key) {
request.reject(new Error(`Plugin settings response key mismatch for request: ${message.requestId}`));
return;
}
request.resolve(message.value);
return;
}
request.reject(new PluginSettingsClientError(message.error, message.message));
};
frameWindow.addEventListener("message", onMessage);
const post = (message: PluginSettingsFrameMessage) => {
frameWindow.parent.postMessage(message, targetOrigin);
};
const request = (
type: "plugin.settings.get" | "plugin.settings.set",
key: string,
value: PluginSettingValue | undefined,
requestOptions?: PluginSettingsClientRequestOptions
) => {
const requestId = requestOptions?.requestId ?? nextRequestId();
const timeoutMs = requestOptions?.timeoutMs ?? defaultTimeoutMs;
return new Promise<PluginSettingValue>((resolve, reject) => {
const timeout =
timeoutMs > 0
? setTimeout(() => {
pending.delete(requestId);
reject(new PluginSettingsClientTimeoutError(requestId));
}, timeoutMs)
: null;
pending.set(requestId, { key, timeout, resolve, reject });
post(
type === "plugin.settings.get"
? {
type,
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
requestId,
key,
}
: {
type,
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
requestId,
key,
value: value as PluginSettingValue,
}
);
});
};
return {
pluginId,
channelId,
ready: (readyOptions) =>
post({
type: "plugin.settings.ready",
version: PLUGIN_SETTINGS_FRAME_PROTOCOL_VERSION,
pluginId,
channelId,
requestId: readyOptions?.requestId,
}),
get: (key, requestOptions) => request("plugin.settings.get", key, undefined, requestOptions),
set: (key, value, requestOptions) => request("plugin.settings.set", key, value, requestOptions),
destroy: () => {
frameWindow.removeEventListener("message", onMessage);
for (const [requestId, pendingRequest] of pending) {
if (pendingRequest.timeout) {
clearTimeout(pendingRequest.timeout);
}
pendingRequest.reject(new Error(`Plugin settings client destroyed before response: ${requestId}`));
}
pending.clear();
},
};
}
/**
* Per-invocation context: bound capabilities + invocation metadata.
*/
export interface PluginContext {
pluginId: string;
pluginVersion: string;
tenantId: string;
hookName: string;
capabilities: ReadonlyArray<PluginCapabilityName>;
log: PluginLogger;
audit: PluginAudit;
tenant?: PluginTenantInfo;
http?: PluginHttpClient;
storage?: PluginStorage;
secret?: { get(key: string): Promise<string | null> };
}
/** A hook handler — synchronous or promise-returning. */
export interface HookHandler<Input = unknown, Output = unknown> {
(input: Input, ctx: PluginContext): Output | Promise<Output>;
}
export type TypedHookHandler<Name extends PluginHookName, Output = unknown> = HookHandler<PluginHookPayloads[Name], Output>;
/**
* Helper to brand a function as a typed hook handler — `defineHook`
* exists so authors can rely on inference of `Input` and `Output`
* without restating the type at the call site.
*/
export function defineHook<Input, Output>(handler: HookHandler<Input, Output>): HookHandler<Input, Output> {
return handler;
}
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}