@@ -0,0 +1,670 @@
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<string, unknown>`
* 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 < typeof pluginJobDeclarationSchema > ;
/**
* 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 < typeof pluginWebhookDeclarationSchema > ;
/**
* 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 < typeof pluginToolDeclarationSchema > ;
/**
* 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 < typeof pluginUiSlotDeclarationSchema > ;
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 < typeof pluginLauncherActionDeclarationSchema > ;
/**
* 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 < typeof pluginLauncherRenderDeclarationSchema > ;
/**
* 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 < typeof pluginLauncherDeclarationSchema > ;
// ---------------------------------------------------------------------------
// 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 < typeof pluginManifestV1Schema > ;
// ---------------------------------------------------------------------------
// 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 < typeof installPluginSchema > ;
// ---------------------------------------------------------------------------
// 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 < typeof upsertPluginConfigSchema > ;
/**
* 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 < typeof patchPluginConfigSchema > ;
// ---------------------------------------------------------------------------
// 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 < typeof updatePluginStatusSchema > ;
// ---------------------------------------------------------------------------
// 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 < typeof uninstallPluginSchema > ;
// ---------------------------------------------------------------------------
// 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 < typeof pluginStateScopeKeySchema > ;
/**
* 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 < typeof setPluginStateSchema > ;
/**
* 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 < typeof listPluginStateSchema > ;