import { z } from "zod"; import { PLUGIN_STATUSES, PLUGIN_CATEGORIES, PLUGIN_CAPABILITIES, PLUGIN_UI_SLOT_TYPES, PLUGIN_UI_SLOT_ENTITY_TYPES, PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS, PLUGIN_LAUNCHER_PLACEMENT_ZONES, PLUGIN_LAUNCHER_ACTIONS, PLUGIN_LAUNCHER_BOUNDS, PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS, PLUGIN_STATE_SCOPE_KINDS, } from "../constants.js"; // --------------------------------------------------------------------------- // JSON Schema placeholder – a permissive validator for JSON Schema objects // --------------------------------------------------------------------------- /** * Permissive validator for JSON Schema objects. Accepts any `Record` * that contains at least a `type`, `$ref`, or composition keyword (`oneOf`/`anyOf`/`allOf`). * Empty objects are also accepted. * * Used to validate `instanceConfigSchema` and `parametersSchema` fields in the * plugin manifest without fully parsing JSON Schema. * * @see PLUGIN_SPEC.md §10.1 — Manifest shape */ export const jsonSchemaSchema = z.record(z.unknown()).refine( (val) => { // Must have a "type" field if non-empty, or be a valid JSON Schema object if (Object.keys(val).length === 0) return true; return typeof val.type === "string" || val.$ref !== undefined || val.oneOf !== undefined || val.anyOf !== undefined || val.allOf !== undefined; }, { message: "Must be a valid JSON Schema object (requires at least a 'type', '$ref', or composition keyword)" }, ); // --------------------------------------------------------------------------- // Manifest sub-type schemas // --------------------------------------------------------------------------- /** * Validates a {@link PluginJobDeclaration} — a scheduled job declared in the * plugin manifest. Requires `jobKey` and `displayName`; `description` and * `schedule` (cron expression) are optional. * * @see PLUGIN_SPEC.md §17 — Scheduled Jobs */ /** * Validates a cron expression has exactly 5 whitespace-separated fields, * each containing only valid cron characters (digits, *, /, -, ,). * * Valid tokens per field: *, N, N-M, N/S, * /S, N-M/S, and comma-separated lists. */ const CRON_FIELD_PATTERN = /^(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?)(?:,(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?))*$/; function isValidCronExpression(expression: string): boolean { const trimmed = expression.trim(); if (!trimmed) return false; const fields = trimmed.split(/\s+/); if (fields.length !== 5) return false; return fields.every((f) => CRON_FIELD_PATTERN.test(f)); } export const pluginJobDeclarationSchema = z.object({ jobKey: z.string().min(1), displayName: z.string().min(1), description: z.string().optional(), schedule: z.string().refine( (val) => isValidCronExpression(val), { message: "schedule must be a valid 5-field cron expression (e.g. '*/15 * * * *')" }, ).optional(), }); export type PluginJobDeclarationInput = z.infer; /** * Validates a {@link PluginWebhookDeclaration} — a webhook endpoint declared * in the plugin manifest. Requires `endpointKey` and `displayName`. * * @see PLUGIN_SPEC.md §18 — Webhooks */ export const pluginWebhookDeclarationSchema = z.object({ endpointKey: z.string().min(1), displayName: z.string().min(1), description: z.string().optional(), }); export type PluginWebhookDeclarationInput = z.infer; /** * Validates a {@link PluginToolDeclaration} — an agent tool contributed by the * plugin. Requires `name`, `displayName`, `description`, and a valid * `parametersSchema`. Requires the `agent.tools.register` capability. * * @see PLUGIN_SPEC.md §11 — Agent Tools */ export const pluginToolDeclarationSchema = z.object({ name: z.string().min(1), displayName: z.string().min(1), description: z.string().min(1), parametersSchema: jsonSchemaSchema, }); export type PluginToolDeclarationInput = z.infer; /** * Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin * fills with a React component. Includes `superRefine` checks for slot-specific * requirements such as `entityTypes` for context-sensitive slots. * * @see PLUGIN_SPEC.md §19 — UI Extension Model */ export const pluginUiSlotDeclarationSchema = z.object({ type: z.enum(PLUGIN_UI_SLOT_TYPES), id: z.string().min(1), displayName: z.string().min(1), exportName: z.string().min(1), entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(), routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, { message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)", }).optional(), order: z.number().int().optional(), }).superRefine((value, ctx) => { // context-sensitive slots require explicit entity targeting. const entityScopedTypes = ["detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem"]; if ( entityScopedTypes.includes(value.type) && (!value.entityTypes || value.entityTypes.length === 0) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${value.type} slots require at least one entityType`, path: ["entityTypes"], }); } // projectSidebarItem only makes sense for entityType "project". if (value.type === "projectSidebarItem" && value.entityTypes && !value.entityTypes.includes("project")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "projectSidebarItem slots require entityTypes to include \"project\"", path: ["entityTypes"], }); } // commentAnnotation only makes sense for entityType "comment". if (value.type === "commentAnnotation" && value.entityTypes && !value.entityTypes.includes("comment")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "commentAnnotation slots require entityTypes to include \"comment\"", path: ["entityTypes"], }); } // commentContextMenuItem only makes sense for entityType "comment". if (value.type === "commentContextMenuItem" && value.entityTypes && !value.entityTypes.includes("comment")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "commentContextMenuItem slots require entityTypes to include \"comment\"", path: ["entityTypes"], }); } if (value.routePath && value.type !== "page") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "routePath is only supported for page slots", path: ["routePath"], }); } if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `routePath "${value.routePath}" is reserved by the host`, path: ["routePath"], }); } }); export type PluginUiSlotDeclarationInput = z.infer; const entityScopedLauncherPlacementZones = [ "detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem", ] as const; const launcherBoundsByEnvironment: Record< (typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number], readonly (typeof PLUGIN_LAUNCHER_BOUNDS)[number][] > = { hostInline: ["inline", "compact", "default"], hostOverlay: ["compact", "default", "wide", "full"], hostRoute: ["default", "wide", "full"], external: [], iframe: ["compact", "default", "wide", "full"], }; /** * Validates the action payload for a declarative plugin launcher. */ export const pluginLauncherActionDeclarationSchema = z.object({ type: z.enum(PLUGIN_LAUNCHER_ACTIONS), target: z.string().min(1), params: z.record(z.unknown()).optional(), }).superRefine((value, ctx) => { if (value.type === "performAction" && value.target.includes("/")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "performAction launchers must target an action key, not a route or URL", path: ["target"], }); } if (value.type === "navigate" && /^https?:\/\//.test(value.target)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "navigate launchers must target a host route, not an absolute URL", path: ["target"], }); } }); export type PluginLauncherActionDeclarationInput = z.infer; /** * Validates optional render hints for a plugin launcher destination. */ export const pluginLauncherRenderDeclarationSchema = z.object({ environment: z.enum(PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS), bounds: z.enum(PLUGIN_LAUNCHER_BOUNDS).optional(), }).superRefine((value, ctx) => { if (!value.bounds) { return; } const supportedBounds = launcherBoundsByEnvironment[value.environment]; if (!supportedBounds.includes(value.bounds)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `bounds "${value.bounds}" is not supported for render environment "${value.environment}"`, path: ["bounds"], }); } }); export type PluginLauncherRenderDeclarationInput = z.infer; /** * Validates declarative launcher metadata in a plugin manifest. */ export const pluginLauncherDeclarationSchema = z.object({ id: z.string().min(1), displayName: z.string().min(1), description: z.string().optional(), placementZone: z.enum(PLUGIN_LAUNCHER_PLACEMENT_ZONES), exportName: z.string().min(1).optional(), entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(), order: z.number().int().optional(), action: pluginLauncherActionDeclarationSchema, render: pluginLauncherRenderDeclarationSchema.optional(), }).superRefine((value, ctx) => { if ( entityScopedLauncherPlacementZones.some((zone) => zone === value.placementZone) && (!value.entityTypes || value.entityTypes.length === 0) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${value.placementZone} launchers require at least one entityType`, path: ["entityTypes"], }); } if ( value.placementZone === "projectSidebarItem" && value.entityTypes && !value.entityTypes.includes("project") ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "projectSidebarItem launchers require entityTypes to include \"project\"", path: ["entityTypes"], }); } if (value.action.type === "performAction" && value.render) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "performAction launchers cannot declare render hints", path: ["render"], }); } if ( ["openModal", "openDrawer", "openPopover"].includes(value.action.type) && !value.render ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `${value.action.type} launchers require render metadata`, path: ["render"], }); } if (value.action.type === "openModal" && value.render?.environment === "hostInline") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "openModal launchers cannot use the hostInline render environment", path: ["render", "environment"], }); } if ( value.action.type === "openDrawer" && value.render && !["hostOverlay", "iframe"].includes(value.render.environment) ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "openDrawer launchers must use hostOverlay or iframe render environments", path: ["render", "environment"], }); } if (value.action.type === "openPopover" && value.render?.environment === "hostRoute") { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "openPopover launchers cannot use the hostRoute render environment", path: ["render", "environment"], }); } }); export type PluginLauncherDeclarationInput = z.infer; // --------------------------------------------------------------------------- // Plugin Manifest V1 schema // --------------------------------------------------------------------------- /** * Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime * validator for plugin manifests read at install time. * * Field-level constraints (see PLUGIN_SPEC.md §10.1 for the normative rules): * * | Field | Type | Constraints | * |--------------------------|------------|----------------------------------------------| * | `id` | string | `^[a-z0-9][a-z0-9._-]*$` | * | `apiVersion` | literal 1 | must equal `PLUGIN_API_VERSION` | * | `version` | string | semver (`\d+\.\d+\.\d+`) | * | `displayName` | string | 1–100 chars | * | `description` | string | 1–500 chars | * | `author` | string | 1–200 chars | * | `categories` | enum[] | at least one; values from PLUGIN_CATEGORIES | * | `minimumHostVersion` | string? | semver lower bound if present, no leading `v`| * | `minimumPaperclipVersion`| string? | legacy alias of `minimumHostVersion` | * | `capabilities` | enum[] | at least one; values from PLUGIN_CAPABILITIES| * | `entrypoints.worker` | string | min 1 char | * | `entrypoints.ui` | string? | required when `ui.slots` is declared | * * Cross-field rules enforced via `superRefine`: * - `entrypoints.ui` required when `ui.slots` declared * - `agent.tools.register` capability required when `tools` declared * - `jobs.schedule` capability required when `jobs` declared * - `webhooks.receive` capability required when `webhooks` declared * - duplicate `jobs[].jobKey` values are rejected * - duplicate `webhooks[].endpointKey` values are rejected * - duplicate `tools[].name` values are rejected * - duplicate `ui.slots[].id` values are rejected * * @see PLUGIN_SPEC.md §10.1 — Manifest shape * @see {@link PaperclipPluginManifestV1} — the inferred TypeScript type */ export const pluginManifestV1Schema = z.object({ id: z.string().min(1).regex( /^[a-z0-9][a-z0-9._-]*$/, "Plugin id must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores", ), apiVersion: z.literal(1), version: z.string().min(1).regex( /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/, "Version must follow semver (e.g. 1.0.0 or 1.0.0-beta.1)", ), displayName: z.string().min(1).max(100), description: z.string().min(1).max(500), author: z.string().min(1).max(200), categories: z.array(z.enum(PLUGIN_CATEGORIES)).min(1), minimumHostVersion: z.string().regex( /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/, "minimumHostVersion must follow semver (e.g. 1.0.0)", ).optional(), minimumPaperclipVersion: z.string().regex( /^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/, "minimumPaperclipVersion must follow semver (e.g. 1.0.0)", ).optional(), capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1), entrypoints: z.object({ worker: z.string().min(1), ui: z.string().min(1).optional(), }), instanceConfigSchema: jsonSchemaSchema.optional(), jobs: z.array(pluginJobDeclarationSchema).optional(), webhooks: z.array(pluginWebhookDeclarationSchema).optional(), tools: z.array(pluginToolDeclarationSchema).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), ui: z.object({ slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(), launchers: z.array(pluginLauncherDeclarationSchema).optional(), }).optional(), }).superRefine((manifest, ctx) => { // ── Entrypoint ↔ UI slot consistency ────────────────────────────────── // Plugins that declare UI slots must also declare a UI entrypoint so the // host knows where to load the bundle from (PLUGIN_SPEC.md §10.1). const hasUiSlots = (manifest.ui?.slots?.length ?? 0) > 0; const hasUiLaunchers = (manifest.ui?.launchers?.length ?? 0) > 0; if ((hasUiSlots || hasUiLaunchers) && !manifest.entrypoints.ui) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "entrypoints.ui is required when ui.slots or ui.launchers are declared", path: ["entrypoints", "ui"], }); } if ( manifest.minimumHostVersion && manifest.minimumPaperclipVersion && manifest.minimumHostVersion !== manifest.minimumPaperclipVersion ) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "minimumHostVersion and minimumPaperclipVersion must match when both are declared", path: ["minimumHostVersion"], }); } // ── Capability ↔ feature declaration consistency ─────────────────────── // The host enforces capabilities at install and runtime. A plugin must // declare every capability it needs up-front; silently having more features // than capabilities would cause runtime rejections. // tools require agent.tools.register (PLUGIN_SPEC.md §11) if (manifest.tools && manifest.tools.length > 0) { if (!manifest.capabilities.includes("agent.tools.register")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Capability 'agent.tools.register' is required when tools are declared", path: ["capabilities"], }); } } // jobs require jobs.schedule (PLUGIN_SPEC.md §17) if (manifest.jobs && manifest.jobs.length > 0) { if (!manifest.capabilities.includes("jobs.schedule")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Capability 'jobs.schedule' is required when jobs are declared", path: ["capabilities"], }); } } // webhooks require webhooks.receive (PLUGIN_SPEC.md §18) if (manifest.webhooks && manifest.webhooks.length > 0) { if (!manifest.capabilities.includes("webhooks.receive")) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Capability 'webhooks.receive' is required when webhooks are declared", path: ["capabilities"], }); } } // ── Uniqueness checks ────────────────────────────────────────────────── // Duplicate keys within a plugin's own manifest are always a bug. The host // would not know which declaration takes precedence, so we reject early. // job keys must be unique within the plugin (used as identifiers in the DB) if (manifest.jobs) { const jobKeys = manifest.jobs.map((j) => j.jobKey); const duplicates = jobKeys.filter((key, i) => jobKeys.indexOf(key) !== i); if (duplicates.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate job keys: ${[...new Set(duplicates)].join(", ")}`, path: ["jobs"], }); } } // webhook endpoint keys must be unique within the plugin (used in routes) if (manifest.webhooks) { const endpointKeys = manifest.webhooks.map((w) => w.endpointKey); const duplicates = endpointKeys.filter((key, i) => endpointKeys.indexOf(key) !== i); if (duplicates.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate webhook endpoint keys: ${[...new Set(duplicates)].join(", ")}`, path: ["webhooks"], }); } } // tool names must be unique within the plugin (namespaced at runtime) if (manifest.tools) { const toolNames = manifest.tools.map((t) => t.name); const duplicates = toolNames.filter((name, i) => toolNames.indexOf(name) !== i); if (duplicates.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate tool names: ${[...new Set(duplicates)].join(", ")}`, path: ["tools"], }); } } // UI slot ids must be unique within the plugin (namespaced at runtime) if (manifest.ui) { if (manifest.ui.slots) { const slotIds = manifest.ui.slots.map((s) => s.id); const duplicates = slotIds.filter((id, i) => slotIds.indexOf(id) !== i); if (duplicates.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate UI slot ids: ${[...new Set(duplicates)].join(", ")}`, path: ["ui", "slots"], }); } } } // launcher ids must be unique within the plugin const allLaunchers = [ ...(manifest.launchers ?? []), ...(manifest.ui?.launchers ?? []), ]; if (allLaunchers.length > 0) { const launcherIds = allLaunchers.map((launcher) => launcher.id); const duplicates = launcherIds.filter((id, i) => launcherIds.indexOf(id) !== i); if (duplicates.length > 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Duplicate launcher ids: ${[...new Set(duplicates)].join(", ")}`, path: manifest.ui?.launchers ? ["ui", "launchers"] : ["launchers"], }); } } }); export type PluginManifestV1Input = z.infer; // --------------------------------------------------------------------------- // Plugin installation / registration request // --------------------------------------------------------------------------- /** * Schema for installing (registering) a plugin. * The server receives the packageName and resolves the manifest from the * installed package. */ export const installPluginSchema = z.object({ packageName: z.string().min(1), version: z.string().min(1).optional(), /** Set by loader for local-path installs so the worker can be resolved. */ packagePath: z.string().min(1).optional(), }); export type InstallPlugin = z.infer; // --------------------------------------------------------------------------- // Plugin config (instance configuration) schemas // --------------------------------------------------------------------------- /** * Schema for creating or updating a plugin's instance configuration. * configJson is validated permissively here; runtime validation against * the plugin's instanceConfigSchema is done at the service layer. */ export const upsertPluginConfigSchema = z.object({ configJson: z.record(z.unknown()), }); export type UpsertPluginConfig = z.infer; /** * Schema for partially updating a plugin's instance configuration. * Allows a partial merge of config values. */ export const patchPluginConfigSchema = z.object({ configJson: z.record(z.unknown()), }); export type PatchPluginConfig = z.infer; // --------------------------------------------------------------------------- // Plugin status update // --------------------------------------------------------------------------- /** * Schema for updating a plugin's lifecycle status. Used by the lifecycle * manager to persist state transitions. * * @see {@link PLUGIN_STATUSES} for the valid status values */ export const updatePluginStatusSchema = z.object({ status: z.enum(PLUGIN_STATUSES), lastError: z.string().nullable().optional(), }); export type UpdatePluginStatus = z.infer; // --------------------------------------------------------------------------- // Plugin uninstall // --------------------------------------------------------------------------- /** Schema for the uninstall request. `removeData` controls hard vs soft delete. */ export const uninstallPluginSchema = z.object({ removeData: z.boolean().optional().default(false), }); export type UninstallPlugin = z.infer; // --------------------------------------------------------------------------- // Plugin state (key-value storage) schemas // --------------------------------------------------------------------------- /** * Schema for a plugin state scope key — identifies the exact location where * state is stored. Used by the `ctx.state.get()`, `ctx.state.set()`, and * `ctx.state.delete()` SDK methods. * * @see PLUGIN_SPEC.md §21.3 `plugin_state` */ export const pluginStateScopeKeySchema = z.object({ scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS), scopeId: z.string().min(1).optional(), namespace: z.string().min(1).optional(), stateKey: z.string().min(1), }); export type PluginStateScopeKey = z.infer; /** * Schema for setting a plugin state value. */ export const setPluginStateSchema = z.object({ scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS), scopeId: z.string().min(1).optional(), namespace: z.string().min(1).optional(), stateKey: z.string().min(1), /** JSON-serializable value to store. */ value: z.unknown(), }); export type SetPluginState = z.infer; /** * Schema for querying plugin state entries. All fields are optional to allow * flexible list queries (e.g. all state for a plugin within a scope). */ export const listPluginStateSchema = z.object({ scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS).optional(), scopeId: z.string().min(1).optional(), namespace: z.string().min(1).optional(), }); export type ListPluginState = z.infer;