mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
feat: collapse plugin runtime to one tier behind a plugin SDK
This commit is contained in:
1 parent
6e058f6e9e
commit
4758016f41
96 files changed
+34631
-8551
No files matched your search
+47
-844
@@ -1,848 +1,51 @@
|
||||
/**
|
||||
* Runtime plugin manifest validation.
|
||||
* Manifest validation, re-exported from the SDK.
|
||||
*
|
||||
* This is a TypeScript port of scripts/validate-plugin-manifest.cjs. The two
|
||||
* are deliberately separate: the .cjs one is a build/authoring tool run from a
|
||||
* checkout, this one runs inside the server, where scripts/ does not exist
|
||||
* (the Docker image only ships dist/). Both enforce the same contract, which
|
||||
* is written down in scripts/plugin-manifest.schema.json.
|
||||
*
|
||||
* The enums below are duplicated from that schema rather than read from it at
|
||||
* runtime, for the same reason. If you change the schema, change both.
|
||||
* The contract lives in @termix/plugin-sdk/manifest so the server, the
|
||||
* authoring script and plugin authors all read the same rules. This file used
|
||||
* to carry its own copy of the enums and validators, which meant three places
|
||||
* had to be edited together and quietly drifted when they were not.
|
||||
*/
|
||||
|
||||
import semver from "semver";
|
||||
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
|
||||
|
||||
export const PLUGIN_PERMISSIONS = [
|
||||
"hosts.read",
|
||||
"hosts.write",
|
||||
"credentials.use",
|
||||
"ssh.exec",
|
||||
"ssh.sftp",
|
||||
"storage.own",
|
||||
"storage.secrets",
|
||||
"network.outbound",
|
||||
"events.read",
|
||||
"notify.send",
|
||||
"users.read",
|
||||
"process.sidecar",
|
||||
// Reserved for first-party plugins. parseManifest refuses it for any id not
|
||||
// on the hardcoded allowlist in first-party.ts, so declaring it does not
|
||||
// grant it.
|
||||
"process:transport-owner",
|
||||
"ui.tab",
|
||||
"ui.rail",
|
||||
"ui.card",
|
||||
"ui.settings",
|
||||
"ui.palette",
|
||||
] as const;
|
||||
|
||||
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
|
||||
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"Terminal",
|
||||
"Files & Transfer",
|
||||
"Infrastructure",
|
||||
"Monitoring",
|
||||
"Networking",
|
||||
"Access & Security",
|
||||
"Productivity",
|
||||
] as const;
|
||||
|
||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
||||
|
||||
/**
|
||||
* Contribution kinds a UI action slot can accept. Closed on purpose: a
|
||||
* free-form list would validate nothing. Adding a kind is a one-line edit
|
||||
* here and in the two other validators.
|
||||
*/
|
||||
export const ACTION_CONTRIBUTION_KINDS = ["button"] as const;
|
||||
|
||||
export type ActionContributionKind = (typeof ACTION_CONTRIBUTION_KINDS)[number];
|
||||
|
||||
const REQUIRED_TOP_LEVEL = [
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"author",
|
||||
"license",
|
||||
"engine",
|
||||
"category",
|
||||
"capabilities",
|
||||
"permissions",
|
||||
"sidecars",
|
||||
];
|
||||
|
||||
const ID_PATTERN = /^[a-z0-9-]+$/;
|
||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||
const API_PREFIX_PATTERN = /^[a-z0-9-]+$/;
|
||||
/** Service names are dotted, e.g. "ssh.transport" or "host-metrics.stream". */
|
||||
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||
/**
|
||||
* A shared secret key is a flat slug, e.g. "api-key". Not dotted: the plugin
|
||||
* id is already a separate field, so a dotted key would only invite encoding
|
||||
* the owner twice and disagreeing with itself.
|
||||
*/
|
||||
const SECRET_KEY_PATTERN = /^[a-z0-9-]+$/;
|
||||
/**
|
||||
* Action and slot ids are dotted like service names, but each segment may be
|
||||
* camelCase: these name a frontend function ("ai.openWithContext"), not a
|
||||
* lowercase service contract, so SERVICE_PATTERN is too strict to reuse.
|
||||
*/
|
||||
const ACTION_ID_PATTERN = /^[a-z0-9-]+(\.[a-zA-Z0-9-]+)+$/;
|
||||
/** A handler names a JS export, so it follows identifier rules, not dotted ones. */
|
||||
const HANDLER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
/**
|
||||
* The plugin SDK major version this build implements. A manifest asking for a
|
||||
* different engine.api is refused rather than loaded and hoped for -- this is
|
||||
* the real compatibility gate (engine.termix is informational only).
|
||||
*/
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = "1";
|
||||
|
||||
export interface PluginManifest {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
author: { name: string; url?: string };
|
||||
license: string;
|
||||
repository?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
engine: { termix: string; api: string };
|
||||
capabilities: {
|
||||
backend: boolean;
|
||||
frontend: boolean;
|
||||
electron: boolean;
|
||||
platforms: string[];
|
||||
};
|
||||
permissions: string[];
|
||||
contributes?: {
|
||||
tabs?: Array<{
|
||||
id: string;
|
||||
titleKey: string;
|
||||
icon: string;
|
||||
openFrom: string[];
|
||||
}>;
|
||||
hostCapability?: HostCapabilityContribution | HostCapabilityContribution[];
|
||||
permissionGroup?: {
|
||||
group: string;
|
||||
permissions: string[];
|
||||
defaultForRole?: Record<string, string[]>;
|
||||
};
|
||||
settingsPanel?: { titleKey: string };
|
||||
dashboardCards?: Array<{ id: string; titleKey: string }>;
|
||||
apiPrefix?: string;
|
||||
/** Named frontend actions this plugin exposes for other plugins to invoke. */
|
||||
actions?: PluginActionContribution[];
|
||||
/** Places this plugin offers other plugins to contribute a UI element. */
|
||||
actionSlots?: PluginActionSlot[];
|
||||
};
|
||||
dependencies?: { plugins?: Record<string, string> };
|
||||
/**
|
||||
* Named services this plugin exposes to other plugins. Each one is gated by
|
||||
* its own RBAC permission, which the plugin's own permissionGroup must
|
||||
* declare -- see parseManifest.
|
||||
*/
|
||||
provides?: PluginServiceProvide[];
|
||||
/** Named services this plugin needs another active plugin to provide. */
|
||||
requires?: PluginServiceRequire[];
|
||||
/**
|
||||
* Secrets this plugin offers other plugins by reference. Gated by one of
|
||||
* this plugin's own permissionGroup permissions -- see parseManifest.
|
||||
*/
|
||||
providesSecret?: PluginSecretProvide[];
|
||||
/** Secrets this plugin wants to borrow from another plugin, by reference. */
|
||||
requiresSecret?: PluginSecretRequire[];
|
||||
sidecars: Array<{ id: string; binary: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A host-level checkbox this plugin contributes to the host editor (e.g.
|
||||
* "Enable RDP"), backed by an existing boolean column on the host record.
|
||||
* Most plugins declare one; a plugin covering several independent protocols
|
||||
* on the same host (rdp/vnc/telnet) declares several -- see
|
||||
* contributes.hostCapability, which accepts either shape.
|
||||
*/
|
||||
export interface HostCapabilityContribution {
|
||||
key: string;
|
||||
labelKey: string;
|
||||
editorTab: string;
|
||||
}
|
||||
|
||||
export interface PluginServiceProvide {
|
||||
service: string;
|
||||
version: string;
|
||||
/** The role permission a calling user needs to reach this service. */
|
||||
permission: string;
|
||||
}
|
||||
|
||||
export interface PluginServiceRequire {
|
||||
service: string;
|
||||
versionRange: string;
|
||||
/** When true, an unsatisfied requirement is skipped instead of failing activation. */
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginSecretProvide {
|
||||
/** The secret's name within this plugin, e.g. "api-key". */
|
||||
key: string;
|
||||
/** The role permission a borrowing user needs. */
|
||||
permission: string;
|
||||
/** Shown in the admin UI so the sharing is legible without reading code. */
|
||||
descriptionKey?: string;
|
||||
}
|
||||
|
||||
export interface PluginSecretRequire {
|
||||
/** The plugin id that offers the secret. */
|
||||
plugin: string;
|
||||
/** The secret's name within that plugin. */
|
||||
key: string;
|
||||
/**
|
||||
* When true, the provider being absent is normal and activation proceeds.
|
||||
* A non-optional reference still activates -- the read just returns null --
|
||||
* but resolveSecretRequirements reports it as an error for install feedback.
|
||||
*/
|
||||
optional?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A named frontend action, declared here and bound at runtime.
|
||||
*
|
||||
* The split matters: this manifest entry is inert JSON the server can read and
|
||||
* audit without running any plugin code, while the function itself is supplied
|
||||
* in the browser by registerAction(id, fn). `handler` records which frontend
|
||||
* export is meant to end up under `id`, so the contract is readable from the
|
||||
* manifest alone and a future frontend loader can wire module[handler]
|
||||
* automatically. Today nothing reads `handler` at runtime.
|
||||
*/
|
||||
export interface PluginActionContribution {
|
||||
/** Dotted and globally unique, e.g. "ai.openWithContext". */
|
||||
id: string;
|
||||
titleKey: string;
|
||||
/** Name of the frontend export implementing this action. */
|
||||
handler: string;
|
||||
/** Lucide icon name, same convention as contributes.tabs[].icon. */
|
||||
icon?: string;
|
||||
/**
|
||||
* Role permission the acting user needs before the action is offered.
|
||||
* Optional: an ungated action renders for everyone. When set, parseManifest
|
||||
* requires it to be declared in this plugin's own permissionGroup.
|
||||
*/
|
||||
permission?: string;
|
||||
/** Slot this action is intended for. Advisory; binding happens at runtime. */
|
||||
slot?: string;
|
||||
kind?: ActionContributionKind;
|
||||
}
|
||||
|
||||
/**
|
||||
* A place this plugin offers other plugins to contribute a UI element.
|
||||
*
|
||||
* A contribution's `kind` is checked against `accepts` in the frontend at
|
||||
* registration time, not here: the slot is usually declared by a different
|
||||
* plugin, and a manifest is validated on its own.
|
||||
*/
|
||||
export interface PluginActionSlot {
|
||||
/** Dotted, e.g. "terminal.toolbar". */
|
||||
id: string;
|
||||
accepts: ActionContributionKind[];
|
||||
descriptionKey?: string;
|
||||
}
|
||||
|
||||
export function validateManifest(manifest: unknown): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
||||
return ["Manifest must be a JSON object"];
|
||||
}
|
||||
|
||||
const m = manifest as Record<string, unknown>;
|
||||
|
||||
for (const field of REQUIRED_TOP_LEVEL) {
|
||||
if (!(field in m)) {
|
||||
errors.push(`Missing required field: "${field}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof m.id === "string" && !ID_PATTERN.test(m.id)) {
|
||||
errors.push(`Field "id" must match ${ID_PATTERN}, got: "${m.id}"`);
|
||||
}
|
||||
|
||||
if (typeof m.version === "string" && !SEMVER_PATTERN.test(m.version)) {
|
||||
errors.push(`Field "version" must be valid semver, got: "${m.version}"`);
|
||||
}
|
||||
|
||||
if (m.author && typeof m.author === "object") {
|
||||
if (!(m.author as Record<string, unknown>).name) {
|
||||
errors.push('Field "author.name" is required');
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof m.category === "string") {
|
||||
if (!PLUGIN_CATEGORIES.includes(m.category as never)) {
|
||||
errors.push(
|
||||
`Field "category" must be one of: ${PLUGIN_CATEGORIES.join(", ")}, got: "${m.category}"`,
|
||||
);
|
||||
}
|
||||
} else if ("category" in m) {
|
||||
errors.push('Field "category" must be a string');
|
||||
}
|
||||
|
||||
if ("icon" in m && typeof m.icon !== "string") {
|
||||
errors.push('Field "icon" must be a string');
|
||||
}
|
||||
|
||||
if (m.engine && typeof m.engine === "object") {
|
||||
const engine = m.engine as Record<string, unknown>;
|
||||
if (!engine.termix) {
|
||||
errors.push('Field "engine.termix" is required');
|
||||
}
|
||||
if (!API_VERSION_PATTERN.test(String(engine.api))) {
|
||||
errors.push(
|
||||
`Field "engine.api" must be an integer-as-string, got: "${engine.api}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (m.capabilities && typeof m.capabilities === "object") {
|
||||
const caps = m.capabilities as Record<string, unknown>;
|
||||
for (const field of ["backend", "frontend", "electron"]) {
|
||||
if (typeof caps[field] !== "boolean") {
|
||||
errors.push(`Field "capabilities.${field}" must be a boolean`);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(caps.platforms)) {
|
||||
errors.push('Field "capabilities.platforms" must be an array');
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(m.permissions)) {
|
||||
for (const permission of m.permissions) {
|
||||
if (!PLUGIN_PERMISSIONS.includes(permission as never)) {
|
||||
errors.push(
|
||||
`Unknown permission: "${permission}". Known values: ${PLUGIN_PERMISSIONS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if ("permissions" in m) {
|
||||
errors.push('Field "permissions" must be an array');
|
||||
}
|
||||
|
||||
if (Array.isArray(m.sidecars)) {
|
||||
m.sidecars.forEach((sidecar: unknown, index: number) => {
|
||||
if (!sidecar || typeof sidecar !== "object") {
|
||||
errors.push(`sidecars[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const s = sidecar as Record<string, unknown>;
|
||||
if (!s.id) errors.push(`sidecars[${index}].id is required`);
|
||||
if (!s.binary) errors.push(`sidecars[${index}].binary is required`);
|
||||
});
|
||||
} else if ("sidecars" in m) {
|
||||
errors.push('Field "sidecars" must be an array');
|
||||
}
|
||||
|
||||
errors.push(...validateProvides(m.provides));
|
||||
errors.push(...validateRequires(m.requires));
|
||||
errors.push(...validateProvidesSecret(m.providesSecret));
|
||||
errors.push(...validateRequiresSecret(m.requiresSecret));
|
||||
|
||||
if (m.contributes && typeof m.contributes === "object") {
|
||||
errors.push(
|
||||
...validateContributes(m.contributes as Record<string, unknown>),
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateProvides(provides: unknown): string[] {
|
||||
if (provides === undefined) return [];
|
||||
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
provides.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`provides[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
|
||||
errors.push(
|
||||
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
|
||||
);
|
||||
} else if (seen.has(e.service)) {
|
||||
errors.push(`provides[${index}].service is a duplicate: "${e.service}"`);
|
||||
} else {
|
||||
seen.add(e.service);
|
||||
}
|
||||
|
||||
if (typeof e.version !== "string" || !SEMVER_PATTERN.test(e.version)) {
|
||||
errors.push(
|
||||
`provides[${index}].version must be valid semver, got: "${String(e.version)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof e.permission !== "string" || e.permission.length === 0) {
|
||||
errors.push(`provides[${index}].permission is required`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRequires(requires: unknown): string[] {
|
||||
if (requires === undefined) return [];
|
||||
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
requires.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`requires[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.service !== "string" || !SERVICE_PATTERN.test(e.service)) {
|
||||
errors.push(
|
||||
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${String(e.service)}"`,
|
||||
);
|
||||
} else if (seen.has(e.service)) {
|
||||
errors.push(`requires[${index}].service is a duplicate: "${e.service}"`);
|
||||
} else {
|
||||
seen.add(e.service);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof e.versionRange !== "string" ||
|
||||
semver.validRange(e.versionRange) === null
|
||||
) {
|
||||
errors.push(
|
||||
`requires[${index}].versionRange must be a valid semver range, got: "${String(e.versionRange)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if ("optional" in e && typeof e.optional !== "boolean") {
|
||||
errors.push(`requires[${index}].optional must be a boolean`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateProvidesSecret(providesSecret: unknown): string[] {
|
||||
if (providesSecret === undefined) return [];
|
||||
if (!Array.isArray(providesSecret)) {
|
||||
return ['Field "providesSecret" must be an array'];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
providesSecret.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`providesSecret[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
|
||||
errors.push(
|
||||
`providesSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
|
||||
);
|
||||
} else if (seen.has(e.key)) {
|
||||
errors.push(`providesSecret[${index}].key is a duplicate: "${e.key}"`);
|
||||
} else {
|
||||
seen.add(e.key);
|
||||
}
|
||||
|
||||
if (typeof e.permission !== "string" || e.permission.length === 0) {
|
||||
errors.push(`providesSecret[${index}].permission is required`);
|
||||
}
|
||||
|
||||
if (
|
||||
"descriptionKey" in e &&
|
||||
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
|
||||
) {
|
||||
errors.push(`providesSecret[${index}].descriptionKey must be a string`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRequiresSecret(requiresSecret: unknown): string[] {
|
||||
if (requiresSecret === undefined) return [];
|
||||
if (!Array.isArray(requiresSecret)) {
|
||||
return ['Field "requiresSecret" must be an array'];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
requiresSecret.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`requiresSecret[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.plugin !== "string" || !ID_PATTERN.test(e.plugin)) {
|
||||
errors.push(
|
||||
`requiresSecret[${index}].plugin must match ${ID_PATTERN}, got: "${String(e.plugin)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof e.key !== "string" || !SECRET_KEY_PATTERN.test(e.key)) {
|
||||
errors.push(
|
||||
`requiresSecret[${index}].key must match ${SECRET_KEY_PATTERN}, got: "${String(e.key)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof e.plugin === "string" && typeof e.key === "string") {
|
||||
const id = `${e.plugin}:${e.key}`;
|
||||
if (seen.has(id)) {
|
||||
errors.push(`requiresSecret[${index}] is a duplicate: "${id}"`);
|
||||
} else {
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if ("optional" in e && typeof e.optional !== "boolean") {
|
||||
errors.push(`requiresSecret[${index}].optional must be a boolean`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateContributes(contributes: Record<string, unknown>): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
if ("tabs" in contributes) {
|
||||
if (!Array.isArray(contributes.tabs)) {
|
||||
errors.push('Field "contributes.tabs" must be an array');
|
||||
} else {
|
||||
contributes.tabs.forEach((tab: unknown, index: number) => {
|
||||
const t = (tab ?? {}) as Record<string, unknown>;
|
||||
for (const field of ["id", "titleKey", "icon", "openFrom"]) {
|
||||
if (!(field in t)) {
|
||||
errors.push(`contributes.tabs[${index}].${field} is required`);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(t.openFrom)) {
|
||||
for (const value of t.openFrom) {
|
||||
if (!OPEN_FROM_VALUES.includes(value as string)) {
|
||||
errors.push(
|
||||
`contributes.tabs[${index}].openFrom has unknown value: "${value}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ("hostCapability" in contributes) {
|
||||
const raw = contributes.hostCapability;
|
||||
const entries = Array.isArray(raw) ? raw : [raw];
|
||||
const seenKeys = new Set<string>();
|
||||
entries.forEach((entry, index) => {
|
||||
const hc = (entry ?? {}) as Record<string, unknown>;
|
||||
const prefix = Array.isArray(raw)
|
||||
? `contributes.hostCapability[${index}]`
|
||||
: "contributes.hostCapability";
|
||||
for (const field of ["key", "labelKey", "editorTab"]) {
|
||||
if (!(field in hc)) {
|
||||
errors.push(`${prefix}.${field} is required`);
|
||||
}
|
||||
}
|
||||
if (typeof hc.key === "string") {
|
||||
if (seenKeys.has(hc.key)) {
|
||||
errors.push(`${prefix}.key is a duplicate: "${hc.key}"`);
|
||||
}
|
||||
seenKeys.add(hc.key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if ("permissionGroup" in contributes) {
|
||||
const group = (contributes.permissionGroup ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (!group.group) {
|
||||
errors.push("contributes.permissionGroup.group is required");
|
||||
}
|
||||
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
|
||||
errors.push(
|
||||
"contributes.permissionGroup.permissions must be a non-empty array",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("settingsPanel" in contributes) {
|
||||
const panel = (contributes.settingsPanel ?? {}) as Record<string, unknown>;
|
||||
if (!panel.titleKey) {
|
||||
errors.push("contributes.settingsPanel.titleKey is required");
|
||||
}
|
||||
}
|
||||
|
||||
if ("dashboardCards" in contributes) {
|
||||
if (!Array.isArray(contributes.dashboardCards)) {
|
||||
errors.push('Field "contributes.dashboardCards" must be an array');
|
||||
} else {
|
||||
contributes.dashboardCards.forEach((card: unknown, index: number) => {
|
||||
const c = (card ?? {}) as Record<string, unknown>;
|
||||
for (const field of ["id", "titleKey"]) {
|
||||
if (!(field in c)) {
|
||||
errors.push(
|
||||
`contributes.dashboardCards[${index}].${field} is required`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ("apiPrefix" in contributes) {
|
||||
if (
|
||||
typeof contributes.apiPrefix !== "string" ||
|
||||
!API_PREFIX_PATTERN.test(contributes.apiPrefix)
|
||||
) {
|
||||
errors.push(
|
||||
`Field "contributes.apiPrefix" must match ${API_PREFIX_PATTERN}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ("actions" in contributes) {
|
||||
errors.push(...validateActions(contributes.actions));
|
||||
}
|
||||
|
||||
if ("actionSlots" in contributes) {
|
||||
errors.push(...validateActionSlots(contributes.actionSlots));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateActions(actions: unknown): string[] {
|
||||
if (!Array.isArray(actions)) {
|
||||
return ['Field "contributes.actions" must be an array'];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
actions.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`contributes.actions[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
|
||||
errors.push(
|
||||
`contributes.actions[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
|
||||
);
|
||||
} else if (seen.has(e.id)) {
|
||||
errors.push(`contributes.actions[${index}].id is a duplicate: "${e.id}"`);
|
||||
} else {
|
||||
seen.add(e.id);
|
||||
}
|
||||
|
||||
if (typeof e.titleKey !== "string" || e.titleKey.length === 0) {
|
||||
errors.push(`contributes.actions[${index}].titleKey is required`);
|
||||
}
|
||||
|
||||
if (typeof e.handler !== "string" || !HANDLER_PATTERN.test(e.handler)) {
|
||||
errors.push(
|
||||
`contributes.actions[${index}].handler must match ${HANDLER_PATTERN}, got: "${String(e.handler)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if ("icon" in e && (typeof e.icon !== "string" || e.icon.length === 0)) {
|
||||
errors.push(`contributes.actions[${index}].icon must be a string`);
|
||||
}
|
||||
|
||||
if (
|
||||
"permission" in e &&
|
||||
(typeof e.permission !== "string" || e.permission.length === 0)
|
||||
) {
|
||||
errors.push(`contributes.actions[${index}].permission must be a string`);
|
||||
}
|
||||
|
||||
if (
|
||||
"slot" in e &&
|
||||
(typeof e.slot !== "string" || !ACTION_ID_PATTERN.test(e.slot))
|
||||
) {
|
||||
errors.push(
|
||||
`contributes.actions[${index}].slot must match ${ACTION_ID_PATTERN}, got: "${String(e.slot)}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if ("kind" in e && !ACTION_CONTRIBUTION_KINDS.includes(e.kind as never)) {
|
||||
errors.push(
|
||||
`contributes.actions[${index}].kind must be one of: ${ACTION_CONTRIBUTION_KINDS.join(", ")}, got: "${String(e.kind)}"`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateActionSlots(slots: unknown): string[] {
|
||||
if (!Array.isArray(slots)) {
|
||||
return ['Field "contributes.actionSlots" must be an array'];
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
slots.forEach((entry: unknown, index: number) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
errors.push(`contributes.actionSlots[${index}] must be an object`);
|
||||
return;
|
||||
}
|
||||
const e = entry as Record<string, unknown>;
|
||||
|
||||
if (typeof e.id !== "string" || !ACTION_ID_PATTERN.test(e.id)) {
|
||||
errors.push(
|
||||
`contributes.actionSlots[${index}].id must match ${ACTION_ID_PATTERN}, got: "${String(e.id)}"`,
|
||||
);
|
||||
} else if (seen.has(e.id)) {
|
||||
errors.push(
|
||||
`contributes.actionSlots[${index}].id is a duplicate: "${e.id}"`,
|
||||
);
|
||||
} else {
|
||||
seen.add(e.id);
|
||||
}
|
||||
|
||||
if (!Array.isArray(e.accepts) || e.accepts.length === 0) {
|
||||
errors.push(
|
||||
`contributes.actionSlots[${index}].accepts must be a non-empty array`,
|
||||
);
|
||||
} else {
|
||||
for (const kind of e.accepts) {
|
||||
if (!ACTION_CONTRIBUTION_KINDS.includes(kind as never)) {
|
||||
errors.push(
|
||||
`contributes.actionSlots[${index}].accepts has unknown value: "${String(kind)}". Known values: ${ACTION_CONTRIBUTION_KINDS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
"descriptionKey" in e &&
|
||||
(typeof e.descriptionKey !== "string" || e.descriptionKey.length === 0)
|
||||
) {
|
||||
errors.push(
|
||||
`contributes.actionSlots[${index}].descriptionKey must be a string`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and narrows in one step. Unlike validateManifest, this also
|
||||
* enforces the SDK compatibility gate, which only the runtime cares about --
|
||||
* the authoring-time validator has no single Termix build to check against.
|
||||
*/
|
||||
export function parseManifest(raw: unknown): {
|
||||
manifest?: PluginManifest;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors = validateManifest(raw);
|
||||
if (errors.length > 0) return { errors };
|
||||
|
||||
const manifest = raw as PluginManifest;
|
||||
if (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
|
||||
return {
|
||||
errors: [
|
||||
`Plugin targets SDK api version "${manifest.engine.api}", this Termix build implements "${SUPPORTED_PLUGIN_API_VERSION}"`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// A service permission has to be one the plugin's own permissionGroup
|
||||
// declares, or it would never reach the catalog and every call through it
|
||||
// would deny against a permission no admin can grant.
|
||||
const declaredPermissions =
|
||||
manifest.contributes?.permissionGroup?.permissions ?? [];
|
||||
for (const entry of manifest.provides ?? []) {
|
||||
if (!declaredPermissions.includes(entry.permission)) {
|
||||
return {
|
||||
errors: [
|
||||
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Same rule for a shared secret. This is what keeps sharing on the existing
|
||||
// RBAC grant path instead of becoming a separate mechanism: the permission
|
||||
// gating a borrowed secret is an ordinary role permission in the provider's
|
||||
// own group, editable per role or per user like any other.
|
||||
for (const entry of manifest.providesSecret ?? []) {
|
||||
if (!declaredPermissions.includes(entry.permission)) {
|
||||
return {
|
||||
errors: [
|
||||
`Secret "${entry.key}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// A plugin borrowing from itself would bypass nothing, but it is always a
|
||||
// mistake: its own secrets are reachable through ctx.secrets directly.
|
||||
for (const entry of manifest.requiresSecret ?? []) {
|
||||
if (entry.plugin === manifest.id) {
|
||||
return {
|
||||
errors: [
|
||||
`requiresSecret entry for "${entry.key}" names this plugin itself; use ctx.secrets.get instead`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Same rule for UI actions, for the same reason: a permission the catalog
|
||||
// never sees is one no admin can grant, so the action would be invisible
|
||||
// forever rather than merely denied.
|
||||
for (const action of manifest.contributes?.actions ?? []) {
|
||||
if (action.permission && !declaredPermissions.includes(action.permission)) {
|
||||
return {
|
||||
errors: [
|
||||
`Action "${action.id}" is gated by "${action.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Refused outright rather than ignored: a plugin that asked to run in-process
|
||||
// and was quietly downgraded to a worker would fail later in a confusing way,
|
||||
// and the manifest would still claim reach it does not have.
|
||||
if (
|
||||
manifest.permissions.includes(TRANSPORT_OWNER_CAPABILITY) &&
|
||||
!isFirstParty(manifest.id)
|
||||
) {
|
||||
return {
|
||||
errors: [
|
||||
`Permission "${TRANSPORT_OWNER_CAPABILITY}" is reserved for first-party plugins and cannot be declared by "${manifest.id}"`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return { manifest, errors: [] };
|
||||
}
|
||||
export {
|
||||
validateManifest,
|
||||
parseManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_PLATFORMS,
|
||||
ACTION_CONTRIBUTION_KINDS,
|
||||
DEFAULT_BACKEND_ENTRY,
|
||||
DEFAULT_FRONTEND_ENTRY,
|
||||
DEFAULT_LOCALES_DIR,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
PluginManifest,
|
||||
PluginAuthor,
|
||||
PluginEngine,
|
||||
PluginContributions,
|
||||
PluginTabContribution,
|
||||
PluginActionContribution,
|
||||
PluginActionSlot,
|
||||
PluginPermissionGroup,
|
||||
PluginServiceProvide,
|
||||
PluginServiceRequire,
|
||||
PluginSecretProvide,
|
||||
PluginSecretRequire,
|
||||
HostCapabilityContribution,
|
||||
ActionContributionKind,
|
||||
ParsedManifest,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
|
||||
export {
|
||||
CAPABILITY_CATALOG,
|
||||
CAPABILITY_IDS,
|
||||
isKnownCapability,
|
||||
getCapabilityInfo,
|
||||
} from "@termix/plugin-sdk/capabilities";
|
||||
|
||||
export type {
|
||||
Capability,
|
||||
CapabilityInfo,
|
||||
CapabilityRisk,
|
||||
} from "@termix/plugin-sdk/capabilities";
|
||||
Reference in new issue
Block a user