Files
TREK/shared/src/plugins/plugins.schema.ts
T
jubnl b82962f95b Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
2026-09-02 23:06:03 +02:00

161 lines
7.7 KiB
TypeScript

import { z } from 'zod';
/**
* Plugin settings contracts (#plugins).
*
* A plugin declares its settings fields in its manifest (validated at install,
* stored in plugin_settings_fields); the server serves them back as form
* metadata and both settings forms — the user Settings tab (`scope:'user'`)
* and the admin instance-settings dialog (`scope:'instance'`) — render from
* this ONE descriptor shape. Nullable-vs-optional is deliberate: the server
* emits SQL NULLs for absent labels/placeholders/hints, and the forms branch
* on them (`f.label || f.key`).
*
* Shared Zod pins SHAPE only. Which keys a plugin may store, secret
* encryption, and the mask sentinel stay server-side (plugins.service.ts) —
* the settled two-layer pattern.
*/
export const pluginSettingsFieldSchema = z.object({
key: z.string(),
label: z.string().nullish(),
/** 'text' | 'number' | 'checkbox' | 'select' | … — server defaults absent to 'text'. */
input_type: z.string().optional(),
placeholder: z.string().nullish(),
hint: z.string().nullish(),
required: z.boolean().optional(),
secret: z.boolean().optional(),
/** The field's effective value when nothing is stored: the form pre-fills it and the
* runtime resolves it, so the form shows what the plugin gets. Never on a secret field. */
default: z.union([z.string(), z.number(), z.boolean()]).nullish(),
options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
});
export type PluginSettingsField = z.infer<typeof pluginSettingsFieldSchema>;
/**
* A settings-form action button a plugin declared in its manifest (`actions`). Which
* form renders it is the `scope`, exactly like a settings field: `'user'` on the user
* Settings tab (runs as the clicking user), `'instance'` in the admin instance-settings
* dialog (runs as the clicking admin). One action lives in exactly one form.
*/
export const pluginActionScopeSchema = z.enum(['user', 'instance']);
export type PluginActionScope = z.infer<typeof pluginActionScopeSchema>;
export const pluginActionDescriptorSchema = z.object({
key: z.string(),
label: z.string(),
hint: z.string().optional(),
danger: z.boolean(),
scope: pluginActionScopeSchema,
});
export type PluginActionDescriptor = z.infer<typeof pluginActionDescriptorSchema>;
/** What an action answers, after the host bounded the plugin-supplied message. */
export const pluginActionResultSchema = z.object({
ok: z.boolean(),
message: z.string().optional(),
});
export type PluginActionResult = z.infer<typeof pluginActionResultSchema>;
/** GET /api/admin/plugins/:id/config — the declared `scope:'instance'` fields
* plus the stored values, secrets masked, plus the `scope:'instance'` actions. */
export const pluginInstanceConfigResponseSchema = z.object({
fields: z.array(pluginSettingsFieldSchema),
config: z.record(z.string(), z.unknown()),
actions: z.array(pluginActionDescriptorSchema),
});
export type PluginInstanceConfigResponse = z.infer<typeof pluginInstanceConfigResponseSchema>;
/** PUT /api/admin/plugins/:id/config body — the config record itself. Loose on
* purpose (journey-contract doctrine): the service drops undeclared keys and
* keeps every decision it already makes, so the schema only refuses non-objects. */
export const pluginInstanceConfigUpdateSchema = z.record(z.string(), z.unknown());
export type PluginInstanceConfigUpdate = z.infer<typeof pluginInstanceConfigUpdateSchema>;
/** PUT /api/admin/plugins/:id/config response — the saved (masked) config, and
* whether the save re-spawned a RUNNING plugin (its child reads config once, at init). */
export const pluginInstanceConfigUpdatedSchema = z.object({
config: z.record(z.string(), z.unknown()),
restarted: z.boolean(),
});
export type PluginInstanceConfigUpdated = z.infer<typeof pluginInstanceConfigUpdatedSchema>;
/**
* Body contracts for the admin plugin surfaces, written to DESCRIBE rather than
* to tighten (the journey-contract doctrine, #1842).
*
* Every one of these handlers already owns its own rejection, and several of
* them answer something other than 400 for a body they dislike: activate and
* install answer 503 when plugins are disabled, and they check that BEFORE
* looking at the body. A schema that rejected first would move a 503 to a 400
* for an operator whose server has plugins off. So the fields stay optional and
* the objects stay loose: the schema pins the wire TYPES, and the handler keeps
* every decision it already made, in the order it already made it.
*/
/** POST /api/admin/plugins/install — `id is required` stays the handler's answer. */
export const pluginInstallRequestSchema = z.looseObject({
id: z.string().optional(),
version: z.string().optional(),
constraint: z.string().optional(),
withDependencies: z.boolean().optional(),
});
export type PluginInstallRequest = z.infer<typeof pluginInstallRequestSchema>;
/** POST /api/admin/plugins/link — `path is required` likewise, and dev-link answers 403 before reading it. */
export const pluginLinkRequestSchema = z.looseObject({ path: z.string().optional() });
export type PluginLinkRequest = z.infer<typeof pluginLinkRequestSchema>;
/** POST /api/admin/plugins/:id/activate */
export const pluginActivateRequestSchema = z.looseObject({ consent: z.boolean().optional() });
export type PluginActivateRequest = z.infer<typeof pluginActivateRequestSchema>;
/** POST /api/admin/plugins/:id/uninstall */
export const pluginUninstallRequestSchema = z.looseObject({ deleteData: z.boolean().optional() });
export type PluginUninstallRequest = z.infer<typeof pluginUninstallRequestSchema>;
/** POST /api/admin/plugins/:id/retrust */
export const pluginRetrustRequestSchema = z.looseObject({
version: z.string().optional(),
publicKey: z.string().optional(),
});
export type PluginRetrustRequest = z.infer<typeof pluginRetrustRequestSchema>;
/**
* POST /api/admin/plugins/:id/update — `version` pins the exact version to
* install (the rollback path). Omitted, the runtime resolves the newest
* TREK-compatible version itself (the classic update).
*/
export const pluginUpdateRequestSchema = z.looseObject({ version: z.string().optional() });
export type PluginUpdateRequest = z.infer<typeof pluginUpdateRequestSchema>;
/**
* PUT /api/admin/plugins/:id/egress-hosts — `hosts` is deliberately unknown,
* not `string[]`.
*
* The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`,
* which means an omitted or non-array `hosts` CLEARS the operator egress list.
* That is the documented way to reset it, so a schema demanding an array would
* not tighten validation, it would remove a working admin action.
*/
export const pluginEgressHostsRequestSchema = z.looseObject({ hosts: z.unknown().optional() });
export type PluginEgressHostsRequest = z.infer<typeof pluginEgressHostsRequestSchema>;
/**
* POST /api/plugin-settings/:id — `config` is unknown rather than a record: the
* handler answers 200 with the stored config for anything it cannot use,
* including a non-object, and a schema that rejected first would turn that
* into a 400.
*/
export const pluginUserSettingsUpdateRequestSchema = z.looseObject({ config: z.unknown().optional() });
export type PluginUserSettingsUpdateRequest = z.infer<typeof pluginUserSettingsUpdateRequestSchema>;
/**
* POST /api/plugins/:id/route — the route hook answers `{ route: null }` at 200
* for every input it dislikes: a missing trip, a bad profile, unusable
* waypoints, a slow provider. The client draws straight lines on a null and
* shows nothing on a 400, so a schema that rejected a malformed body would turn
* a graceful fallback into a visible error. Loose and fully optional on purpose.
*/
export const pluginRouteRequestSchema = z.looseObject({});
export type PluginRouteRequest = z.infer<typeof pluginRouteRequestSchema>;