/** * @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; 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 { 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; 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, msg: string): void; warn(fields: Record, msg: string): void; error(fields: Record, 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; body?: string }): Promise<{ status: number; headers: Record; body: string; }>; } /** Tenant-scoped KV store — gated on `storage.read` / `storage.write`. */ export interface PluginStorage { get(key: string): Promise; put(key: string, value: string, opts?: { ttlSeconds?: number }): Promise; } /** Audit-event sink — always allowed. */ export interface PluginAudit { record(input: { action: string; target?: string; meta?: Record }): Promise; } /** 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 }; } export type PluginSettingValue = null | boolean | number | string | ReadonlyArray | { 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; set(key: string, value: PluginSettingValue, options?: PluginSettingsClientRequestOptions): Promise; destroy(): void; } type PluginSettingsClientPendingRequest = { readonly key: string; readonly timeout: ReturnType | 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(); 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((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; log: PluginLogger; audit: PluginAudit; tenant?: PluginTenantInfo; http?: PluginHttpClient; storage?: PluginStorage; secret?: { get(key: string): Promise }; } /** A hook handler — synchronous or promise-returning. */ export interface HookHandler { (input: Input, ctx: PluginContext): Output | Promise; } export type TypedHookHandler = HookHandler; /** * 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(handler: HookHandler): HookHandler { return handler; }