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,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"]
|
||||
}
|
||||
Reference in new issue
Block a user