feat: collapse plugin runtime to one tier behind a plugin SDK

This commit is contained in:
LukeGus committed 2026-09-22 14:11:50 -05:00
1 parent 6e058f6e9e
commit 4758016f41
96 files changed
+34631 -8551

No files matched your search

+47 -844
View File
@@ -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";