mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35: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
@@ -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";
|
||||
@@ -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",
|
||||
];
|
||||
@@ -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";
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user