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

+135
View File
@@ -0,0 +1,135 @@
/**
* The backend contract: what a plugin's activate(ctx) receives.
*
* These are types only. Core builds the real object in
* src/backend/plugins/ctx.ts and injects it at activation, so the SDK stays a
* description of the contract rather than an implementation a plugin could
* reach around.
*
* Members marked A3-A8 are not built yet. A plugin written against them today
* will type-check and fail at runtime, which is the intended signal.
*/
import type { PluginManifest } from "./manifest.js";
/**
* Thrown when a plugin calls a guarded member without the capability.
*
* Two things must be true: the manifest declares it, and it is granted. A
* grant for something the manifest never declared is ignored, so widening a
* plugin's reach always needs a new manifest the user can see.
*/
export class PluginCapabilityError extends Error {
readonly code = "EPLUGINCAP";
readonly pluginId: string;
readonly capability: string;
constructor(pluginId: string, capability: string) {
super(
`Plugin "${pluginId}" is not granted the "${capability}" capability. ` +
`Declare it in the manifest's capabilities array and grant it in the plugin's settings.`,
);
this.name = "PluginCapabilityError";
this.pluginId = pluginId;
this.capability = capability;
}
}
export interface PluginLogger {
debug: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string, error?: Error) => void;
}
export interface PluginEvents {
/** Topics are namespaced to plugin.<id>.* unless the plugin holds events:core. */
emit: (topic: string, payload: unknown) => void;
on: (topic: string, listener: (payload: unknown) => void) => () => void;
}
/** Small key/value state owned by this plugin. Requires kv:own. */
export interface PluginKeyValue {
get: (key: string) => Promise<unknown>;
set: (key: string, value: unknown) => Promise<void>;
delete: (key: string) => Promise<boolean>;
list: () => Promise<string[]>;
}
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
revoke: (key: string, value?: unknown) => boolean;
}
export interface PluginServices {
provide: <T extends object>(service: string, implementation: T) => void;
get: <T extends object>(service: string, options?: { userId?: string }) => T;
}
export interface PluginSecrets {
offer: (
key: string,
resolve: (userId: string) => Promise<string | null> | string | null,
) => void;
withdraw: (key: string) => boolean;
getShared: (
pluginId: string,
key: string,
options?: { userId?: string },
) => Promise<string | null>;
}
/**
* Anything the runtime should undo when the plugin deactivates. Everything
* registered through ctx is tracked automatically; this is for resources a
* plugin creates itself, such as a server or an interval.
*/
export interface PluginDisposables {
add: (dispose: () => void | Promise<void>) => void;
}
export interface PluginContext {
readonly pluginId: string;
readonly manifest: PluginManifest;
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly registry: PluginRegistry;
readonly services: PluginServices;
readonly secrets: PluginSecrets;
readonly disposables: PluginDisposables;
/**
* Runs `fn` with `userId` as the acting user, for background work that has
* no request behind it. Always audited, and every core API inside still
* applies that user's RBAC.
*
* This is the only way a plugin can name a user. Nothing else trusts a user
* id that came from plugin code.
*/
asUser: <T>(userId: string, fn: () => Promise<T> | T) => Promise<T>;
/** The acting user for the current call, when there is one. */
currentActor: () => string | undefined;
}
export type PluginActivate = (ctx: PluginContext) => void | Promise<void>;
export type PluginDeactivate = () => void | Promise<void>;
export interface PluginModule {
activate: PluginActivate;
deactivate?: PluginDeactivate;
}
/**
* Identity helper that gives a plugin entry its types without importing the
* runtime. Everything a plugin creates belongs inside activate: the module is
* cached by the ESM loader, so disable-then-enable re-runs activate on the
* same module object.
*/
export function definePlugin(plugin: PluginModule): PluginModule {
return plugin;
}
export type { PluginManifest } from "./manifest.js";
+97
View File
@@ -0,0 +1,97 @@
/**
* What a plugin's code may do.
*
* A capability is declared in the manifest and checked by the SDK method that
* needs it. It is not a user permission: those stay in core RBAC and are dotted
* (hosts.view), while capabilities are colon-separated (hosts:read) so the two
* can never be confused in a grant table or a log line.
*
* Adding one means an entry here plus the SDK method that checks it. The i18n
* keys resolve against core's en.json under plugins.capabilities.<id>.
*
* The copy those keys carry describes the consequence to the person deciding,
* not the mechanism. "Run commands on your servers", not "grants ssh:connect".
*/
export type CapabilityRisk = "low" | "medium" | "high" | "critical";
export interface CapabilityInfo {
id: string;
risk: CapabilityRisk;
/** i18n key for the short consequence, e.g. "Run commands on your servers". */
titleKey: string;
/** i18n key for the qualifying sentence under the title. */
consequenceKey: string;
}
function entry(id: string, risk: CapabilityRisk): CapabilityInfo {
return {
id,
risk,
titleKey: `plugins.capabilities.${id}.title`,
consequenceKey: `plugins.capabilities.${id}.consequence`,
};
}
/**
* Ordered critical -> low. The install and review UI reads this order directly
* so the worst line is never folded into a summary row.
*/
export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("credentials:read", "critical"),
entry("ssh:connect", "high"),
entry("process:spawn", "high"),
entry("users:write", "high"),
entry("auth:provide", "high"),
entry("system:tls", "high"),
entry("hosts:write", "medium"),
entry("credentials:use", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("hosts:read", "low"),
entry("db:own", "low"),
entry("kv:own", "low"),
entry("secrets:own", "low"),
entry("settings:read-core", "low"),
entry("ui:surface", "low"),
] as const;
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
(capability) => capability.id,
);
/**
* Kept as a string rather than a union of the catalog ids: a capability is
* data the runtime checks at load time, and a union here would force every
* caller holding a manifest value to cast.
*/
export type Capability = string;
const BY_ID = new Map(
CAPABILITY_CATALOG.map((capability) => [capability.id, capability]),
);
export function isKnownCapability(capability: string): boolean {
return BY_ID.has(capability);
}
export function getCapabilityInfo(
capability: string,
): CapabilityInfo | undefined {
return BY_ID.get(capability);
}
export const CAPABILITY_RISK_ORDER: readonly CapabilityRisk[] = [
"critical",
"high",
"medium",
"low",
];
+40
View File
@@ -0,0 +1,40 @@
/**
* The frontend contract: what a plugin's frontend entry receives.
*
* Stubs only. A7 builds the real loader and fills these in, at which point a
* plugin registers every surface it contributes through this object and the
* shell stops carrying plugin ids of its own.
*/
import type { PluginManifest } from "./manifest.js";
export interface TermixAppInfo {
readonly pluginId: string;
readonly manifest: PluginManifest;
}
/**
* Registration surface for a plugin frontend.
*
* A7 adds: rail items, panels, tabs, host editor sections, host actions and
* badges, context menu items, palette entries, dashboard cards, homepage
* widgets, settings components, action slots, login/2FA UI and SSH auth
* editors. Everything registered here is removed when the plugin is disabled.
*/
export interface TermixApp extends TermixAppInfo {
/** Registered cleanup, run when the plugin is disabled. */
onDispose: (dispose: () => void) => void;
}
export type FrontendActivate = (app: TermixApp) => void | Promise<void>;
export interface FrontendModule {
activate: FrontendActivate;
deactivate?: () => void | Promise<void>;
}
export function definePluginFrontend(plugin: FrontendModule): FrontendModule {
return plugin;
}
export type { PluginManifest } from "./manifest.js";
+840
View File
@@ -0,0 +1,840 @@
/**
* 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 SDK major version this build implements. engine.api is the real
* compatibility gate; engine.termix is a display string and is never enforced.
*/
export const SUPPORTED_PLUGIN_API_VERSION = "1";
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"] 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}$/;
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
const API_VERSION_PATTERN = /^[0-9]+$/;
/** 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_$]*$/;
const PERMISSION_PATTERN = /^[a-z0-9-]+(\.[a-z0-9_-]+)+$/;
export interface PluginAuthor {
name: string;
url?: string;
}
export interface PluginEngine {
/** Display string only, e.g. ">=2.9.0". Never enforced. */
termix: string;
/** SDK major version as a string. The real gate. */
api: string;
}
export interface HostCapabilityContribution {
key: string;
labelKey: string;
editorTab: string;
}
export interface PluginTabContribution {
id: string;
titleKey: string;
icon: string;
openFrom: string[];
}
export interface PluginPermissionGroup {
group: string;
permissions: string[];
/** Role name -> permissions. Only this plugin's own permissions are allowed. */
defaultForRole?: Record<string, string[]>;
}
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;
}
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 interface PluginContributions {
tabs?: PluginTabContribution[];
actions?: PluginActionContribution[];
actionSlots?: PluginActionSlot[];
permissionGroup?: PluginPermissionGroup;
/**
* 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[];
}
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<string, string>;
/** pluginId -> semver range. A missing one is normal. */
optionalDependencies?: Record<string, string>;
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[];
}
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",
]);
const ALLOWED_CONTRIBUTES = new Set([
"tabs",
"actions",
"actionSlots",
"permissionGroup",
"hostCapability",
]);
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Pushes an error for every key that is not in `allowed`. */
function rejectUnknown(
value: Record<string, unknown>,
allowed: Set<string> | 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}`);
}
}
}
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)}`,
);
}
}
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);
if ("backend" in m) requireString(m.backend, "backend", errors);
if ("frontend" in m) requireString(m.frontend, "frontend", errors);
if ("locales" in m) requireString(m.locales, "locales", errors);
validateAuthor(m.author, errors);
validateEngine(m.engine, errors);
validateCategory(m.category, errors);
validateCapabilities(m.capabilities, errors);
validatePlatforms(m.platforms, errors);
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, 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.api !== "string" || !API_VERSION_PATTERN.test(engine.api)) {
errors.push(
`Field "engine.api" must be an integer string, 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<string>();
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 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<string>();
provides.forEach((raw, index) => {
const where = `provides[${index}]`;
if (!isPlainObject(raw)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(raw, ["service", "version", "permission"], 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);
});
}
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<string>();
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, 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);
validatePermissionGroup(contributes.permissionGroup, errors);
validateActions(contributes.actions, errors);
validateActionSlots(contributes.actionSlots, errors);
validateHostCapability(contributes.hostCapability, 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<string>();
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 validatePermissionGroup(group: unknown, errors: string[]): void {
if (group === undefined) return;
const where = "contributes.permissionGroup";
if (!isPlainObject(group)) {
errors.push(`${where} must be an object`);
return;
}
rejectUnknown(
group,
["group", "permissions", "defaultForRole"],
where,
errors,
);
requireString(group.group, `${where}.group`, errors);
if (!Array.isArray(group.permissions) || group.permissions.length === 0) {
errors.push(`${where}.permissions must be a non-empty array`);
} else {
group.permissions.forEach((permission: unknown, index: number) => {
if (
typeof permission !== "string" ||
!PERMISSION_PATTERN.test(permission)
) {
errors.push(
`${where}.permissions[${index}] must be a dotted permission name`,
);
}
});
}
if (group.defaultForRole === undefined) return;
if (!isPlainObject(group.defaultForRole)) {
errors.push(
`${where}.defaultForRole must be an object of role -> permissions`,
);
return;
}
const declared = new Set(
Array.isArray(group.permissions)
? (group.permissions as unknown[]).filter(
(value): value is string => typeof value === "string",
)
: [],
);
for (const [role, wanted] of Object.entries(group.defaultForRole)) {
if (!Array.isArray(wanted)) {
errors.push(`${where}.defaultForRole["${role}"] must be an array`);
continue;
}
wanted.forEach((permission: unknown, index: number) => {
if (typeof permission !== "string") {
errors.push(
`${where}.defaultForRole["${role}"][${index}] must be a string`,
);
return;
}
// A plugin may only suggest defaults for permissions it declares itself.
// Without this a manifest could add admin.* to any role at boot.
if (!declared.has(permission)) {
errors.push(
`${where}.defaultForRole["${role}"][${index}] is "${permission}", which this plugin does not declare in contributes.permissionGroup.permissions`,
);
}
});
}
}
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<string>();
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<string>();
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<string>();
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 (manifest.engine.api !== SUPPORTED_PLUGIN_API_VERSION) {
return {
errors: [
`Plugin targets SDK API version ${manifest.engine.api}, but this build implements ${SUPPORTED_PLUGIN_API_VERSION}`,
],
};
}
const declared = new Set(
manifest.contributes?.permissionGroup?.permissions ?? [],
);
// 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.permissionGroup.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.permissionGroup.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.permissionGroup.permissions`,
);
}
}
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: [],
};
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Test helpers for plugin authors.
*
* A2 adds the shared vitest preset each plugin's config extends. For now this
* exposes the one thing a plugin test needs before that lands: a context
* double that satisfies the PluginContext contract without a running server.
*/
import type {
PluginContext,
PluginDisposables,
PluginLogger,
} from "./backend.js";
import type { PluginManifest } from "./manifest.js";
export interface FakeContextOptions {
pluginId?: string;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
}
export interface FakePluginContext {
ctx: PluginContext;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
kv: Map<string, unknown>;
}
function noopLogger(): PluginLogger {
return {
debug: () => {},
info: () => {},
warn: () => {},
error: () => {},
};
}
/**
* A context double good enough to call activate() against in a unit test.
* Nothing here is capability-checked: a test asserting a capability gate
* should use the real runtime, not this.
*/
export function createFakeContext(
options: FakeContextOptions = {},
): FakePluginContext {
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void | Promise<void>> = [];
const emitted: Array<{ topic: string; payload: unknown }> = [];
const kv = new Map<string, unknown>();
const listeners = new Map<string, Set<(payload: unknown) => void>>();
let actor = options.actor;
const manifest = {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities: [],
...options.manifest,
} as PluginManifest;
const disposables: PluginDisposables = {
add: (dispose) => disposals.push(dispose),
};
const ctx: PluginContext = {
pluginId,
manifest,
log: noopLogger(),
events: {
emit: (topic, payload) => {
emitted.push({ topic, payload });
for (const listener of listeners.get(topic) ?? []) listener(payload);
},
on: (topic, listener) => {
let set = listeners.get(topic);
if (!set) {
set = new Set();
listeners.set(topic, set);
}
set.add(listener);
return () => set!.delete(listener);
},
},
kv: {
get: async (key) => (kv.has(key) ? kv.get(key) : null),
set: async (key, value) => {
kv.set(key, value);
},
delete: async (key) => kv.delete(key),
list: async () => [...kv.keys()],
},
registry: {
provide: () => {},
consume: () => undefined,
revoke: () => false,
},
services: {
provide: () => {},
get: () => {
throw new Error("createFakeContext does not implement services.get");
},
},
secrets: {
offer: () => {},
withdraw: () => false,
getShared: async () => null,
},
disposables,
asUser: async (userId, fn) => {
const previous = actor;
actor = userId;
try {
return await fn();
} finally {
actor = previous;
}
},
currentActor: () => actor,
};
return { ctx, disposals, emitted, kv };
}