/** * @fileoverview Plugin management REST API routes * * This module provides Express routes for managing the complete plugin lifecycle: * - Listing and filtering plugins by status * - Installing plugins from npm or local paths * - Uninstalling plugins (soft delete or hard purge) * - Enabling/disabling plugins * - Running health diagnostics * - Upgrading plugins * - Retrieving UI slot contributions for frontend rendering * - Discovering and executing plugin-contributed agent tools * * All routes require board-level authentication (assertBoard middleware). * * @module server/routes/plugins * @see doc/plugins/PLUGIN_SPEC.md for the full plugin specification */ import { existsSync } from "node:fs"; import path from "node:path"; import { randomUUID } from "node:crypto"; import { fileURLToPath } from "node:url"; import { Router } from "express"; import type { Request } from "express"; import { and, desc, eq, gte } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { companies, pluginLogs, pluginWebhookDeliveries } from "@paperclipai/db"; import type { PluginStatus, PaperclipPluginManifestV1, PluginBridgeErrorCode, PluginLauncherRenderContextSnapshot, } from "@paperclipai/shared"; import { PLUGIN_STATUSES, } from "@paperclipai/shared"; import { pluginRegistryService } from "../services/plugin-registry.js"; import { pluginLifecycleManager } from "../services/plugin-lifecycle.js"; import { getPluginUiContributionMetadata, pluginLoader } from "../services/plugin-loader.js"; import { logActivity } from "../services/activity-log.js"; import { publishGlobalLiveEvent } from "../services/live-events.js"; import type { PluginJobScheduler } from "../services/plugin-job-scheduler.js"; import type { PluginJobStore } from "../services/plugin-job-store.js"; import type { PluginWorkerManager } from "../services/plugin-worker-manager.js"; import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */ type PluginUiSlotDeclaration = NonNullable["slots"]>[number]; /** Launcher declaration extracted from plugin manifest */ type PluginLauncherDeclaration = NonNullable[number]; /** * Normalized UI contribution for frontend slot host consumption. * Only includes plugins in 'ready' state with non-empty slot declarations. */ type PluginUiContribution = { pluginId: string; pluginKey: string; displayName: string; version: string; updatedAt: string; /** * Relative path within the plugin's UI directory to the entry module * (e.g. `"index.js"`). The frontend constructs the full import URL as * `/_plugins/${pluginId}/ui/${uiEntryFile}`. */ uiEntryFile: string; slots: PluginUiSlotDeclaration[]; launchers: PluginLauncherDeclaration[]; }; /** Request body for POST /api/plugins/install */ interface PluginInstallRequest { /** npm package name (e.g., @paperclip/plugin-linear) or local path */ packageName: string; /** Target version for npm packages (optional, defaults to latest) */ version?: string; /** True if packageName is a local filesystem path */ isLocalPath?: boolean; } interface AvailablePluginExample { packageName: string; pluginKey: string; displayName: string; description: string; localPath: string; tag: "example"; } /** Response body for GET /api/plugins/:pluginId/health */ interface PluginHealthCheckResult { pluginId: string; status: string; healthy: boolean; checks: Array<{ name: string; passed: boolean; message?: string; }>; lastError?: string; } /** UUID v4 regex used for plugin ID route resolution. */ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, "../../.."); const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ { packageName: "@paperclipai/plugin-hello-world-example", pluginKey: "paperclip.hello-world-example", displayName: "Hello World Widget (Example)", description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.", localPath: "packages/plugins/examples/plugin-hello-world-example", tag: "example", }, { packageName: "@paperclipai/plugin-file-browser-example", pluginKey: "paperclip-file-browser-example", displayName: "File Browser (Example)", description: "Example plugin that adds a Files link in project navigation plus a project detail file browser.", localPath: "packages/plugins/examples/plugin-file-browser-example", tag: "example", }, { packageName: "@paperclipai/plugin-kitchen-sink-example", pluginKey: "paperclip-kitchen-sink-example", displayName: "Kitchen Sink (Example)", description: "Reference plugin that demonstrates the current Paperclip plugin API surface, bridge flows, UI extension surfaces, jobs, webhooks, tools, streams, and trusted local workspace/process demos.", localPath: "packages/plugins/examples/plugin-kitchen-sink-example", tag: "example", }, ]; function listBundledPluginExamples(): AvailablePluginExample[] { return BUNDLED_PLUGIN_EXAMPLES.flatMap((plugin) => { const absoluteLocalPath = path.resolve(REPO_ROOT, plugin.localPath); if (!existsSync(absoluteLocalPath)) return []; return [{ ...plugin, localPath: absoluteLocalPath }]; }); } /** * Resolve a plugin by either database ID or plugin key. * * Lookup order: * - UUID-like IDs: getById first, then getByKey. * - Scoped package keys (e.g. "@scope/name"): getByKey only, never getById. * - Other non-UUID IDs: try getById first (test/memory registries may allow this), * then fallback to getByKey. Any UUID parse error from getById is ignored. * * @param registry - The plugin registry service instance * @param pluginId - Either a database UUID or plugin key (manifest id) * @returns Plugin record or null if not found */ async function resolvePlugin( registry: ReturnType, pluginId: string, ) { const isUuid = UUID_REGEX.test(pluginId); const isScopedPackageKey = pluginId.startsWith("@") || pluginId.includes("/"); // Scoped package IDs are valid plugin keys but invalid UUIDs. // Skip getById() entirely to avoid Postgres uuid parse errors. if (isScopedPackageKey && !isUuid) { return registry.getByKey(pluginId); } try { const byId = await registry.getById(pluginId); if (byId) return byId; } catch (error) { const maybeCode = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; // Ignore invalid UUID cast errors and continue with key lookup. if (maybeCode !== "22P02") { throw error; } } return registry.getByKey(pluginId); } /** * Optional dependencies for plugin job scheduling routes. * * When provided, job-related routes (list jobs, list runs, trigger job) are * mounted. When omitted, the routes return 501 Not Implemented. */ export interface PluginRouteJobDeps { /** The job scheduler instance. */ scheduler: PluginJobScheduler; /** The job persistence store. */ jobStore: PluginJobStore; } /** * Optional dependencies for plugin webhook routes. * * When provided, the webhook ingestion route is enabled. When omitted, * webhook POST requests return 501 Not Implemented. */ export interface PluginRouteWebhookDeps { /** The worker manager for dispatching handleWebhook RPC calls. */ workerManager: PluginWorkerManager; } /** * Optional dependencies for plugin tool routes. * * When provided, tool discovery and execution routes are enabled. * When omitted, the tool routes return 501 Not Implemented. */ export interface PluginRouteToolDeps { /** The tool dispatcher for listing and executing plugin tools. */ toolDispatcher: PluginToolDispatcher; } /** * Optional dependencies for plugin UI bridge routes. * * When provided, the getData and performAction bridge proxy routes are enabled, * allowing plugin UI components to communicate with their worker backend via * `usePluginData()` and `usePluginAction()` hooks. * * @see PLUGIN_SPEC.md §13.8 — `getData` * @see PLUGIN_SPEC.md §13.9 — `performAction` * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ export interface PluginRouteBridgeDeps { /** The worker manager for dispatching getData/performAction RPC calls. */ workerManager: PluginWorkerManager; /** Optional stream bus for SSE push from worker to UI. */ streamBus?: PluginStreamBus; } /** Request body for POST /api/plugins/tools/execute */ interface PluginToolExecuteRequest { /** Fully namespaced tool name (e.g., "acme.linear:search-issues"). */ tool: string; /** Parameters matching the tool's declared JSON Schema. */ parameters?: unknown; /** Agent run context. */ runContext: ToolRunContext; } /** * Create Express router for plugin management API. * * Routes provided: * * | Method | Path | Description | * |--------|------|-------------| * | GET | /plugins | List all plugins (optional ?status= filter) | * | GET | /plugins/ui-contributions | Get UI slots from ready plugins | * | GET | /plugins/:pluginId | Get single plugin by ID or key | * | POST | /plugins/install | Install from npm or local path | * | DELETE | /plugins/:pluginId | Uninstall (optional ?purge=true) | * | POST | /plugins/:pluginId/enable | Enable a plugin | * | POST | /plugins/:pluginId/disable | Disable a plugin | * | GET | /plugins/:pluginId/health | Run health diagnostics | * | POST | /plugins/:pluginId/upgrade | Upgrade to newer version | * | GET | /plugins/:pluginId/jobs | List jobs for a plugin | * | GET | /plugins/:pluginId/jobs/:jobId/runs | List runs for a job | * | POST | /plugins/:pluginId/jobs/:jobId/trigger | Manually trigger a job | * | POST | /plugins/:pluginId/webhooks/:endpointKey | Receive inbound webhook | * | GET | /plugins/tools | List all available plugin tools | * | GET | /plugins/tools?pluginId=... | List tools for a specific plugin | * | POST | /plugins/tools/execute | Execute a plugin tool | * | GET | /plugins/:pluginId/config | Get current plugin config | * | POST | /plugins/:pluginId/config | Save (upsert) plugin config | * | POST | /plugins/:pluginId/config/test | Test config via validateConfig RPC | * | POST | /plugins/:pluginId/bridge/data | Proxy getData to plugin worker | * | POST | /plugins/:pluginId/bridge/action | Proxy performAction to plugin worker | * | POST | /plugins/:pluginId/data/:key | Proxy getData to plugin worker (key in URL) | * | POST | /plugins/:pluginId/actions/:key | Proxy performAction to plugin worker (key in URL) | * | GET | /plugins/:pluginId/bridge/stream/:channel | SSE stream from worker to UI | * | GET | /plugins/:pluginId/dashboard | Aggregated health dashboard data | * * **Route Ordering Note:** Static routes (like /ui-contributions, /tools) must be * registered before parameterized routes (like /:pluginId) to prevent Express from * matching them as a plugin ID. * * @param db - Database connection instance * @param jobDeps - Optional job scheduling dependencies * @param webhookDeps - Optional webhook ingestion dependencies * @param toolDeps - Optional tool dispatcher dependencies * @param bridgeDeps - Optional bridge proxy dependencies for getData/performAction * @returns Express router with plugin routes mounted */ export function pluginRoutes( db: Db, loader: ReturnType, jobDeps?: PluginRouteJobDeps, webhookDeps?: PluginRouteWebhookDeps, toolDeps?: PluginRouteToolDeps, bridgeDeps?: PluginRouteBridgeDeps, ) { const router = Router(); const registry = pluginRegistryService(db); const lifecycle = pluginLifecycleManager(db, { loader, workerManager: bridgeDeps?.workerManager ?? webhookDeps?.workerManager, }); async function resolvePluginAuditCompanyIds(req: Request): Promise { if (typeof (db as { select?: unknown }).select === "function") { const rows = await db .select({ id: companies.id }) .from(companies); return rows.map((row) => row.id); } if (req.actor.type === "agent" && req.actor.companyId) { return [req.actor.companyId]; } if (req.actor.type === "board") { return req.actor.companyIds ?? []; } return []; } async function logPluginMutationActivity( req: Request, action: string, entityId: string, details: Record, ): Promise { const companyIds = await resolvePluginAuditCompanyIds(req); if (companyIds.length === 0) return; const actor = getActorInfo(req); await Promise.all(companyIds.map((companyId) => logActivity(db, { companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId, runId: actor.runId, action, entityType: "plugin", entityId, details, }))); } /** * GET /api/plugins * * List all installed plugins, optionally filtered by lifecycle status. * * Query params: * - `status` (optional): Filter by lifecycle status. Must be one of the * values in `PLUGIN_STATUSES` (`installed`, `ready`, `error`, * `upgrade_pending`, `uninstalled`). Returns HTTP 400 if the value is * not a recognised status string. * * Response: `PluginRecord[]` */ router.get("/plugins", async (req, res) => { assertBoard(req); const rawStatus = req.query.status; if (rawStatus !== undefined) { if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { res.status(400).json({ error: `Invalid status '${String(rawStatus)}'. Must be one of: ${PLUGIN_STATUSES.join(", ")}`, }); return; } } const status = rawStatus as PluginStatus | undefined; const plugins = status ? await registry.listByStatus(status) : await registry.listInstalled(); res.json(plugins); }); /** * GET /api/plugins/examples * * Return first-party example plugins bundled in this repo, if present. * These can be installed through the normal local-path install flow. */ router.get("/plugins/examples", async (req, res) => { assertBoard(req); res.json(listBundledPluginExamples()); }); // IMPORTANT: Static routes must come before parameterized routes // to avoid Express matching "ui-contributions" as a :pluginId /** * GET /api/plugins/ui-contributions * * Return UI contributions from all plugins in 'ready' state. * Used by the frontend to discover plugin UI slots and launcher metadata. * * The response is normalized for the frontend slot host: * - Only includes plugins with at least one declared UI slot or launcher * - Excludes plugins with null/missing manifestJson (defensive) * - Slots are extracted from manifest.ui.slots * - Launchers are aggregated from legacy manifest.launchers and manifest.ui.launchers * * Example response: * ```json * [ * { * "pluginId": "plg_123", * "pluginKey": "paperclip.claude-usage", * "displayName": "Claude Usage", * "version": "1.0.0", * "uiEntryFile": "index.js", * "slots": [], * "launchers": [ * { * "id": "claude-usage-toolbar", * "displayName": "Claude Usage", * "placementZone": "toolbarButton", * "action": { "type": "openModal", "target": "ClaudeUsageView" }, * "render": { "environment": "hostOverlay", "bounds": "wide" } * } * ] * } * ] * ``` * * Response: PluginUiContribution[] */ router.get("/plugins/ui-contributions", async (req, res) => { assertBoard(req); const plugins = await registry.listByStatus("ready"); const contributions: PluginUiContribution[] = plugins .map((plugin) => { // Safety check: manifestJson should always exist for ready plugins, but guard against null const manifest = plugin.manifestJson; if (!manifest) return null; const uiMetadata = getPluginUiContributionMetadata(manifest); if (!uiMetadata) return null; return { pluginId: plugin.id, pluginKey: plugin.pluginKey, displayName: manifest.displayName, version: plugin.version, updatedAt: plugin.updatedAt.toISOString(), uiEntryFile: uiMetadata.uiEntryFile, slots: uiMetadata.slots, launchers: uiMetadata.launchers, }; }) .filter((item): item is PluginUiContribution => item !== null); res.json(contributions); }); // =========================================================================== // Tool discovery and execution routes // =========================================================================== /** * GET /api/plugins/tools * * List all available plugin-contributed tools in an agent-friendly format. * * Query params: * - `pluginId` (optional): Filter to tools from a specific plugin * * Response: `AgentToolDescriptor[]` * Errors: 501 if tool dispatcher is not configured */ router.get("/plugins/tools", async (req, res) => { assertBoard(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); return; } const pluginId = req.query.pluginId as string | undefined; const filter = pluginId ? { pluginId } : undefined; const tools = toolDeps.toolDispatcher.listToolsForAgent(filter); res.json(tools); }); /** * POST /api/plugins/tools/execute * * Execute a plugin-contributed tool by its namespaced name. * * This is the primary endpoint used by the agent service to invoke * plugin tools during an agent run. * * Request body: * - `tool`: Fully namespaced tool name (e.g., "acme.linear:search-issues") * - `parameters`: Parameters matching the tool's declared JSON Schema * - `runContext`: Agent run context with agentId, runId, companyId, projectId * * Response: `ToolExecutionResult` * Errors: * - 400 if request validation fails * - 404 if tool is not found * - 501 if tool dispatcher is not configured * - 502 if the plugin worker is unavailable or the RPC call fails */ router.post("/plugins/tools/execute", async (req, res) => { assertBoard(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); return; } const body = (req.body as PluginToolExecuteRequest | undefined); if (!body) { res.status(400).json({ error: "Request body is required" }); return; } const { tool, parameters, runContext } = body; // Validate required fields if (!tool || typeof tool !== "string") { res.status(400).json({ error: '"tool" is required and must be a string' }); return; } if (!runContext || typeof runContext !== "object") { res.status(400).json({ error: '"runContext" is required and must be an object' }); return; } if (!runContext.agentId || !runContext.runId || !runContext.companyId || !runContext.projectId) { res.status(400).json({ error: '"runContext" must include agentId, runId, companyId, and projectId', }); return; } assertCompanyAccess(req, runContext.companyId); // Verify the tool exists const registeredTool = toolDeps.toolDispatcher.getTool(tool); if (!registeredTool) { res.status(404).json({ error: `Tool "${tool}" not found` }); return; } try { const result = await toolDeps.toolDispatcher.executeTool( tool, parameters ?? {}, runContext, ); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); // Distinguish between "worker not running" (502) and other errors (500) if (message.includes("not running") || message.includes("worker")) { res.status(502).json({ error: message }); } else { res.status(500).json({ error: message }); } } }); /** * POST /api/plugins/install * * Install a plugin from npm or a local filesystem path. * * Request body: * - packageName: npm package name or local path (required) * - version: Target version for npm packages (optional) * - isLocalPath: Set true if packageName is a local path * * The installer: * 1. Downloads from npm or loads from local path * 2. Validates the manifest (schema + capability consistency) * 3. Registers in the database * 4. Transitions to `ready` state if no new capability approval is needed * * Response: `PluginRecord` * * Errors: * - `400` — validation failure or install error (package not found, bad manifest, etc.) * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => { assertBoard(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation if (!packageName || typeof packageName !== "string") { res.status(400).json({ error: "packageName is required and must be a string" }); return; } if (version !== undefined && typeof version !== "string") { res.status(400).json({ error: "version must be a string if provided" }); return; } if (isLocalPath !== undefined && typeof isLocalPath !== "boolean") { res.status(400).json({ error: "isLocalPath must be a boolean if provided" }); return; } // Validate package name format const trimmedPackage = packageName.trim(); if (trimmedPackage.length === 0) { res.status(400).json({ error: "packageName cannot be empty" }); return; } // Basic security check for package name (prevent injection) if (!isLocalPath && /[<>:"|?*]/.test(trimmedPackage)) { res.status(400).json({ error: "packageName contains invalid characters" }); return; } try { const installOptions = isLocalPath ? { localPath: trimmedPackage } : { packageName: trimmedPackage, version: version?.trim() }; const discovered = await loader.installPlugin(installOptions); if (!discovered.manifest) { res.status(500).json({ error: "Plugin installed but manifest is missing" }); return; } // Transition to ready state const existingPlugin = await registry.getByKey(discovered.manifest.id); if (existingPlugin) { await lifecycle.load(existingPlugin.id); const updated = await registry.getById(existingPlugin.id); await logPluginMutationActivity(req, "plugin.installed", existingPlugin.id, { pluginId: existingPlugin.id, pluginKey: existingPlugin.pluginKey, packageName: updated?.packageName ?? existingPlugin.packageName, version: updated?.version ?? existingPlugin.version, source: isLocalPath ? "local_path" : "npm", }); publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: existingPlugin.id, action: "installed" } }); res.json(updated); } else { // This shouldn't happen since installPlugin already registers in the DB res.status(500).json({ error: "Plugin installed but not found in registry" }); } } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); // =========================================================================== // UI Bridge proxy routes (getData / performAction) // =========================================================================== /** Request body for POST /api/plugins/:pluginId/bridge/data */ interface PluginBridgeDataRequest { /** Plugin-defined data key (e.g. `"sync-health"`). */ key: string; /** Optional company scope for authorizing company-context bridge calls. */ companyId?: string; /** Optional context and query parameters from the UI. */ params?: Record; /** Optional host launcher/render metadata for the worker bridge call. */ renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } /** Request body for POST /api/plugins/:pluginId/bridge/action */ interface PluginBridgeActionRequest { /** Plugin-defined action key (e.g. `"resync"`). */ key: string; /** Optional company scope for authorizing company-context bridge calls. */ companyId?: string; /** Optional parameters from the UI. */ params?: Record; /** Optional host launcher/render metadata for the worker bridge call. */ renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } /** Response envelope for bridge errors. */ interface PluginBridgeErrorResponse { code: PluginBridgeErrorCode; message: string; details?: unknown; } /** * Map a worker RPC error to a bridge-level error code. * * JsonRpcCallError carries numeric codes from the plugin RPC error code space. * This helper maps them to the string error codes defined in PluginBridgeErrorCode. * * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ function mapRpcErrorToBridgeError(err: unknown): PluginBridgeErrorResponse { if (err instanceof JsonRpcCallError) { switch (err.code) { case PLUGIN_RPC_ERROR_CODES.WORKER_UNAVAILABLE: return { code: "WORKER_UNAVAILABLE", message: err.message, details: err.data, }; case PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED: return { code: "CAPABILITY_DENIED", message: err.message, details: err.data, }; case PLUGIN_RPC_ERROR_CODES.TIMEOUT: return { code: "TIMEOUT", message: err.message, details: err.data, }; case PLUGIN_RPC_ERROR_CODES.WORKER_ERROR: return { code: "WORKER_ERROR", message: err.message, details: err.data, }; default: return { code: "UNKNOWN", message: err.message, details: err.data, }; } } const message = err instanceof Error ? err.message : String(err); // Worker not running — surface as WORKER_UNAVAILABLE if (message.includes("not running") || message.includes("not registered")) { return { code: "WORKER_UNAVAILABLE", message, }; } return { code: "UNKNOWN", message, }; } /** * POST /api/plugins/:pluginId/bridge/data * * Proxy a `getData` call from the plugin UI to the plugin worker. * * This is the server-side half of the `usePluginData(key, params)` bridge hook. * The frontend sends a POST with the data key and optional params; the host * forwards the call to the worker via the `getData` RPC method and returns * the result. * * Request body: * - `key`: Plugin-defined data key (e.g. `"sync-health"`) * - `params`: Optional query parameters forwarded to the worker handler * * Response: The raw result from the worker's `getData` handler * * Error response body follows the `PluginBridgeError` shape: * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` * * Errors: * - 400 if request validation fails * - 404 if plugin not found * - 501 if bridge deps are not configured * - 502 if the worker is unavailable or returns an error * * @see PLUGIN_SPEC.md §13.8 — `getData` * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/data", async (req, res) => { assertBoard(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); return; } const { pluginId } = req.params; // Resolve plugin const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Validate plugin is in ready state if (plugin.status !== "ready") { const bridgeError: PluginBridgeErrorResponse = { code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; res.status(502).json(bridgeError); return; } // Validate request body const body = req.body as PluginBridgeDataRequest | undefined; if (!body || !body.key || typeof body.key !== "string") { res.status(400).json({ error: '"key" is required and must be a string' }); return; } if (body.companyId) { assertCompanyAccess(req, body.companyId); } try { const result = await bridgeDeps.workerManager.call( plugin.id, "getData", { key: body.key, params: body.params ?? {}, renderEnvironment: body.renderEnvironment ?? null, }, ); res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); res.status(502).json(bridgeError); } }); /** * POST /api/plugins/:pluginId/bridge/action * * Proxy a `performAction` call from the plugin UI to the plugin worker. * * This is the server-side half of the `usePluginAction(key)` bridge hook. * The frontend sends a POST with the action key and optional params; the host * forwards the call to the worker via the `performAction` RPC method and * returns the result. * * Request body: * - `key`: Plugin-defined action key (e.g. `"resync"`) * - `params`: Optional parameters forwarded to the worker handler * * Response: The raw result from the worker's `performAction` handler * * Error response body follows the `PluginBridgeError` shape: * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` * * Errors: * - 400 if request validation fails * - 404 if plugin not found * - 501 if bridge deps are not configured * - 502 if the worker is unavailable or returns an error * * @see PLUGIN_SPEC.md §13.9 — `performAction` * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { assertBoard(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); return; } const { pluginId } = req.params; // Resolve plugin const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Validate plugin is in ready state if (plugin.status !== "ready") { const bridgeError: PluginBridgeErrorResponse = { code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; res.status(502).json(bridgeError); return; } // Validate request body const body = req.body as PluginBridgeActionRequest | undefined; if (!body || !body.key || typeof body.key !== "string") { res.status(400).json({ error: '"key" is required and must be a string' }); return; } if (body.companyId) { assertCompanyAccess(req, body.companyId); } try { const result = await bridgeDeps.workerManager.call( plugin.id, "performAction", { key: body.key, params: body.params ?? {}, renderEnvironment: body.renderEnvironment ?? null, }, ); res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); res.status(502).json(bridgeError); } }); // =========================================================================== // URL-keyed bridge routes (key as path parameter) // =========================================================================== /** * POST /api/plugins/:pluginId/data/:key * * Proxy a `getData` call from the plugin UI to the plugin worker, with the * data key specified as a URL path parameter instead of in the request body. * * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/data`. * The frontend bridge hooks use this endpoint for cleaner URLs. * * Request body (optional): * - `params`: Optional query parameters forwarded to the worker handler * * Response: The raw result from the worker's `getData` handler wrapped as `{ data: T }` * * Error response body follows the `PluginBridgeError` shape: * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` * * Errors: * - 404 if plugin not found * - 501 if bridge deps are not configured * - 502 if the worker is unavailable or returns an error * * @see PLUGIN_SPEC.md §13.8 — `getData` * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/data/:key", async (req, res) => { assertBoard(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); return; } const { pluginId, key } = req.params; // Resolve plugin const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Validate plugin is in ready state if (plugin.status !== "ready") { const bridgeError: PluginBridgeErrorResponse = { code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; res.status(502).json(bridgeError); return; } const body = req.body as { companyId?: string; params?: Record; renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; if (body?.companyId) { assertCompanyAccess(req, body.companyId); } try { const result = await bridgeDeps.workerManager.call( plugin.id, "getData", { key, params: body?.params ?? {}, renderEnvironment: body?.renderEnvironment ?? null, }, ); res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); res.status(502).json(bridgeError); } }); /** * POST /api/plugins/:pluginId/actions/:key * * Proxy a `performAction` call from the plugin UI to the plugin worker, with * the action key specified as a URL path parameter instead of in the request body. * * This is a REST-friendly alternative to `POST /plugins/:pluginId/bridge/action`. * The frontend bridge hooks use this endpoint for cleaner URLs. * * Request body (optional): * - `params`: Optional parameters forwarded to the worker handler * * Response: The raw result from the worker's `performAction` handler wrapped as `{ data: T }` * * Error response body follows the `PluginBridgeError` shape: * `{ code: PluginBridgeErrorCode, message: string, details?: unknown }` * * Errors: * - 404 if plugin not found * - 501 if bridge deps are not configured * - 502 if the worker is unavailable or returns an error * * @see PLUGIN_SPEC.md §13.9 — `performAction` * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { assertBoard(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); return; } const { pluginId, key } = req.params; // Resolve plugin const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Validate plugin is in ready state if (plugin.status !== "ready") { const bridgeError: PluginBridgeErrorResponse = { code: "WORKER_UNAVAILABLE", message: `Plugin is not ready (current status: ${plugin.status})`, }; res.status(502).json(bridgeError); return; } const body = req.body as { companyId?: string; params?: Record; renderEnvironment?: PluginLauncherRenderContextSnapshot | null; } | undefined; if (body?.companyId) { assertCompanyAccess(req, body.companyId); } try { const result = await bridgeDeps.workerManager.call( plugin.id, "performAction", { key, params: body?.params ?? {}, renderEnvironment: body?.renderEnvironment ?? null, }, ); res.json({ data: result }); } catch (err) { const bridgeError = mapRpcErrorToBridgeError(err); res.status(502).json(bridgeError); } }); // =========================================================================== // SSE stream bridge route // =========================================================================== /** * GET /api/plugins/:pluginId/bridge/stream/:channel * * Server-Sent Events endpoint for real-time streaming from plugin worker to UI. * * The worker pushes events via `ctx.streams.emit(channel, event)` which arrive * as JSON-RPC notifications to the host, get published on the PluginStreamBus, * and are fanned out to all connected SSE clients matching (pluginId, channel, * companyId). * * Query parameters: * - `companyId` (required): Scope events to a specific company * * SSE event types: * - `message`: A data event from the worker (default) * - `open`: The worker opened the stream channel * - `close`: The worker closed the stream channel — client should disconnect * * Errors: * - 400 if companyId is missing * - 404 if plugin not found * - 501 if bridge deps or stream bus are not configured */ router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { assertBoard(req); if (!bridgeDeps?.streamBus) { res.status(501).json({ error: "Plugin stream bridge is not enabled" }); return; } const { pluginId, channel } = req.params; const companyId = req.query.companyId as string | undefined; if (!companyId) { res.status(400).json({ error: '"companyId" query parameter is required' }); return; } const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } assertCompanyAccess(req, companyId); // Set SSE headers res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", }); res.flushHeaders(); // Send initial comment to establish the connection res.write(":ok\n\n"); let unsubscribed = false; const safeUnsubscribe = () => { if (!unsubscribed) { unsubscribed = true; unsubscribe(); } }; const unsubscribe = bridgeDeps.streamBus.subscribe( plugin.id, channel, companyId, (event, eventType) => { if (unsubscribed || !res.writable) return; try { if (eventType !== "message") { res.write(`event: ${eventType}\n`); } res.write(`data: ${JSON.stringify(event)}\n\n`); } catch { // Connection closed or write error — stop delivering safeUnsubscribe(); } }, ); req.on("close", safeUnsubscribe); res.on("error", safeUnsubscribe); }); /** * GET /api/plugins/:pluginId * * Get detailed information about a single plugin. * * The :pluginId parameter accepts either: * - Database UUID (e.g., "abc123-def456") * - Plugin key (e.g., "acme.linear") * * Response: PluginRecord * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Enrich with worker capabilities when available const worker = bridgeDeps?.workerManager.getWorker(plugin.id); const supportsConfigTest = worker ? worker.supportedMethods.includes("validateConfig") : false; res.json({ ...plugin, supportsConfigTest }); }); /** * DELETE /api/plugins/:pluginId * * Uninstall a plugin. * * Query params: * - purge: If "true", permanently delete all plugin data (hard delete) * Otherwise, soft-delete with 30-day data retention * * Response: PluginRecord (the deleted record) * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.delete("/plugins/:pluginId", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const purge = req.query.purge === "true"; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } try { const result = await lifecycle.unload(plugin.id, purge); await logPluginMutationActivity(req, "plugin.uninstalled", plugin.id, { pluginId: plugin.id, pluginKey: plugin.pluginKey, purge, }); publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "uninstalled" } }); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); /** * POST /api/plugins/:pluginId/enable * * Enable a plugin that is currently disabled or in error state. * * Transitions the plugin to 'ready' state after loading and validation. * * Response: PluginRecord * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/enable", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } try { const result = await lifecycle.enable(plugin.id); await logPluginMutationActivity(req, "plugin.enabled", plugin.id, { pluginId: plugin.id, pluginKey: plugin.pluginKey, version: result?.version ?? plugin.version, }); publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "enabled" } }); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); /** * POST /api/plugins/:pluginId/disable * * Disable a running plugin. * * Request body (optional): * - reason: Human-readable reason for disabling * * The plugin transitions to 'installed' state and stops processing events. * * Response: PluginRecord * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/disable", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const body = req.body as { reason?: string } | undefined; const reason = body?.reason; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } try { const result = await lifecycle.disable(plugin.id, reason); await logPluginMutationActivity(req, "plugin.disabled", plugin.id, { pluginId: plugin.id, pluginKey: plugin.pluginKey, reason: reason ?? null, }); publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "disabled" } }); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); /** * GET /api/plugins/:pluginId/health * * Run health diagnostics on a plugin. * * Performs the following checks: * 1. Registry: Plugin is registered in the database * 2. Manifest: Manifest is valid and parseable * 3. Status: Plugin is in 'ready' state * 4. Error state: Plugin has no unhandled errors * * Response: PluginHealthCheckResult * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/health", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const checks: PluginHealthCheckResult["checks"] = []; // Check 1: Plugin is registered checks.push({ name: "registry", passed: true, message: "Plugin found in registry", }); // Check 2: Manifest is valid const hasValidManifest = Boolean(plugin.manifestJson?.id); checks.push({ name: "manifest", passed: hasValidManifest, message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", }); // Check 3: Plugin status const isHealthy = plugin.status === "ready"; checks.push({ name: "status", passed: isHealthy, message: `Current status: ${plugin.status}`, }); // Check 4: No last error const hasNoError = !plugin.lastError; if (!hasNoError) { checks.push({ name: "error_state", passed: false, message: plugin.lastError ?? undefined, }); } const result: PluginHealthCheckResult = { pluginId: plugin.id, status: plugin.status, healthy: isHealthy && hasValidManifest && hasNoError, checks, lastError: plugin.lastError ?? undefined, }; res.json(result); }); /** * GET /api/plugins/:pluginId/logs * * Query recent log entries for a plugin. * * Query params: * - limit: Maximum number of entries (default 25, max 500) * - level: Filter by log level (info, warn, error, debug) * - since: ISO timestamp to filter logs newer than this time * * Response: Array of log entries, newest first. */ router.get("/plugins/:pluginId/logs", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 25, 1), 500); const level = req.query.level as string | undefined; const since = req.query.since as string | undefined; const conditions = [eq(pluginLogs.pluginId, plugin.id)]; if (level) { conditions.push(eq(pluginLogs.level, level)); } if (since) { const sinceDate = new Date(since); if (!isNaN(sinceDate.getTime())) { conditions.push(gte(pluginLogs.createdAt, sinceDate)); } } const rows = await db .select() .from(pluginLogs) .where(and(...conditions)) .orderBy(desc(pluginLogs.createdAt)) .limit(limit); res.json(rows); }); /** * POST /api/plugins/:pluginId/upgrade * * Upgrade a plugin to a newer version. * * Request body (optional): * - version: Target version (defaults to latest) * * If the upgrade adds new capabilities, the plugin transitions to * 'upgrade_pending' state for board approval. Otherwise, it goes * directly to 'ready'. * * Response: PluginRecord * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } try { // Upgrade the plugin - this would typically: // 1. Download the new version // 2. Compare capabilities // 3. If new capabilities, mark as upgrade_pending // 4. Otherwise, transition to ready const result = await lifecycle.upgrade(plugin.id, version); await logPluginMutationActivity(req, "plugin.upgraded", plugin.id, { pluginId: plugin.id, pluginKey: plugin.pluginKey, previousVersion: plugin.version, version: result?.version ?? plugin.version, targetVersion: version ?? null, }); publishGlobalLiveEvent({ type: "plugin.ui.updated", payload: { pluginId: plugin.id, action: "upgraded" } }); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); // =========================================================================== // Plugin configuration routes // =========================================================================== /** * GET /api/plugins/:pluginId/config * * Retrieve the current instance configuration for a plugin. * * Returns the `PluginConfig` record if one exists, or `null` if the plugin * has not yet been configured. * * Response: `PluginConfig | null` * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/config", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const config = await registry.getConfig(plugin.id); res.json(config); }); /** * POST /api/plugins/:pluginId/config * * Save (create or replace) the instance configuration for a plugin. * * The caller provides the full `configJson` object. The server persists it * via `registry.upsertConfig()`. * * Request body: * - `configJson`: Configuration values matching the plugin's `instanceConfigSchema` * * Response: `PluginConfig` * Errors: * - 400 if request validation fails * - 404 if plugin not found */ router.post("/plugins/:pluginId/config", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const body = req.body as { configJson?: Record } | undefined; if (!body?.configJson || typeof body.configJson !== "object") { res.status(400).json({ error: '"configJson" is required and must be an object' }); return; } // Strip devUiUrl unless the caller is an instance admin. devUiUrl activates // a dev-proxy in the static file route that could be abused for SSRF if any // board-level user were allowed to set it. if ( "devUiUrl" in body.configJson && !(req.actor.type === "board" && req.actor.isInstanceAdmin) ) { delete body.configJson.devUiUrl; } // Validate configJson against the plugin's instanceConfigSchema (if declared). // This ensures CLI/API callers get the same validation the UI performs client-side. const schema = plugin.manifestJson?.instanceConfigSchema; if (schema && Object.keys(schema).length > 0) { const validation = validateInstanceConfig(body.configJson, schema); if (!validation.valid) { res.status(400).json({ error: "Configuration does not match the plugin's instanceConfigSchema", fieldErrors: validation.errors, }); return; } } try { const result = await registry.upsertConfig(plugin.id, { configJson: body.configJson, }); await logPluginMutationActivity(req, "plugin.config.updated", plugin.id, { pluginId: plugin.id, pluginKey: plugin.pluginKey, configKeyCount: Object.keys(body.configJson).length, }); // Notify the running worker about the config change (PLUGIN_SPEC §25.4.4). // If the worker implements onConfigChanged, send the new config via RPC. // If it doesn't (METHOD_NOT_IMPLEMENTED), restart the worker so it picks // up the new config on re-initialize. If no worker is running, skip. if (bridgeDeps?.workerManager.isRunning(plugin.id)) { try { await bridgeDeps.workerManager.call( plugin.id, "configChanged", { config: body.configJson }, ); } catch (rpcErr) { if ( rpcErr instanceof JsonRpcCallError && rpcErr.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED ) { // Worker doesn't handle live config — restart it. try { await lifecycle.restartWorker(plugin.id); } catch { // Restart failure is non-fatal for the config save response. } } // Other RPC errors (timeout, unavailable) are non-fatal — config is // already persisted and will take effect on next worker restart. } } res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); /** * POST /api/plugins/:pluginId/config/test * * Test a plugin configuration without persisting it by calling the plugin * worker's `validateConfig` RPC method. * * Only works when the plugin's worker implements `onValidateConfig`. * If the worker does not implement the method, returns * `{ valid: false, supported: false, message: "..." }` with HTTP 200. * * Request body: * - `configJson`: Configuration values to validate * * Response: `{ valid: boolean; message?: string; supported?: boolean }` * Errors: * - 400 if request validation fails * - 404 if plugin not found * - 501 if bridge deps (worker manager) are not configured * - 502 if the worker is unavailable */ router.post("/plugins/:pluginId/config/test", async (req, res) => { assertBoard(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); return; } const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } if (plugin.status !== "ready") { res.status(400).json({ error: `Plugin is not ready (current status: ${plugin.status})`, }); return; } const body = req.body as { configJson?: Record } | undefined; if (!body?.configJson || typeof body.configJson !== "object") { res.status(400).json({ error: '"configJson" is required and must be an object' }); return; } // Fast schema-level rejection before hitting the worker RPC. const schema = plugin.manifestJson?.instanceConfigSchema; if (schema && Object.keys(schema).length > 0) { const validation = validateInstanceConfig(body.configJson, schema); if (!validation.valid) { res.status(400).json({ error: "Configuration does not match the plugin's instanceConfigSchema", fieldErrors: validation.errors, }); return; } } try { const result = await bridgeDeps.workerManager.call( plugin.id, "validateConfig", { config: body.configJson }, ); // The worker returns PluginConfigValidationResult { ok, warnings?, errors? } // Map to the frontend-expected shape { valid, message? } if (result.ok) { const warningText = result.warnings?.length ? `Warnings: ${result.warnings.join("; ")}` : undefined; res.json({ valid: true, message: warningText }); } else { const errorText = result.errors?.length ? result.errors.join("; ") : "Configuration validation failed."; res.json({ valid: false, message: errorText }); } } catch (err) { // If the worker does not implement validateConfig, return a structured response if ( err instanceof JsonRpcCallError && err.code === PLUGIN_RPC_ERROR_CODES.METHOD_NOT_IMPLEMENTED ) { res.json({ valid: false, supported: false, message: "This plugin does not support configuration testing.", }); return; } // Worker unavailable or other RPC errors const bridgeError = mapRpcErrorToBridgeError(err); res.status(502).json(bridgeError); } }); // =========================================================================== // Job scheduling routes // =========================================================================== /** * GET /api/plugins/:pluginId/jobs * * List all scheduled jobs for a plugin. * * Query params: * - `status` (optional): Filter by job status (`active`, `paused`, `failed`) * * Response: PluginJobRecord[] * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs", async (req, res) => { assertBoard(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; } const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const rawStatus = req.query.status as string | undefined; const validStatuses = ["active", "paused", "failed"]; if (rawStatus !== undefined && !validStatuses.includes(rawStatus)) { res.status(400).json({ error: `Invalid status '${rawStatus}'. Must be one of: ${validStatuses.join(", ")}`, }); return; } try { const jobs = await jobDeps.jobStore.listJobs( plugin.id, rawStatus as "active" | "paused" | "failed" | undefined, ); res.json(jobs); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(500).json({ error: message }); } }); /** * GET /api/plugins/:pluginId/jobs/:jobId/runs * * List execution history for a specific job. * * Query params: * - `limit` (optional): Maximum number of runs to return (default: 50) * * Response: PluginJobRunRecord[] * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { assertBoard(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; } const { pluginId, jobId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); if (!job) { res.status(404).json({ error: "Job not found" }); return; } const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 25; if (isNaN(limit) || limit < 1 || limit > 500) { res.status(400).json({ error: "limit must be a number between 1 and 500" }); return; } try { const runs = await jobDeps.jobStore.listRunsByJob(jobId, limit); res.json(runs); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(500).json({ error: message }); } }); /** * POST /api/plugins/:pluginId/jobs/:jobId/trigger * * Manually trigger a job execution outside its cron schedule. * * Creates a run with `trigger: "manual"` and dispatches immediately. * The response returns before the job completes (non-blocking). * * Response: `{ runId: string, jobId: string }` * Errors: * - 404 if plugin not found * - 400 if job not found, not active, already running, or worker unavailable */ router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { assertBoard(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; } const { pluginId, jobId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } const job = await jobDeps.jobStore.getJobByIdForPlugin(plugin.id, jobId); if (!job) { res.status(404).json({ error: "Job not found" }); return; } try { const result = await jobDeps.scheduler.triggerJob(jobId, "manual"); res.json(result); } catch (err) { const message = err instanceof Error ? err.message : String(err); res.status(400).json({ error: message }); } }); // =========================================================================== // Webhook ingestion route // =========================================================================== /** * POST /api/plugins/:pluginId/webhooks/:endpointKey * * Receive an inbound webhook delivery for a plugin. * * This route is called by external systems (e.g. GitHub, Linear, Stripe) to * deliver webhook payloads to a plugin. The host validates that: * 1. The plugin exists and is in 'ready' state * 2. The plugin declares the `webhooks.receive` capability * 3. The `endpointKey` matches a declared webhook in the manifest * * The delivery is recorded in the `plugin_webhook_deliveries` table and * dispatched to the worker via the `handleWebhook` RPC method. * * **Note:** This route does NOT require board authentication — webhook * endpoints must be publicly accessible for external callers. Signature * verification is the plugin's responsibility. * * Response: `{ deliveryId: string, status: string }` * Errors: * - 404 if plugin not found or endpointKey not declared * - 400 if plugin is not in ready state or lacks webhooks.receive capability * - 502 if the worker is unavailable or the RPC call fails */ router.post("/plugins/:pluginId/webhooks/:endpointKey", async (req, res) => { if (!webhookDeps) { res.status(501).json({ error: "Webhook ingestion is not enabled" }); return; } const { pluginId, endpointKey } = req.params; // Step 1: Resolve the plugin const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // Step 2: Validate the plugin is in 'ready' state if (plugin.status !== "ready") { res.status(400).json({ error: `Plugin is not ready (current status: ${plugin.status})`, }); return; } // Step 3: Validate the plugin has webhooks.receive capability const manifest = plugin.manifestJson; if (!manifest) { res.status(400).json({ error: "Plugin manifest is missing" }); return; } const capabilities = manifest.capabilities ?? []; if (!capabilities.includes("webhooks.receive")) { res.status(400).json({ error: "Plugin does not have the webhooks.receive capability", }); return; } // Step 4: Validate the endpointKey exists in the manifest's webhook declarations const declaredWebhooks = manifest.webhooks ?? []; const webhookDecl = declaredWebhooks.find( (w) => w.endpointKey === endpointKey, ); if (!webhookDecl) { res.status(404).json({ error: `Webhook endpoint '${endpointKey}' is not declared by this plugin`, }); return; } // Step 5: Extract request data const requestId = randomUUID(); const rawHeaders: Record = {}; for (const [key, value] of Object.entries(req.headers)) { if (typeof value === "string") { rawHeaders[key] = value; } else if (Array.isArray(value)) { rawHeaders[key] = value.join(", "); } } // Use the raw buffer stashed by the express.json() `verify` callback. // This preserves the exact bytes the provider signed, whereas // JSON.stringify(req.body) would re-serialize and break HMAC verification. const stashedRaw = (req as unknown as { rawBody?: Buffer }).rawBody; const rawBody = stashedRaw ? stashedRaw.toString("utf-8") : ""; const parsedBody = req.body as unknown; const payload = (req.body as Record | undefined) ?? {}; // Step 6: Record the delivery in the database const startedAt = new Date(); const [delivery] = await db .insert(pluginWebhookDeliveries) .values({ pluginId: plugin.id, webhookKey: endpointKey, status: "pending", payload, headers: rawHeaders, startedAt, }) .returning({ id: pluginWebhookDeliveries.id }); // Step 7: Dispatch to the worker via handleWebhook RPC try { await webhookDeps.workerManager.call(plugin.id, "handleWebhook", { endpointKey, headers: req.headers as Record, rawBody, parsedBody, requestId, }); // Step 8: Update delivery record to success const finishedAt = new Date(); const durationMs = finishedAt.getTime() - startedAt.getTime(); await db .update(pluginWebhookDeliveries) .set({ status: "success", durationMs, finishedAt, }) .where(eq(pluginWebhookDeliveries.id, delivery.id)); res.status(200).json({ deliveryId: delivery.id, status: "success", }); } catch (err) { // Step 8 (error): Update delivery record to failed const finishedAt = new Date(); const durationMs = finishedAt.getTime() - startedAt.getTime(); const errorMessage = err instanceof Error ? err.message : String(err); await db .update(pluginWebhookDeliveries) .set({ status: "failed", durationMs, error: errorMessage, finishedAt, }) .where(eq(pluginWebhookDeliveries.id, delivery.id)); res.status(502).json({ deliveryId: delivery.id, status: "failed", error: errorMessage, }); } }); // =========================================================================== // Plugin health dashboard — aggregated diagnostics for the settings page // =========================================================================== /** * GET /api/plugins/:pluginId/dashboard * * Aggregated health dashboard data for a plugin's settings page. * * Returns worker diagnostics (status, uptime, crash history), recent job * runs, recent webhook deliveries, and the current health check result — * all in a single response to avoid multiple round-trips. * * Response: PluginDashboardData * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/dashboard", async (req, res) => { assertBoard(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { res.status(404).json({ error: "Plugin not found" }); return; } // --- Worker diagnostics --- let worker: { status: string; pid: number | null; uptime: number | null; consecutiveCrashes: number; totalCrashes: number; pendingRequests: number; lastCrashAt: number | null; nextRestartAt: number | null; } | null = null; // Try bridgeDeps first (primary source for worker manager), fallback to webhookDeps const wm = bridgeDeps?.workerManager ?? webhookDeps?.workerManager ?? null; if (wm) { const handle = wm.getWorker(plugin.id); if (handle) { const diag = handle.diagnostics(); worker = { status: diag.status, pid: diag.pid, uptime: diag.uptime, consecutiveCrashes: diag.consecutiveCrashes, totalCrashes: diag.totalCrashes, pendingRequests: diag.pendingRequests, lastCrashAt: diag.lastCrashAt, nextRestartAt: diag.nextRestartAt, }; } } // --- Recent job runs (last 10, newest first) --- let recentJobRuns: Array<{ id: string; jobId: string; jobKey?: string; trigger: string; status: string; durationMs: number | null; error: string | null; startedAt: string | null; finishedAt: string | null; createdAt: string; }> = []; if (jobDeps) { try { const runs = await jobDeps.jobStore.listRunsByPlugin(plugin.id, undefined, 10); // Also fetch job definitions so we can include jobKey const jobs = await jobDeps.jobStore.listJobs(plugin.id); const jobKeyMap = new Map(jobs.map((j) => [j.id, j.jobKey])); recentJobRuns = runs .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .map((r) => ({ id: r.id, jobId: r.jobId, jobKey: jobKeyMap.get(r.jobId) ?? undefined, trigger: r.trigger, status: r.status, durationMs: r.durationMs, error: r.error, startedAt: r.startedAt ? new Date(r.startedAt).toISOString() : null, finishedAt: r.finishedAt ? new Date(r.finishedAt).toISOString() : null, createdAt: new Date(r.createdAt).toISOString(), })); } catch { // Job data unavailable — leave empty } } // --- Recent webhook deliveries (last 10, newest first) --- let recentWebhookDeliveries: Array<{ id: string; webhookKey: string; status: string; durationMs: number | null; error: string | null; startedAt: string | null; finishedAt: string | null; createdAt: string; }> = []; try { const deliveries = await db .select({ id: pluginWebhookDeliveries.id, webhookKey: pluginWebhookDeliveries.webhookKey, status: pluginWebhookDeliveries.status, durationMs: pluginWebhookDeliveries.durationMs, error: pluginWebhookDeliveries.error, startedAt: pluginWebhookDeliveries.startedAt, finishedAt: pluginWebhookDeliveries.finishedAt, createdAt: pluginWebhookDeliveries.createdAt, }) .from(pluginWebhookDeliveries) .where(eq(pluginWebhookDeliveries.pluginId, plugin.id)) .orderBy(desc(pluginWebhookDeliveries.createdAt)) .limit(10); recentWebhookDeliveries = deliveries.map((d) => ({ id: d.id, webhookKey: d.webhookKey, status: d.status, durationMs: d.durationMs, error: d.error, startedAt: d.startedAt ? d.startedAt.toISOString() : null, finishedAt: d.finishedAt ? d.finishedAt.toISOString() : null, createdAt: d.createdAt.toISOString(), })); } catch { // Webhook data unavailable — leave empty } // --- Health check (same logic as GET /health) --- const checks: PluginHealthCheckResult["checks"] = []; checks.push({ name: "registry", passed: true, message: "Plugin found in registry", }); const hasValidManifest = Boolean(plugin.manifestJson?.id); checks.push({ name: "manifest", passed: hasValidManifest, message: hasValidManifest ? "Manifest is valid" : "Manifest is invalid or missing", }); const isHealthy = plugin.status === "ready"; checks.push({ name: "status", passed: isHealthy, message: `Current status: ${plugin.status}`, }); const hasNoError = !plugin.lastError; if (!hasNoError) { checks.push({ name: "error_state", passed: false, message: plugin.lastError ?? undefined, }); } const health: PluginHealthCheckResult = { pluginId: plugin.id, status: plugin.status, healthy: isHealthy && hasValidManifest && hasNoError, checks, lastError: plugin.lastError ?? undefined, }; res.json({ pluginId: plugin.id, worker, recentJobRuns, recentWebhookDeliveries, health, checkedAt: new Date().toISOString(), }); }); return router; }