484 lines
16 KiB
TypeScript
484 lines
16 KiB
TypeScript
/**
|
|
* @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;
|
|
}
|