/** * Manifest v2: the inert JSON description of a plugin. * * Core reads this without running any plugin code, so everything that decides * whether a plugin may load, what it may do and where its UI appears has to be * expressible here. * * Unknown fields are rejected at every level. A typo'd "contribute" or * "permissionGroups" used to validate clean and be silently dropped, which * made a manifest look like it declared something it did not. */ import semver from "semver"; import { isKnownCapability } from "./capabilities.js"; /** * The plugin API this build implements. A minor bump adds to the API and * never removes; a major bump may break plugins. * * engine.api is a semver range checked against this ("1" means any 1.x, * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ export const PLUGIN_API_VERSION = "1.0.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( semver.major(PLUGIN_API_VERSION), ); /** Whether this build's plugin API satisfies a manifest's engine.api. */ export function isApiCompatible(range: string): boolean { return ( semver.validRange(range) !== null && semver.satisfies(PLUGIN_API_VERSION, range) ); } /** * Whether a core version satisfies a manifest's engine.termix. A prerelease * core (2.9.0-beta.1) counts as that release. */ export function isTermixCompatible( range: string, coreVersion: string | null, ): boolean { if (!coreVersion || !semver.valid(semver.coerce(coreVersion))) return true; if (semver.validRange(range) === null) return false; return semver.satisfies(semver.coerce(coreVersion)!.version, range); } export const PLUGIN_CATEGORIES = [ "Terminal", "Files & Transfer", "Infrastructure", "Monitoring", "Networking", "Access & Security", "Productivity", ] as const; export const PLUGIN_PLATFORMS = ["linux", "win32", "darwin"] as const; export const ACTION_CONTRIBUTION_KINDS = ["button", "component"] as const; export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number]; const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"] as const; const ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/; /** * Ids no plugin may take. A permission is registered as ., so a * plugin called "admin" would mint admin.* permissions and could default them * onto the user role; the rest are names core or the runtime already uses. */ export const RESERVED_PLUGIN_IDS: readonly string[] = [ "admin", "hosts", "credentials", "core", "termix", "plugin", "plugins", "users", "system", "sdk", ]; /** A preset key is a plain property name, never a prototype one. */ const PRESET_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/; const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/; /** Service names are dotted, e.g. "ssh.transport". */ const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/; const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/; /** Action ids name a frontend function, so segments may be camelCase. */ const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/; const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** * A plugin-relative permission name. Unlike a full id one segment is fine, * and underscores are allowed in the first segment too ("manage_providers"). */ const PERMISSION_NAME_PATTERN = /^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/; /** The roles core seeds. A plugin may only set defaults for these. */ export const SYSTEM_ROLE_NAMES = ["admin", "user"] as const; export type SystemRoleName = (typeof SYSTEM_ROLE_NAMES)[number]; /** * Core permission groups. A plugin permission may not start with one of these, * or a manifest could declare admin.users.manage and gate a route on it. */ export const RESERVED_PERMISSION_PREFIXES = [ "hosts", "credentials", "admin", ] as const; /** The id a plugin-relative permission name is registered under. */ export function qualifyPermission(pluginId: string, name: string): string { return `${pluginId}.${name}`; } export interface PluginAuthor { name: string; url?: string; } export interface PluginEngine { /** Semver range of Termix releases the plugin runs on, e.g. ">=2.9.0". */ termix: string; /** * Semver range of the plugin API it needs, e.g. "1" or "^1.2". Use the * lowest minor whose API you call, so older cores refuse it cleanly. */ api: string; } export interface HostCapabilityContribution { key: string; labelKey: string; editorTab: string; } export interface PluginTabContribution { id: string; titleKey: string; icon: string; openFrom: string[]; } /** * A sidebar panel or dashboard card the plugin's frontend registers. Declared * here so core knows who owns a saved view even while the plugin is off and * its code never loaded. */ export interface PluginViewContribution { id: string; titleKey: string; icon?: string; } /** * One role permission a plugin contributes. * * `name` is short and plugin-relative; core registers it as * `.`, so a plugin can never name a core group or another * plugin's namespace. The i18n keys are plugin-relative too. */ export interface PluginPermissionContribution { name: string; titleKey: string; descriptionKey: string; /** System roles that should hold this the first time it is seen. */ defaultRoles?: SystemRoleName[]; } export interface PluginActionContribution { id: string; titleKey: string; /** Name of the frontend export implementing this action. */ handler: string; icon?: string; permission?: string; slot?: string; kind?: ActionContributionKind; } export interface PluginActionSlot { id: string; accepts: ActionContributionKind[]; descriptionKey?: string; } export interface PluginServiceProvide { service: string; version: string; /** The role permission a calling user needs. */ permission: string; /** * Named providers this plugin registers, for a service several plugins * provide side by side (sessions.live, keyed by session type). Omitted, the * plugin provides it once, unnamed. */ names?: string[]; } export interface PluginServiceRequire { service: string; versionRange: string; optional?: boolean; } export interface PluginSecretProvide { key: string; permission: string; descriptionKey?: string; } export interface PluginSecretRequire { plugin: string; key: string; optional?: boolean; } export const SETTINGS_FIELD_TYPES = [ "boolean", "string", "number", "select", "multiselect", "secret", "textarea", "json", "custom", ] as const; export type PluginSettingsFieldType = (typeof SETTINGS_FIELD_TYPES)[number]; export const SETTINGS_SCOPES = ["admin", "user", "host"] as const; export type PluginSettingsScope = (typeof SETTINGS_SCOPES)[number]; export interface PluginSettingsOption { value: string; labelKey: string; } /** * One field on a plugin's settings page. * * Core renders these; a plugin supplies data only, so it cannot ship its own * form styling and drift from the rest of the app. `type: "custom"` is the * escape hatch for UI a schema cannot express, and names a component the * frontend registered rather than carrying markup. */ export interface PluginSettingsField { key: string; type: PluginSettingsFieldType; /** Required except for "custom", which draws its own label. */ labelKey?: string; descriptionKey?: string; placeholderKey?: string; default?: unknown; /** select and multiselect only. */ options?: PluginSettingsOption[]; /** number only. */ min?: number; max?: number; /** Key of a boolean field in the same scope that must be on. */ requires?: string; /** * Who may write it. Admin fields default to admin.plugins.manage; a short * name resolves against this plugin's own permissions. */ permission?: string; /** Section heading this field sits under. */ group?: string; /** Registered component id. Required when type is "custom". */ component?: string; /** * Deprecated and ignored: host defaults cover every host field now. Still * accepted so an older manifest loads. Removed in 3.0.0. */ defaultFrom?: string; /** * Host scope only: whether the field can have a host default (server, * user or folder) that hosts follow until they set their own. On by * default for every field except secrets and json fields with * `secretKeys`. Turn it off for a value that only makes sense per host, * such as a MAC address or an API endpoint. */ defaultable?: boolean; /** * Host scope only: the levels that may set a default for it. Defaults to * all three. Leave "admin" out for a value that points at one user's own * rows (a snippet id, a profile id). */ defaultLevels?: Array<"admin" | "user" | "folder">; /** * Host scope only: a look-and-feel value. When the host follows its * defaults, a user it is shared with gets their own defaults for it * rather than the owner's. */ personal?: boolean; /** * Host scope only: the lowest share level whose recipients see this value * in the host payload. Defaults to "connect", everyone who sees the host. */ shareRead?: "connect" | "view" | "edit" | "manage"; /** Host scope only: only the host's owner may change it. */ ownerOnly?: boolean; /** * JSON fields only: keys inside the stored object that hold secrets, such * as a gateway password. Core clears them from exports like any secret. */ secretKeys?: string[]; /** * Stored and validated like any field, but not drawn by the generic * settings form, because the plugin edits it in its own UI (a host editor * section). */ hidden?: boolean; } export interface PluginHostSettingsContribution { /** Boolean field rendered first, gating the rest of the section. */ enableKey?: string; enableLabelKey?: string; enableDescriptionKey?: string; /** What the enable switch reads before a host saves it. Off by default. */ enableDefault?: boolean; /** Host editor strip the generated tab sits in. Defaults to "top". */ editorGroup?: "top" | "ssh"; /** Position among that strip's tabs, core ones included. */ editorOrder?: number; fields: PluginSettingsField[]; } export interface PluginSettingsContribution { admin?: PluginSettingsField[]; user?: PluginSettingsField[]; host?: PluginHostSettingsContribution; } /** * What a plugin adds to sign-in and SSH auth. Declared so core can tell who * owns an auth type or factor even while the plugin is disabled, and so the * login screen knows which bundles it needs before anyone has signed in. */ export interface PluginAuthContribution { /** Values of ssh_data.auth_type this plugin provides. */ sshAuthTypes?: string[]; /** Login method ids. */ loginMethods?: string[]; /** Second factor ids. */ secondFactors?: string[]; /** Schemes this plugin resolves for "://..." secret references, e.g. "op". */ secretSchemes?: string[]; /** Keyboard-interactive handler ids, e.g. "warpgate". */ keyboardInteractive?: string[]; } export interface PluginContributions { tabs?: PluginTabContribution[]; panels?: PluginViewContribution[]; dashboardCards?: PluginViewContribution[]; /** * The frontend also runs on anonymous guest pages (shared-session and * collab links), where it is activated with app.guest set. */ guest?: boolean; /** * `?view=` names an anonymous guest page may open. Each must be served by a * tab's `standalone` component. Needs `guest: true`. */ guestViews?: string[]; actions?: PluginActionContribution[]; actionSlots?: PluginActionSlot[]; permissions?: PluginPermissionContribution[]; settings?: PluginSettingsContribution; /** * A host-editor checkbox backed by a boolean column on the host record. * A5/A6/A7 reshape this; it stays in v2 because remote-desktop, docker, * proxmox and web-endpoint ship it today. */ hostCapability?: HostCapabilityContribution | HostCapabilityContribution[]; auth?: PluginAuthContribution; http?: PluginHttpContribution; /** * This plugin's Appearance defaults for each interface preset, read in the * frontend with usePluginUiPreferences. Every preset names the same keys; * values are booleans, numbers, strings or string arrays. */ uiPresets?: Record< "simple" | "balanced" | "advanced", Record >; /** * Wire names of the sync entities this plugin registers with * ctx.sync.registerEntity, so a server can name them while the plugin is * off and a desktop can list them in its sync settings. */ syncEntities?: string[]; /** * Other plugins' action, slot and extension point ids this plugin calls, * fills or extends. Declaration only: nothing breaks when the owner is * missing, but the coupling is written down where tools and people can * see it. */ uses?: string[]; /** * Keybinding actions this plugin adds to Appearance > Keybindings, with * the parameters a saved binding carries. Core validates saved bindings * against these without running plugin code, and translates a parameter * naming a sync entity over sync. */ keybindingActions?: PluginKeybindingActionContribution[]; /** * Connection protocols whose per-host login core stores, encrypts, * shares, exports and syncs for this plugin, read back with * ctx.credentials.resolveHostProtocol. */ protocols?: PluginProtocolContribution[]; } /** A field a protocol login carries besides username and password. */ export interface PluginProtocolCredentialField { key: string; /** Encrypted with the owner's data key and never sent to a browser. */ secret?: boolean; } export interface PluginProtocolContribution { /** Stored with each login. Never change it once hosts use it. */ id: string; credentialFields?: PluginProtocolCredentialField[]; /** * The port an import row without one gets, for a host whose main * protocol this is. */ defaultPort?: number; /** * What the owner's login falls back to from the host's own SSH login * when the protocol login leaves it empty. */ hostLoginFallback?: Array<"username" | "password">; } /** Protocol ids core keeps for itself. */ export const RESERVED_PROTOCOL_IDS: readonly string[] = ["ssh"]; /** Login keys a declared credential field may not reuse. */ export const RESERVED_PROTOCOL_FIELD_KEYS: readonly string[] = [ "username", "password", "authType", "credentialId", ]; /** One parameter a saved keybinding action carries next to its type. */ export interface PluginKeybindingParam { type: "string" | "boolean"; required?: boolean; /** A regular expression a string value must match. */ pattern?: string; maxLength?: number; /** * The value is a local row id of this sync entity, stored as a string. * Sync sends the row's syncId instead. */ syncEntity?: string; } export interface PluginKeybindingActionContribution { /** * Stored as the binding's action.type. New actions use * "."; the bundled ones keep their 2.8 names. */ id: string; params?: Record; } /** Keybinding action types the shell runs itself; no plugin may declare them. */ export const CORE_KEYBINDING_ACTIONS: readonly string[] = [ "nextTab", "previousTab", "openCommandPalette", "reconnectSession", ]; /** * How a desktop linked to a server treats this plugin. * - "mirror": follows the server, installed and switched on or off with it. * - "local": each desktop decides for itself (a serial port is local). * - "server": only makes sense on a server, never runs on a linked desktop. */ export type PluginDesktopMode = "mirror" | "local" | "server"; export const PLUGIN_DESKTOP_MODES: readonly PluginDesktopMode[] = [ "mirror", "local", "server", ]; export interface PluginHttpContribution { /** * Old URLs something outside Termix still calls (a webhook a third party * posts to). Each must start with "//"; a request under it is * served by this plugin's router with that prefix removed, so * "/automations/webhook/x" reaches the route "/webhook/x". Core routes win * a clash. */ legacyPaths?: string[]; /** * Old URLs outside the plugin's namespace that something outside Termix * still sends people to (an identity provider's callback, a published * resolver URL). A request at `from`, or under it, is redirected to `to` * under /plugin-api// with the rest of the path and the query kept. * Core routes win a clash. */ legacyRedirects?: PluginLegacyRedirect[]; /** * An admin may call this plugin's routes on behalf of another user with * the X-Admin-Target-User header (the admin "manage user" panel). Without * it core refuses the header on every route under /plugin-api//. */ adminImpersonation?: boolean; } export interface PluginLegacyRedirect { /** An absolute path, e.g. "/users/oidc/callback". */ from: string; /** A path in this plugin's router, e.g. "/callback". */ to: string; /** 307 (the default) or 308 for a permanent move. */ status?: 307 | 308; } export interface PluginManifest { id: string; name: string; version: string; description: string; author: PluginAuthor; license: string; repository?: string; category: string; icon?: string; engine: PluginEngine; /** Catalog capability ids. */ capabilities: string[]; /** pluginId -> semver range. A missing one blocks activation. */ dependencies?: Record; /** pluginId -> semver range. A missing one is normal. */ optionalDependencies?: Record; provides?: PluginServiceProvide[]; requires?: PluginServiceRequire[]; providesSecret?: PluginSecretProvide[]; requiresSecret?: PluginSecretRequire[]; contributes?: PluginContributions; /** Built backend entry, relative to the plugin root. */ backend?: string; /** Built frontend entry, relative to the plugin root. */ frontend?: string; /** Locales directory, relative to the plugin root. */ locales?: string; platforms?: string[]; /** * Bare npm package names carrying a native (.node) binding. The CLI build * never bundles them: they stay a real dependency in the plugin's own * package.json and are resolved from node_modules at runtime instead, * because esbuild bundling a native addon breaks the relative path it uses * to locate its compiled binary. */ nativeDependencies?: string[]; /** How a linked desktop treats this plugin. Defaults to "mirror". */ desktop?: PluginDesktopMode; } export const DEFAULT_BACKEND_ENTRY = "dist/backend.js"; export const DEFAULT_FRONTEND_ENTRY = "dist/frontend.js"; export const DEFAULT_LOCALES_DIR = "locales"; const REQUIRED_TOP_LEVEL = [ "id", "name", "version", "description", "author", "license", "engine", "category", "capabilities", ]; const ALLOWED_TOP_LEVEL = new Set([ ...REQUIRED_TOP_LEVEL, "repository", "icon", "dependencies", "optionalDependencies", "provides", "requires", "providesSecret", "requiresSecret", "contributes", "backend", "frontend", "locales", "platforms", "nativeDependencies", "desktop", ]); const ALLOWED_CONTRIBUTES = new Set([ "tabs", "panels", "dashboardCards", "guest", "guestViews", "actions", "actionSlots", "permissions", "settings", "hostCapability", "http", "uiPresets", "auth", "syncEntities", "uses", "keybindingActions", "protocols", ]); const ALLOWED_SETTINGS_FIELD = [ "key", "type", "labelKey", "descriptionKey", "placeholderKey", "default", "options", "min", "max", "requires", "permission", "group", "component", "hidden", "defaultFrom", "defaultable", "defaultLevels", "personal", "shareRead", "ownerOnly", "secretKeys", ]; const SHARE_LEVELS = ["connect", "view", "edit", "manage"]; /** Settings keys are stored as-is, so they stay short and index-safe. */ const SETTINGS_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** Pushes an error for every key that is not in `allowed`. */ function rejectUnknown( value: Record, allowed: Set | string[], where: string, errors: string[], ): void { const set = Array.isArray(allowed) ? new Set(allowed) : allowed; for (const key of Object.keys(value)) { if (!set.has(key)) { errors.push(`Unknown field "${key}" in ${where}`); } } } /** * A path inside the plugin folder. Absolute paths, ".." and backslashes are * refused so an entry point or asset folder cannot point outside the plugin. */ function requireRelativePath( value: string, where: string, errors: string[], ): void { const segments = value.split("/"); if ( value.startsWith("/") || /^[A-Za-z]:/.test(value) || value.includes("\\") || value.includes("\0") || segments.includes("..") || segments.every((segment) => segment === "" || segment === ".") ) { errors.push( `Field "${where}" must be a relative path inside the plugin, got: ${JSON.stringify(value)}`, ); } } function requireString( value: unknown, where: string, errors: string[], ): value is string { if (typeof value !== "string" || value.trim().length === 0) { errors.push(`Field "${where}" must be a non-empty string`); return false; } return true; } export function validateManifest(manifest: unknown): string[] { const errors: string[] = []; if (!isPlainObject(manifest)) { return ["Manifest must be a JSON object"]; } const m = manifest; rejectUnknown(m, ALLOWED_TOP_LEVEL, "the manifest", errors); for (const field of REQUIRED_TOP_LEVEL) { if (!(field in m)) errors.push(`Missing required field: "${field}"`); } if ("id" in m) { if (typeof m.id !== "string" || !ID_PATTERN.test(m.id)) { errors.push( `Field "id" must match ${ID_PATTERN} (lowercase, starts with a letter, 2-40 chars), got: ${JSON.stringify(m.id)}`, ); } else if (RESERVED_PLUGIN_IDS.includes(m.id)) { errors.push(`Field "id" may not be "${m.id}": that name is reserved`); } } if ("name" in m) requireString(m.name, "name", errors); if ("description" in m) requireString(m.description, "description", errors); if ("license" in m) requireString(m.license, "license", errors); if ("version" in m) { if (typeof m.version !== "string" || !SEMVER_PATTERN.test(m.version)) { errors.push( `Field "version" must be valid semver, got: ${JSON.stringify(m.version)}`, ); } } if ("repository" in m) requireString(m.repository, "repository", errors); if ("icon" in m) requireString(m.icon, "icon", errors); for (const field of ["backend", "frontend", "locales"] as const) { if (field in m && requireString(m[field], field, errors)) { requireRelativePath(m[field] as string, field, errors); } } validateAuthor(m.author, errors); validateEngine(m.engine, errors); validateCategory(m.category, errors); validateCapabilities(m.capabilities, errors); validatePlatforms(m.platforms, errors); validateNativeDependencies(m.nativeDependencies, errors); if ( m.desktop !== undefined && !PLUGIN_DESKTOP_MODES.includes(m.desktop as PluginDesktopMode) ) { errors.push( `Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`, ); } validateDependencyMap(m.dependencies, "dependencies", errors); validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors); validateProvides(m.provides, errors); validateRequires(m.requires, errors); validateProvidesSecret(m.providesSecret, errors); validateRequiresSecret(m.requiresSecret, errors); validateContributes( m.contributes, typeof m.id === "string" ? m.id : undefined, errors, ); return errors; } function validateAuthor(author: unknown, errors: string[]): void { if (author === undefined) return; if (!isPlainObject(author)) { errors.push('Field "author" must be an object'); return; } rejectUnknown(author, ["name", "url"], '"author"', errors); requireString(author.name, "author.name", errors); if ("url" in author) requireString(author.url, "author.url", errors); } function validateEngine(engine: unknown, errors: string[]): void { if (engine === undefined) return; if (!isPlainObject(engine)) { errors.push('Field "engine" must be an object'); return; } rejectUnknown(engine, ["termix", "api"], '"engine"', errors); requireString(engine.termix, "engine.termix", errors); if ( typeof engine.termix === "string" && semver.validRange(engine.termix) === null ) { errors.push( `Field "engine.termix" must be a semver range, got: ${JSON.stringify(engine.termix)}`, ); } if ( typeof engine.api !== "string" || semver.validRange(engine.api) === null ) { errors.push( `Field "engine.api" must be a semver range such as "1" or "^1.2", got: ${JSON.stringify(engine.api)}`, ); } } function validateCategory(category: unknown, errors: string[]): void { if (category === undefined) return; if (typeof category !== "string") { errors.push('Field "category" must be a string'); return; } if (!PLUGIN_CATEGORIES.includes(category as never)) { errors.push( `Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${category}"`, ); } } function validateCapabilities(capabilities: unknown, errors: string[]): void { if (capabilities === undefined) return; if (!Array.isArray(capabilities)) { errors.push('Field "capabilities" must be an array of capability ids'); return; } const seen = new Set(); capabilities.forEach((capability, index) => { if (typeof capability !== "string") { errors.push(`capabilities[${index}] must be a string`); return; } if (!isKnownCapability(capability)) { errors.push( `capabilities[${index}] is not a known capability: "${capability}"`, ); return; } if (seen.has(capability)) { errors.push(`capabilities[${index}] duplicates "${capability}"`); } seen.add(capability); }); } function validatePlatforms(platforms: unknown, errors: string[]): void { if (platforms === undefined) return; if (!Array.isArray(platforms) || platforms.length === 0) { errors.push('Field "platforms" must be a non-empty array when present'); return; } platforms.forEach((platform, index) => { if (!PLUGIN_PLATFORMS.includes(platform as never)) { errors.push( `platforms[${index}] must be one of: ${PLUGIN_PLATFORMS.join(", ")}, got: ${JSON.stringify(platform)}`, ); } }); } function validateNativeDependencies(value: unknown, errors: string[]): void { if (value === undefined) return; if (!Array.isArray(value) || value.length === 0) { errors.push( 'Field "nativeDependencies" must be a non-empty array when present', ); return; } value.forEach((entry, index) => { if (typeof entry !== "string" || entry.length === 0) { errors.push(`nativeDependencies[${index}] must be a non-empty string`); } }); } function validateDependencyMap( value: unknown, field: string, errors: string[], ): void { if (value === undefined) return; if (!isPlainObject(value)) { errors.push( `Field "${field}" must be an object of pluginId -> semver range`, ); return; } for (const [pluginId, range] of Object.entries(value)) { if (!ID_PATTERN.test(pluginId)) { errors.push(`${field} key "${pluginId}" is not a valid plugin id`); } if (typeof range !== "string" || !semver.validRange(range)) { errors.push( `${field}["${pluginId}"] must be a valid semver range, got: ${JSON.stringify(range)}`, ); } } } function validateProvides(provides: unknown, errors: string[]): void { if (provides === undefined) return; if (!Array.isArray(provides)) { errors.push('Field "provides" must be an array'); return; } const seen = new Set(); provides.forEach((raw, index) => { const where = `provides[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown( raw, ["service", "version", "permission", "names"], where, errors, ); if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) { errors.push(`${where}.service must be a dotted lowercase name`); } else { if (seen.has(raw.service)) { errors.push(`${where}.service duplicates "${raw.service}"`); } seen.add(raw.service); } if (typeof raw.version !== "string" || !SEMVER_PATTERN.test(raw.version)) { errors.push(`${where}.version must be valid semver`); } requireString(raw.permission, `${where}.permission`, errors); if (raw.names !== undefined) { validateNameList(raw.names, `${where}.names`, errors); } }); } const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/; function validateSyncEntities(value: unknown, errors: string[]) { const where = "contributes.syncEntities"; if (!Array.isArray(value) || value.length === 0) { errors.push(`${where} must be a non-empty array`); return; } const seen = new Set(); for (const name of value) { if (typeof name !== "string" || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(name)) { errors.push(`${where} entries must be letters and digits`); continue; } if (seen.has(name)) errors.push(`${where} duplicates "${name}"`); seen.add(name); } } function validateUses(value: unknown, errors: string[]) { const where = "contributes.uses"; if (!Array.isArray(value) || value.length === 0) { errors.push(`${where} must be a non-empty array`); return; } const seen = new Set(); for (const id of value) { if ( typeof id !== "string" || !/^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(id) ) { errors.push( `${where} entries must be dotted ids such as "terminal.open"`, ); continue; } if (seen.has(id)) errors.push(`${where} duplicates "${id}"`); seen.add(id); } } function validateNameList(value: unknown, where: string, errors: string[]) { if (!Array.isArray(value) || value.length === 0) { errors.push(`${where} must be a non-empty array`); return; } const seen = new Set(); for (const name of value) { if (typeof name !== "string" || !PROVIDER_NAME_PATTERN.test(name)) { errors.push(`${where} entries must be lowercase names`); continue; } if (seen.has(name)) errors.push(`${where} duplicates "${name}"`); seen.add(name); } } function validateRequires(requires: unknown, errors: string[]): void { if (requires === undefined) return; if (!Array.isArray(requires)) { errors.push('Field "requires" must be an array'); return; } requires.forEach((raw, index) => { const where = `requires[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["service", "versionRange", "optional"], where, errors); if (typeof raw.service !== "string" || !SERVICE_PATTERN.test(raw.service)) { errors.push(`${where}.service must be a dotted lowercase name`); } if ( typeof raw.versionRange !== "string" || !semver.validRange(raw.versionRange) ) { errors.push(`${where}.versionRange must be a valid semver range`); } if ("optional" in raw && typeof raw.optional !== "boolean") { errors.push(`${where}.optional must be a boolean`); } }); } function validateProvidesSecret(value: unknown, errors: string[]): void { if (value === undefined) return; if (!Array.isArray(value)) { errors.push('Field "providesSecret" must be an array'); return; } const seen = new Set(); value.forEach((raw, index) => { const where = `providesSecret[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["key", "permission", "descriptionKey"], where, errors); if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) { errors.push(`${where}.key must be a lowercase slug`); } else { if (seen.has(raw.key)) errors.push(`${where}.key duplicates "${raw.key}"`); seen.add(raw.key); } requireString(raw.permission, `${where}.permission`, errors); if ("descriptionKey" in raw) { requireString(raw.descriptionKey, `${where}.descriptionKey`, errors); } }); } function validateRequiresSecret(value: unknown, errors: string[]): void { if (value === undefined) return; if (!Array.isArray(value)) { errors.push('Field "requiresSecret" must be an array'); return; } value.forEach((raw, index) => { const where = `requiresSecret[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["plugin", "key", "optional"], where, errors); if (typeof raw.plugin !== "string" || !ID_PATTERN.test(raw.plugin)) { errors.push(`${where}.plugin must be a valid plugin id`); } if (typeof raw.key !== "string" || !SECRET_KEY_PATTERN.test(raw.key)) { errors.push(`${where}.key must be a lowercase slug`); } if ("optional" in raw && typeof raw.optional !== "boolean") { errors.push(`${where}.optional must be a boolean`); } }); } function validateContributes( contributes: unknown, pluginId: string | undefined, errors: string[], ): void { if (contributes === undefined) return; if (!isPlainObject(contributes)) { errors.push('Field "contributes" must be an object'); return; } rejectUnknown(contributes, ALLOWED_CONTRIBUTES, '"contributes"', errors); validateTabs(contributes.tabs, errors); validateViews(contributes.panels, "panels", errors); validateViews(contributes.dashboardCards, "dashboardCards", errors); if ( contributes.guest !== undefined && typeof contributes.guest !== "boolean" ) { errors.push('Field "contributes.guest" must be a boolean'); } if (contributes.guestViews !== undefined) { validateNameList(contributes.guestViews, "contributes.guestViews", errors); if (contributes.guest !== true) { errors.push('Field "contributes.guestViews" needs "contributes.guest"'); } } if (contributes.syncEntities !== undefined) { validateSyncEntities(contributes.syncEntities, errors); } if (contributes.uses !== undefined) { validateUses(contributes.uses, errors); } validatePermissions(contributes.permissions, pluginId, errors); validateActions(contributes.actions, errors); validateActionSlots(contributes.actionSlots, errors); validateSettings(contributes.settings, errors); validateHostCapability(contributes.hostCapability, errors); validateAuthContribution(contributes.auth, errors); validateHttpContribution(contributes.http, pluginId, errors); validateUiPresets(contributes.uiPresets, errors); validateKeybindingActions(contributes.keybindingActions, errors); validateProtocols(contributes.protocols, errors); } export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; export const PROTOCOL_FIELD_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/; function validateProtocols(value: unknown, errors: string[]): void { if (value === undefined) return; const where = "contributes.protocols"; if (!Array.isArray(value)) { errors.push(`Field "${where}" must be an array`); return; } const seen = new Set(); value.forEach((raw, index) => { const at = `${where}[${index}]`; if (!isPlainObject(raw)) { errors.push(`${at} must be an object`); return; } rejectUnknown( raw, ["id", "credentialFields", "defaultPort", "hostLoginFallback"], at, errors, ); if (typeof raw.id !== "string" || !PROTOCOL_ID_PATTERN.test(raw.id)) { errors.push( `${at}.id must be lowercase letters, digits and dashes, starting with a letter`, ); } else if (RESERVED_PROTOCOL_IDS.includes(raw.id)) { errors.push(`${at}.id "${raw.id}" belongs to core`); } else { if (seen.has(raw.id)) errors.push(`${at}.id duplicates "${raw.id}"`); seen.add(raw.id); } if ( raw.defaultPort !== undefined && (typeof raw.defaultPort !== "number" || !Number.isInteger(raw.defaultPort) || raw.defaultPort < 1 || raw.defaultPort > 65535) ) { errors.push(`${at}.defaultPort must be a port number`); } if (raw.hostLoginFallback !== undefined) { if ( !Array.isArray(raw.hostLoginFallback) || raw.hostLoginFallback.some( (entry) => entry !== "username" && entry !== "password", ) ) { errors.push( `${at}.hostLoginFallback must list "username" and/or "password"`, ); } } if (raw.credentialFields === undefined) return; if (!Array.isArray(raw.credentialFields)) { errors.push(`${at}.credentialFields must be an array`); return; } const keys = new Set(); raw.credentialFields.forEach((field, fieldIndex) => { const fieldAt = `${at}.credentialFields[${fieldIndex}]`; if (!isPlainObject(field)) { errors.push(`${fieldAt} must be an object`); return; } rejectUnknown(field, ["key", "secret"], fieldAt, errors); if ( typeof field.key !== "string" || !PROTOCOL_FIELD_KEY_PATTERN.test(field.key) ) { errors.push(`${fieldAt}.key must be letters and digits`); } else if (RESERVED_PROTOCOL_FIELD_KEYS.includes(field.key)) { errors.push(`${fieldAt}.key "${field.key}" is part of every login`); } else { if (keys.has(field.key)) { errors.push(`${fieldAt}.key duplicates "${field.key}"`); } keys.add(field.key); } if (field.secret !== undefined && typeof field.secret !== "boolean") { errors.push(`${fieldAt}.secret must be a boolean`); } }); }); } const KEYBINDING_ACTION_PATTERN = /^[a-zA-Z][a-zA-Z0-9.-]{0,63}$/; const KEYBINDING_PARAM_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/; function validateKeybindingParam( param: unknown, at: string, errors: string[], ): void { if (!isPlainObject(param)) { errors.push(`${at} must be an object`); return; } rejectUnknown( param, ["type", "required", "pattern", "maxLength", "syncEntity"], at, errors, ); if (param.type !== "string" && param.type !== "boolean") { errors.push(`${at}.type must be "string" or "boolean"`); } if (param.required !== undefined && typeof param.required !== "boolean") { errors.push(`${at}.required must be a boolean`); } if (param.pattern !== undefined) { let valid = typeof param.pattern === "string"; if (valid) { try { new RegExp(param.pattern as string); } catch { valid = false; } } if (!valid) errors.push(`${at}.pattern must be a regular expression`); } if ( param.maxLength !== undefined && (typeof param.maxLength !== "number" || !Number.isInteger(param.maxLength) || param.maxLength < 1) ) { errors.push(`${at}.maxLength must be a positive integer`); } if (param.syncEntity !== undefined) { if ( typeof param.syncEntity !== "string" || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(param.syncEntity) ) { errors.push(`${at}.syncEntity must be a sync entity wire name`); } else if (param.type !== "string") { errors.push(`${at}.syncEntity needs a string parameter`); } } } function validateKeybindingActions(value: unknown, errors: string[]): void { if (value === undefined) return; const where = "contributes.keybindingActions"; if (!Array.isArray(value)) { errors.push(`Field "${where}" must be an array`); return; } const seen = new Set(); value.forEach((raw, index) => { const at = `${where}[${index}]`; if (!isPlainObject(raw)) { errors.push(`${at} must be an object`); return; } rejectUnknown(raw, ["id", "params"], at, errors); if (typeof raw.id !== "string" || !KEYBINDING_ACTION_PATTERN.test(raw.id)) { errors.push(`${at}.id must be letters, digits, dots and dashes`); } else if (CORE_KEYBINDING_ACTIONS.includes(raw.id)) { errors.push(`${at}.id "${raw.id}" is one of the shell's own actions`); } else { if (seen.has(raw.id)) errors.push(`${at}.id duplicates "${raw.id}"`); seen.add(raw.id); } if (raw.params === undefined) return; if (!isPlainObject(raw.params)) { errors.push(`${at}.params must be an object`); return; } for (const [name, param] of Object.entries(raw.params)) { if (!KEYBINDING_PARAM_PATTERN.test(name) || name === "type") { errors.push(`${at}.params.${name} is not a valid parameter name`); } validateKeybindingParam(param, `${at}.params.${name}`, errors); } }); } /** * Checks one saved keybinding action against the parameters its declaration * lists. A parameter the declaration does not list is refused, so a binding * cannot carry arbitrary data under a declared type. */ export function validateKeybindingActionParams( action: Record, declaration: PluginKeybindingActionContribution, ): boolean { const params = declaration.params ?? {}; for (const key of Object.keys(action)) { if (key !== "type" && !(key in params) && action[key] != null) { return false; } } for (const [name, param] of Object.entries(params)) { const value = action[name]; if (value === undefined || value === null) { if (param.required) return false; continue; } if (param.type === "boolean") { if (typeof value !== "boolean") return false; continue; } if (typeof value !== "string") return false; if (param.required && value.length === 0) return false; if (value.length > (param.maxLength ?? 65_536)) return false; if (param.pattern && !new RegExp(param.pattern).test(value)) return false; } return true; } function isPresetValue(value: unknown): boolean { return ( typeof value === "boolean" || (typeof value === "number" && Number.isFinite(value)) || typeof value === "string" || (Array.isArray(value) && value.every((item) => typeof item === "string")) ); } function validateUiPresets(value: unknown, errors: string[]): void { if (value === undefined) return; const where = "contributes.uiPresets"; if (!isPlainObject(value)) { errors.push(`Field "${where}" must be an object`); return; } rejectUnknown(value, ["simple", "balanced", "advanced"], where, errors); let keys: string | undefined; for (const level of ["simple", "balanced", "advanced"]) { const preset = value[level]; if (!isPlainObject(preset)) { errors.push(`${where}.${level} must be an object`); continue; } for (const [key, entry] of Object.entries(preset)) { if (!PRESET_KEY_PATTERN.test(key)) { errors.push( `${where}.${level} has an invalid key ${JSON.stringify(key)}`, ); } if (!isPresetValue(entry)) { errors.push( `${where}.${level}.${key} must be a boolean, number, string or string array`, ); } } const names = Object.keys(preset).sort().join(","); if (keys === undefined) keys = names; else if (keys !== names) { errors.push(`${where} presets must all name the same keys`); } } } const LEGACY_PATH_PATTERN = /^\/[a-z][a-z0-9-]*(\/[A-Za-z0-9_.-]+)+$/; const REDIRECT_PATH_PATTERN = /^(\/[A-Za-z0-9_.-]+)+$/; const RESERVED_PREFIXES = ["/plugin-api", "/plugin-ws", "/plugin-assets"]; function validateLegacyRedirects(value: unknown, errors: string[]): void { if (value === undefined) return; if (!Array.isArray(value)) { errors.push('Field "contributes.http.legacyRedirects" must be an array'); return; } value.forEach((entry, index) => { const at = `contributes.http.legacyRedirects[${index}]`; if (!isPlainObject(entry)) { errors.push(`${at} must be an object`); return; } rejectUnknown(entry, ["from", "to", "status"], at, errors); const { from, to, status } = entry; if (typeof from !== "string" || !REDIRECT_PATH_PATTERN.test(from)) { errors.push(`${at}.from must be an absolute path`); } else if ( RESERVED_PREFIXES.some( (prefix) => from === prefix || from.startsWith(`${prefix}/`), ) ) { errors.push(`${at}.from cannot be under ${from.split("/")[1]}`); } if (typeof to !== "string" || !REDIRECT_PATH_PATTERN.test(to)) { errors.push(`${at}.to must be a path in the plugin's router`); } if (status !== undefined && status !== 307 && status !== 308) { errors.push(`${at}.status must be 307 or 308`); } }); } function validateHttpContribution( value: unknown, pluginId: string | undefined, errors: string[], ): void { if (value === undefined) return; if (!isPlainObject(value)) { errors.push('Field "contributes.http" must be an object'); return; } rejectUnknown( value, ["legacyPaths", "legacyRedirects", "adminImpersonation"], "contributes.http", errors, ); if ( value.adminImpersonation !== undefined && typeof value.adminImpersonation !== "boolean" ) { errors.push( 'Field "contributes.http.adminImpersonation" must be a boolean', ); } validateLegacyRedirects(value.legacyRedirects, errors); const paths = value.legacyPaths; if (paths === undefined) return; if (!Array.isArray(paths)) { errors.push('Field "contributes.http.legacyPaths" must be an array'); return; } paths.forEach((path, index) => { const at = `contributes.http.legacyPaths[${index}]`; if (typeof path !== "string" || !LEGACY_PATH_PATTERN.test(path)) { errors.push(`${at} must be an absolute path like "//name"`); return; } if (pluginId && !path.startsWith(`/${pluginId}/`)) { errors.push(`${at} must start with "/${pluginId}/"`); } }); } const AUTH_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/; function validateAuthContribution(value: unknown, errors: string[]): void { if (value === undefined) return; if (!isPlainObject(value)) { errors.push('Field "contributes.auth" must be an object'); return; } rejectUnknown( value, [ "sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes", "keyboardInteractive", ], "contributes.auth", errors, ); for (const key of [ "sshAuthTypes", "loginMethods", "secondFactors", "secretSchemes", "keyboardInteractive", ]) { const list = value[key]; if (list === undefined) continue; if (!Array.isArray(list)) { errors.push(`Field "contributes.auth.${key}" must be an array`); continue; } const seen = new Set(); list.forEach((entry, index) => { if (typeof entry !== "string" || !AUTH_ID_PATTERN.test(entry)) { errors.push( `Field "contributes.auth.${key}[${index}]" must be a lowercase id (letters, digits and dashes)`, ); return; } if (seen.has(entry)) { errors.push(`Duplicate "${entry}" in contributes.auth.${key}`); } seen.add(entry); }); } } function validateSettings(settings: unknown, errors: string[]): void { if (settings === undefined) return; const where = "contributes.settings"; if (!isPlainObject(settings)) { errors.push(`${where} must be an object`); return; } rejectUnknown(settings, ["admin", "user", "host"], `"${where}"`, errors); for (const scope of ["admin", "user"] as const) { const value = settings[scope]; if (value === undefined) continue; if (!Array.isArray(value)) { errors.push(`${where}.${scope} must be an array of fields`); continue; } validateSettingsFields(value, `${where}.${scope}`, errors); } if (settings.host !== undefined) { const host = settings.host; const at = `${where}.host`; if (!isPlainObject(host)) { errors.push(`${at} must be an object`); return; } rejectUnknown( host, [ "enableKey", "enableLabelKey", "enableDescriptionKey", "enableDefault", "editorGroup", "editorOrder", "fields", ], `"${at}"`, errors, ); if ("enableKey" in host) { if ( typeof host.enableKey !== "string" || !SETTINGS_KEY_PATTERN.test(host.enableKey) ) { errors.push(`${at}.enableKey must be a short alphanumeric key`); } // An enable switch with no label is a blank row in the host editor. requireString(host.enableLabelKey, `${at}.enableLabelKey`, errors); } if ("enableDescriptionKey" in host) { requireString( host.enableDescriptionKey, `${at}.enableDescriptionKey`, errors, ); } if ("enableDefault" in host && typeof host.enableDefault !== "boolean") { errors.push(`${at}.enableDefault must be a boolean`); } if ( "editorGroup" in host && host.editorGroup !== "top" && host.editorGroup !== "ssh" ) { errors.push(`${at}.editorGroup must be "top" or "ssh"`); } if ( "editorOrder" in host && (typeof host.editorOrder !== "number" || !Number.isFinite(host.editorOrder)) ) { errors.push(`${at}.editorOrder must be a number`); } if (!Array.isArray(host.fields)) { errors.push(`${at}.fields must be an array`); return; } validateSettingsFields( host.fields, `${at}.fields`, errors, typeof host.enableKey === "string" ? host.enableKey : undefined, ); host.fields.forEach((raw, index) => { if (!isPlainObject(raw)) return; const fieldAt = `${at}.fields[${index}]`; if ("defaultFrom" in raw && typeof raw.defaultFrom !== "string") { errors.push(`${fieldAt}.defaultFrom must be a string`); } for (const flag of ["defaultable", "personal"]) { if (flag in raw && typeof raw[flag] !== "boolean") { errors.push(`${fieldAt}.${flag} must be a boolean`); } } if ("defaultLevels" in raw) { const levels = raw.defaultLevels; if ( !Array.isArray(levels) || levels.length === 0 || levels.some( (level) => !["admin", "user", "folder"].includes(level as string), ) ) { errors.push( `${fieldAt}.defaultLevels must be a non-empty array of "admin", "user" or "folder"`, ); } } if (raw.type === "secret" && raw.defaultable === true) { errors.push(`${fieldAt} is a secret and cannot be defaultable`); } if ( "shareRead" in raw && !SHARE_LEVELS.includes(raw.shareRead as string) ) { errors.push( `${fieldAt}.shareRead must be one of: ${SHARE_LEVELS.join(", ")}`, ); } if ("ownerOnly" in raw && typeof raw.ownerOnly !== "boolean") { errors.push(`${fieldAt}.ownerOnly must be a boolean`); } if ("secretKeys" in raw) { const keys = raw.secretKeys; if (raw.type !== "json") { errors.push(`${fieldAt}.secretKeys is only valid on json fields`); } else if ( !Array.isArray(keys) || keys.length === 0 || keys.some((key) => typeof key !== "string" || key.length === 0) ) { errors.push(`${fieldAt}.secretKeys must be a non-empty string array`); } } }); } for (const scope of ["admin", "user"] as const) { const fields = settings[scope]; if (!Array.isArray(fields)) continue; fields.forEach((raw, index) => { if (!isPlainObject(raw)) return; for (const key of [ "defaultFrom", "defaultable", "defaultLevels", "personal", "shareRead", "ownerOnly", ]) { if (key in raw) { errors.push( `${where}.${scope}[${index}].${key} is only valid on host fields`, ); } } }); } } /** * Validates one scope's fields. * * `requires` is checked against the keys declared in the same scope, plus the * host scope's enableKey, because a field pointing at a key that does not * exist would simply never render. */ function validateSettingsFields( fields: unknown[], where: string, errors: string[], enableKey?: string, ): void { const seen = new Set(); const booleanKeys = new Set(); if (enableKey) booleanKeys.add(enableKey); fields.forEach((raw, index) => { const at = `${where}[${index}]`; if (!isPlainObject(raw)) { errors.push(`${at} must be an object`); return; } rejectUnknown(raw, ALLOWED_SETTINGS_FIELD, at, errors); if (typeof raw.key !== "string" || !SETTINGS_KEY_PATTERN.test(raw.key)) { errors.push(`${at}.key must be a short alphanumeric key`); } else { if (seen.has(raw.key)) { errors.push(`${at}.key duplicates "${raw.key}"`); } seen.add(raw.key); if (raw.type === "boolean") booleanKeys.add(raw.key); if (enableKey && raw.key === enableKey) { errors.push( `${at}.key "${raw.key}" is already the section's enableKey`, ); } } if ( typeof raw.type !== "string" || !(SETTINGS_FIELD_TYPES as readonly string[]).includes(raw.type) ) { errors.push( `${at}.type must be one of: ${SETTINGS_FIELD_TYPES.join(", ")}`, ); return; } // A custom field draws its own label, so it needs a component instead. if (raw.type === "custom") { requireString(raw.component, `${at}.component`, errors); } else { requireString(raw.labelKey, `${at}.labelKey`, errors); if ("component" in raw) { errors.push(`${at}.component is only valid when type is "custom"`); } } for (const key of ["descriptionKey", "placeholderKey", "group"] as const) { if (key in raw) requireString(raw[key], `${at}.${key}`, errors); } if ("permission" in raw) { requireString(raw.permission, `${at}.permission`, errors); } if ("hidden" in raw && typeof raw.hidden !== "boolean") { errors.push(`${at}.hidden must be a boolean`); } if (raw.type === "select" || raw.type === "multiselect") { validateSettingsOptions(raw.options, at, errors); } else if ("options" in raw) { errors.push(`${at}.options is only valid for select and multiselect`); } if (raw.type === "number") { for (const bound of ["min", "max"] as const) { if (bound in raw && typeof raw[bound] !== "number") { errors.push(`${at}.${bound} must be a number`); } } if ( typeof raw.min === "number" && typeof raw.max === "number" && raw.min > raw.max ) { errors.push(`${at}.min must not be greater than ${at}.max`); } } else if ("min" in raw || "max" in raw) { errors.push(`${at}.min and ${at}.max are only valid for number fields`); } }); // Second pass: every key is known by now, so forward references are fine. fields.forEach((raw, index) => { if (!isPlainObject(raw) || !("requires" in raw)) return; const at = `${where}[${index}]`; if (typeof raw.requires !== "string") { errors.push(`${at}.requires must be a string`); return; } if (!booleanKeys.has(raw.requires)) { errors.push( `${at}.requires "${raw.requires}" must name a boolean field in the same scope`, ); } }); } function validateSettingsOptions( options: unknown, where: string, errors: string[], ): void { if (!Array.isArray(options) || options.length === 0) { errors.push(`${where}.options must be a non-empty array`); return; } const seen = new Set(); options.forEach((raw, index) => { const at = `${where}.options[${index}]`; if (!isPlainObject(raw)) { errors.push(`${at} must be an object`); return; } rejectUnknown(raw, ["value", "labelKey"], at, errors); if (requireString(raw.value, `${at}.value`, errors)) { if (seen.has(raw.value)) { errors.push(`${at}.value duplicates "${raw.value}"`); } seen.add(raw.value); } requireString(raw.labelKey, `${at}.labelKey`, errors); }); } function validateTabs(tabs: unknown, errors: string[]): void { if (tabs === undefined) return; if (!Array.isArray(tabs)) { errors.push('Field "contributes.tabs" must be an array'); return; } const seen = new Set(); tabs.forEach((raw, index) => { const where = `contributes.tabs[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["id", "titleKey", "icon", "openFrom"], where, errors); if (requireString(raw.id, `${where}.id`, errors)) { if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); seen.add(raw.id); } requireString(raw.titleKey, `${where}.titleKey`, errors); requireString(raw.icon, `${where}.icon`, errors); if (!Array.isArray(raw.openFrom) || raw.openFrom.length === 0) { errors.push(`${where}.openFrom must be a non-empty array`); return; } raw.openFrom.forEach((value: unknown, openIndex: number) => { if (!OPEN_FROM_VALUES.includes(value as never)) { errors.push( `${where}.openFrom[${openIndex}] must be one of: ${OPEN_FROM_VALUES.join(", ")}`, ); } }); }); } function validateViews(views: unknown, field: string, errors: string[]): void { if (views === undefined) return; if (!Array.isArray(views)) { errors.push(`Field "contributes.${field}" must be an array`); return; } const seen = new Set(); views.forEach((raw, index) => { const where = `contributes.${field}[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["id", "titleKey", "icon"], where, errors); if (requireString(raw.id, `${where}.id`, errors)) { if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); seen.add(raw.id); } requireString(raw.titleKey, `${where}.titleKey`, errors); if (raw.icon !== undefined) requireString(raw.icon, `${where}.icon`, errors); }); } function validatePermissions( permissions: unknown, pluginId: string | undefined, errors: string[], ): void { if (permissions === undefined) return; const where = "contributes.permissions"; if (!Array.isArray(permissions)) { errors.push(`${where} must be an array`); return; } const seen = new Set(); permissions.forEach((entry: unknown, index: number) => { const at = `${where}[${index}]`; if (!isPlainObject(entry)) { errors.push(`${at} must be an object`); return; } rejectUnknown( entry, ["name", "titleKey", "descriptionKey", "defaultRoles"], at, errors, ); const name = entry.name; if (typeof name !== "string" || !PERMISSION_NAME_PATTERN.test(name)) { errors.push(`${at}.name must be a lowercase, dotted permission name`); } else { if (seen.has(name)) { errors.push(`${at}.name "${name}" is declared more than once`); } seen.add(name); // Core groups first: a plugin naming one could gate a route on core // authority it was never given. const head = name.split(".")[0]; if ((RESERVED_PERMISSION_PREFIXES as readonly string[]).includes(head)) { errors.push( `${at}.name "${name}" starts with the reserved core group "${head}". Permissions are registered as ., so drop the prefix.`, ); } // "ai" declaring "ai.use" would register as ai.ai.use, which is always // a mistake rather than an intent. if (pluginId && (name === pluginId || head === pluginId)) { errors.push( `${at}.name "${name}" already starts with this plugin's id. Permissions are registered as ., so drop the prefix.`, ); } } requireString(entry.titleKey, `${at}.titleKey`, errors); requireString(entry.descriptionKey, `${at}.descriptionKey`, errors); if (entry.defaultRoles === undefined) return; if (!Array.isArray(entry.defaultRoles)) { errors.push(`${at}.defaultRoles must be an array`); return; } entry.defaultRoles.forEach((role: unknown, roleIndex: number) => { if ( typeof role !== "string" || !(SYSTEM_ROLE_NAMES as readonly string[]).includes(role) ) { errors.push( `${at}.defaultRoles[${roleIndex}] must be one of: ${SYSTEM_ROLE_NAMES.join(", ")}`, ); } }); }); } function validateActions(actions: unknown, errors: string[]): void { if (actions === undefined) return; if (!Array.isArray(actions)) { errors.push('Field "contributes.actions" must be an array'); return; } const seen = new Set(); actions.forEach((raw, index) => { const where = `contributes.actions[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown( raw, ["id", "titleKey", "handler", "icon", "permission", "slot", "kind"], where, errors, ); if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) { errors.push(`${where}.id must be a dotted action id`); } else { if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); seen.add(raw.id); } requireString(raw.titleKey, `${where}.titleKey`, errors); if (typeof raw.handler !== "string" || !HANDLER_PATTERN.test(raw.handler)) { errors.push(`${where}.handler must be a JavaScript identifier`); } if ("icon" in raw) requireString(raw.icon, `${where}.icon`, errors); if ("permission" in raw) { requireString(raw.permission, `${where}.permission`, errors); } if ("slot" in raw) requireString(raw.slot, `${where}.slot`, errors); if ( "kind" in raw && !ACTION_CONTRIBUTION_KINDS.includes(raw.kind as never) ) { errors.push( `${where}.kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, ); } }); } function validateActionSlots(slots: unknown, errors: string[]): void { if (slots === undefined) return; if (!Array.isArray(slots)) { errors.push('Field "contributes.actionSlots" must be an array'); return; } const seen = new Set(); slots.forEach((raw, index) => { const where = `contributes.actionSlots[${index}]`; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["id", "accepts", "descriptionKey"], where, errors); if (typeof raw.id !== "string" || !ACTION_ID_PATTERN.test(raw.id)) { errors.push(`${where}.id must be a dotted slot id`); } else { if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`); seen.add(raw.id); } if (!Array.isArray(raw.accepts) || raw.accepts.length === 0) { errors.push(`${where}.accepts must be a non-empty array`); } else { raw.accepts.forEach((kind: unknown, kindIndex: number) => { if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) { errors.push( `${where}.accepts[${kindIndex}] must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`, ); } }); } if ("descriptionKey" in raw) { requireString(raw.descriptionKey, `${where}.descriptionKey`, errors); } }); } function validateHostCapability(value: unknown, errors: string[]): void { if (value === undefined) return; const entries = Array.isArray(value) ? value : [value]; if (Array.isArray(value) && value.length === 0) { errors.push( 'Field "contributes.hostCapability" must not be an empty array', ); return; } const seen = new Set(); entries.forEach((raw, index) => { const where = Array.isArray(value) ? `contributes.hostCapability[${index}]` : "contributes.hostCapability"; if (!isPlainObject(raw)) { errors.push(`${where} must be an object`); return; } rejectUnknown(raw, ["key", "labelKey", "editorTab"], where, errors); if (requireString(raw.key, `${where}.key`, errors)) { if (seen.has(raw.key)) errors.push(`${where}.key duplicates "${raw.key}"`); seen.add(raw.key); } requireString(raw.labelKey, `${where}.labelKey`, errors); requireString(raw.editorTab, `${where}.editorTab`, errors); }); } export interface ParsedManifest { manifest?: PluginManifest; errors: string[]; } /** * Validates, then applies the cross-field rules a JSON schema cannot express * and fills in the entry-point defaults. */ export function parseManifest(raw: unknown): ParsedManifest { const errors = validateManifest(raw); if (errors.length > 0) return { errors }; const manifest = raw as PluginManifest; if (!isApiCompatible(manifest.engine.api)) { return { errors: [ `Plugin needs plugin API ${manifest.engine.api}, but this build implements ${PLUGIN_API_VERSION}`, ], }; } // Full ids: provides, providesSecret and actions all name permissions the // way an admin sees them, so they are compared against the qualified form. const declared = new Set( (manifest.contributes?.permissions ?? []).map((permission) => qualifyPermission(manifest.id, permission.name), ), ); // A permission the catalog never sees is one no admin can grant, so the // service or action would be invisible rather than denied. for (const provide of manifest.provides ?? []) { if (!declared.has(provide.permission)) { errors.push( `provides["${provide.service}"].permission "${provide.permission}" is not declared in contributes.permissions`, ); } } for (const secret of manifest.providesSecret ?? []) { if (!declared.has(secret.permission)) { errors.push( `providesSecret["${secret.key}"].permission "${secret.permission}" is not declared in contributes.permissions`, ); } } for (const action of manifest.contributes?.actions ?? []) { if (action.permission && !declared.has(action.permission)) { errors.push( `contributes.actions["${action.id}"].permission "${action.permission}" is not declared in contributes.permissions`, ); } } // A settings field may gate on one of this plugin's own permissions, or on // a core/other-plugin id used as given. Only the first form is checkable // here, and an undeclared one would hide the field from every admin. const settings = manifest.contributes?.settings; const settingsScopes: [string, PluginSettingsField[]][] = [ ["admin", settings?.admin ?? []], ["user", settings?.user ?? []], ["host", settings?.host?.fields ?? []], ]; for (const [scope, fields] of settingsScopes) { for (const field of fields) { if (!field.permission) continue; if (field.permission.includes(".")) continue; if (!declared.has(qualifyPermission(manifest.id, field.permission))) { errors.push( `contributes.settings.${scope} field "${field.key}" requires permission "${field.permission}", which is not declared in contributes.permissions`, ); } } } const auth = manifest.contributes?.auth; const contributesAuth = (auth?.sshAuthTypes?.length ?? 0) + (auth?.loginMethods?.length ?? 0) + (auth?.secondFactors?.length ?? 0) + (auth?.secretSchemes?.length ?? 0) + (auth?.keyboardInteractive?.length ?? 0) > 0; if (contributesAuth && !manifest.capabilities.includes("auth:provide")) { errors.push( 'contributes.auth needs the "auth:provide" capability in the capabilities array', ); } for (const secret of manifest.requiresSecret ?? []) { if (secret.plugin === manifest.id) { errors.push( `requiresSecret["${secret.key}"] points at this plugin; use ctx.secrets.get instead`, ); } } for (const dependency of Object.keys(manifest.dependencies ?? {})) { if (dependency === manifest.id) { errors.push(`dependencies["${dependency}"] points at this plugin`); } if (manifest.optionalDependencies?.[dependency]) { errors.push( `"${dependency}" is listed in both dependencies and optionalDependencies`, ); } } for (const dependency of Object.keys(manifest.optionalDependencies ?? {})) { if (dependency === manifest.id) { errors.push( `optionalDependencies["${dependency}"] points at this plugin`, ); } } if (errors.length > 0) return { errors }; return { manifest: { ...manifest, backend: manifest.backend ?? DEFAULT_BACKEND_ENTRY, frontend: manifest.frontend ?? DEFAULT_FRONTEND_ENTRY, locales: manifest.locales ?? DEFAULT_LOCALES_DIR, }, errors: [], }; }