mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: add a permission-gated backend service registry for plugins
This commit is contained in:
1 parent
ee2d6b4697
commit
225f618337
17 files changed
+1755
-2
No files matched your search
Generated
+8
@@ -15,6 +15,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/ldapjs": "^3.0.6",
|
"@types/ldapjs": "^3.0.6",
|
||||||
|
"@types/semver": "^7.8.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^6.2.1",
|
"redis": "^6.2.1",
|
||||||
|
"semver": "^7.8.5",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"socks": "^2.8.10",
|
"socks": "^2.8.10",
|
||||||
@@ -7413,6 +7415,12 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/semver": {
|
||||||
|
"version": "7.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||||
|
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/send": {
|
"node_modules/@types/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.14.13",
|
"@tanstack/react-virtual": "^3.14.13",
|
||||||
"@types/compression": "^1.8.1",
|
"@types/compression": "^1.8.1",
|
||||||
"@types/ldapjs": "^3.0.6",
|
"@types/ldapjs": "^3.0.6",
|
||||||
|
"@types/semver": "^7.8.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"redis": "^6.2.1",
|
"redis": "^6.2.1",
|
||||||
|
"semver": "^7.8.5",
|
||||||
"serialport": "^13.0.0",
|
"serialport": "^13.0.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.4",
|
||||||
"socks": "^2.8.10",
|
"socks": "^2.8.10",
|
||||||
|
|||||||
@@ -24,7 +24,19 @@
|
|||||||
"icon": "Sparkles",
|
"icon": "Sparkles",
|
||||||
"openFrom": ["rail", "palette"]
|
"openFrom": ["rail", "palette"]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"permissionGroup": {
|
||||||
|
"group": "ai",
|
||||||
|
"permissions": [
|
||||||
|
"ai.use",
|
||||||
|
"ai.manage_providers",
|
||||||
|
"ai.apply_proposals",
|
||||||
|
"ai.services.use"
|
||||||
|
],
|
||||||
|
"defaultForRole": {
|
||||||
|
"admin": ["ai.services.use"]
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"sidecars": []
|
"sidecars": []
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-provider",
|
||||||
|
"name": "Sample Provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gates a service behind a permission its own group never declares.",
|
||||||
|
"author": {
|
||||||
|
"name": "Jane Doe"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"category": "Productivity",
|
||||||
|
"engine": {
|
||||||
|
"termix": ">=2.9.0",
|
||||||
|
"api": "1"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"backend": true,
|
||||||
|
"frontend": false,
|
||||||
|
"electron": false,
|
||||||
|
"platforms": ["linux"]
|
||||||
|
},
|
||||||
|
"permissions": ["hosts.read"],
|
||||||
|
"contributes": {
|
||||||
|
"permissionGroup": {
|
||||||
|
"group": "sampleprovider",
|
||||||
|
"permissions": ["sampleprovider.greet.use"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"service": "sampleprovider.greet",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"permission": "sampleprovider.undeclared"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sidecars": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"id": "sample-provider",
|
||||||
|
"name": "Sample Provider",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A plugin that both provides and requires a named service.",
|
||||||
|
"author": {
|
||||||
|
"name": "Jane Doe"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"category": "Productivity",
|
||||||
|
"engine": {
|
||||||
|
"termix": ">=2.9.0",
|
||||||
|
"api": "1"
|
||||||
|
},
|
||||||
|
"capabilities": {
|
||||||
|
"backend": true,
|
||||||
|
"frontend": false,
|
||||||
|
"electron": false,
|
||||||
|
"platforms": ["linux", "win32", "darwin"]
|
||||||
|
},
|
||||||
|
"permissions": ["hosts.read"],
|
||||||
|
"contributes": {
|
||||||
|
"permissionGroup": {
|
||||||
|
"group": "sampleprovider",
|
||||||
|
"permissions": ["sampleprovider.greet.use"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"provides": [
|
||||||
|
{
|
||||||
|
"service": "sampleprovider.greet",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"permission": "sampleprovider.greet.use"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requires": [
|
||||||
|
{
|
||||||
|
"service": "other.thing",
|
||||||
|
"versionRange": "^1.0.0",
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sidecars": []
|
||||||
|
}
|
||||||
@@ -224,6 +224,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"provides": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Named services this plugin exposes to other plugins.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["service", "version", "permission"],
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$",
|
||||||
|
"description": "Dotted service name, e.g. \"ssh.transport\"."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?(\\+[0-9A-Za-z-.]+)?$",
|
||||||
|
"description": "Semver version of this service contract."
|
||||||
|
},
|
||||||
|
"permission": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Role permission a calling user needs. Must also appear in contributes.permissionGroup.permissions."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Named services this plugin needs another active plugin to provide.",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["service", "versionRange"],
|
||||||
|
"properties": {
|
||||||
|
"service": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z0-9-]+(\\.[a-z0-9-]+)+$"
|
||||||
|
},
|
||||||
|
"versionRange": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"description": "Semver range, e.g. \"^1.2.0\"."
|
||||||
|
},
|
||||||
|
"optional": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "When true, an unsatisfied requirement is skipped instead of failing activation."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidecars": {
|
"sidecars": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@@ -10,12 +10,14 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const semver = require("semver");
|
||||||
|
|
||||||
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
|
const SCHEMA_PATH = path.join(__dirname, "plugin-manifest.schema.json");
|
||||||
|
|
||||||
const ID_PATTERN = /^[a-z0-9-]+$/;
|
const ID_PATTERN = /^[a-z0-9-]+$/;
|
||||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||||
|
const SERVICE_PATTERN = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/;
|
||||||
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
const OPEN_FROM_VALUES = ["rail", "host-context-menu", "palette"];
|
||||||
|
|
||||||
function loadSchema() {
|
function loadSchema() {
|
||||||
@@ -123,10 +125,116 @@ function validateManifest(manifest, schema) {
|
|||||||
errors.push('Field "sidecars" must be an array');
|
errors.push('Field "sidecars" must be an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errors.push(...validateProvides(manifest.provides));
|
||||||
|
errors.push(...validateRequires(manifest.requires));
|
||||||
|
|
||||||
if (manifest.contributes && typeof manifest.contributes === "object") {
|
if (manifest.contributes && typeof manifest.contributes === "object") {
|
||||||
errors.push(...validateContributes(manifest.contributes));
|
errors.push(...validateContributes(manifest.contributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A service permission has to be one the plugin's own permissionGroup
|
||||||
|
// declares, or no admin could ever grant it.
|
||||||
|
const declared = manifest.contributes?.permissionGroup?.permissions;
|
||||||
|
if (Array.isArray(manifest.provides) && Array.isArray(declared)) {
|
||||||
|
for (const entry of manifest.provides) {
|
||||||
|
if (entry && entry.permission && !declared.includes(entry.permission)) {
|
||||||
|
errors.push(
|
||||||
|
`Service "${entry.service}" is gated by "${entry.permission}", which is not declared in contributes.permissionGroup.permissions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProvides(provides) {
|
||||||
|
if (provides === undefined) return [];
|
||||||
|
if (!Array.isArray(provides)) return ['Field "provides" must be an array'];
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
provides.forEach((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(`provides[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof entry.service !== "string" ||
|
||||||
|
!SERVICE_PATTERN.test(entry.service)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`provides[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(entry.service)) {
|
||||||
|
errors.push(
|
||||||
|
`provides[${index}].service is a duplicate: "${entry.service}"`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
seen.add(entry.service);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof entry.version !== "string" ||
|
||||||
|
!SEMVER_PATTERN.test(entry.version)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`provides[${index}].version must be valid semver, got: "${entry.version}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof entry.permission !== "string" || entry.permission.length === 0) {
|
||||||
|
errors.push(`provides[${index}].permission is required`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRequires(requires) {
|
||||||
|
if (requires === undefined) return [];
|
||||||
|
if (!Array.isArray(requires)) return ['Field "requires" must be an array'];
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
requires.forEach((entry, index) => {
|
||||||
|
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||||
|
errors.push(`requires[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof entry.service !== "string" ||
|
||||||
|
!SERVICE_PATTERN.test(entry.service)
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`requires[${index}].service must match ${SERVICE_PATTERN}, got: "${entry.service}"`,
|
||||||
|
);
|
||||||
|
} else if (seen.has(entry.service)) {
|
||||||
|
errors.push(
|
||||||
|
`requires[${index}].service is a duplicate: "${entry.service}"`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
seen.add(entry.service);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof entry.versionRange !== "string" ||
|
||||||
|
semver.validRange(entry.versionRange) === null
|
||||||
|
) {
|
||||||
|
errors.push(
|
||||||
|
`requires[${index}].versionRange must be a valid semver range, got: "${entry.versionRange}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("optional" in entry && typeof entry.optional !== "boolean") {
|
||||||
|
errors.push(`requires[${index}].optional must be a boolean`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ describe("validate-plugin-manifest.cjs", () => {
|
|||||||
expect(output).toMatch(/Field "id" must match/);
|
expect(output).toMatch(/Field "id" must match/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a manifest declaring provides and requires", () => {
|
||||||
|
const { status, output } = runValidator("valid-services.json");
|
||||||
|
expect(status).toBe(0);
|
||||||
|
expect(output).toContain("Valid plugin manifest");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a service gated by an undeclared permission", () => {
|
||||||
|
const { status, output } = runValidator("invalid-service-permission.json");
|
||||||
|
expect(status).not.toBe(0);
|
||||||
|
expect(output).toContain("sampleprovider.undeclared");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a manifest with an unknown permission", () => {
|
it("rejects a manifest with an unknown permission", () => {
|
||||||
const { status, output } = runValidator("invalid-unknown-permission.json");
|
const { status, output } = runValidator("invalid-unknown-permission.json");
|
||||||
expect(status).not.toBe(0);
|
expect(status).not.toBe(0);
|
||||||
|
|||||||
@@ -771,7 +771,7 @@ function storageKey(raw: unknown): string {
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeDetails(method: string, args: unknown[]): string {
|
export function safeDetails(method: string, args: unknown[]): string {
|
||||||
const summary = args.map((arg) => {
|
const summary = args.map((arg) => {
|
||||||
if (typeof arg === "number" || typeof arg === "boolean") return arg;
|
if (typeof arg === "number" || typeof arg === "boolean") return arg;
|
||||||
if (typeof arg === "string") {
|
if (typeof arg === "string") {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
import { pluginLogger } from "../utils/logger.js";
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
import { pluginEvents } from "./events.js";
|
import { pluginEvents } from "./events.js";
|
||||||
import * as registry from "./registry.js";
|
import * as registry from "./registry.js";
|
||||||
|
import * as serviceRegistry from "./service-registry.js";
|
||||||
|
import type { ServiceRegistration } from "./service-registry.js";
|
||||||
|
import { safeDetails } from "./broker.js";
|
||||||
import type { PluginManifest } from "./manifest.js";
|
import type { PluginManifest } from "./manifest.js";
|
||||||
|
|
||||||
export interface InProcessPluginContext {
|
export interface InProcessPluginContext {
|
||||||
@@ -43,6 +46,18 @@ export interface InProcessPluginContext {
|
|||||||
consume: <T>(key: string) => T | undefined;
|
consume: <T>(key: string) => T | undefined;
|
||||||
revoke: (key: string, value?: unknown) => boolean;
|
revoke: (key: string, value?: unknown) => boolean;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* Declared service contracts between plugins. Unlike `registry` above, a
|
||||||
|
* service must be declared in the manifest and every call through a handle
|
||||||
|
* is checked against the acting user's role permissions.
|
||||||
|
*/
|
||||||
|
services: {
|
||||||
|
provide: <T extends object>(service: string, implementation: T) => void;
|
||||||
|
get: <T extends object>(
|
||||||
|
service: string,
|
||||||
|
options?: { userId?: string },
|
||||||
|
) => T;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +71,8 @@ export interface InProcessHandle {
|
|||||||
unsubscribers: Array<() => void>;
|
unsubscribers: Array<() => void>;
|
||||||
/** Registry keys this plugin provided, revoked on deactivate. */
|
/** Registry keys this plugin provided, revoked on deactivate. */
|
||||||
providedKeys: Array<{ key: string; value: unknown }>;
|
providedKeys: Array<{ key: string; value: unknown }>;
|
||||||
|
/** Declared services this plugin provided, revoked on deactivate. */
|
||||||
|
providedServices: ServiceRegistration[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginModule {
|
export interface PluginModule {
|
||||||
@@ -135,9 +152,76 @@ export function createInProcessContext(
|
|||||||
consume: (key) => registry.consume(key),
|
consume: (key) => registry.consume(key),
|
||||||
revoke: (key, value) => registry.revoke(key, value),
|
revoke: (key, value) => registry.revoke(key, value),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
services: {
|
||||||
|
provide: (service, implementation) => {
|
||||||
|
// Declared AND provided, the same shape the capability gate uses: a
|
||||||
|
// plugin cannot publish a service its manifest never mentioned.
|
||||||
|
const declared = manifest.provides?.find(
|
||||||
|
(entry) => entry.service === service,
|
||||||
|
);
|
||||||
|
if (!declared) {
|
||||||
|
throw new Error(
|
||||||
|
`Plugin ${pluginId} cannot provide service "${service}": it is not declared in the manifest's provides array`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = serviceRegistry.provideService({
|
||||||
|
service,
|
||||||
|
version: declared.version,
|
||||||
|
permission: declared.permission,
|
||||||
|
pluginId,
|
||||||
|
pluginName: manifest.name,
|
||||||
|
implementation: implementation as Record<string, unknown>,
|
||||||
|
});
|
||||||
|
handle.providedServices.push(registration);
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (service, options) =>
|
||||||
|
serviceRegistry.createServiceHandle(service, pluginId, {
|
||||||
|
resolveUserId: () => options?.userId,
|
||||||
|
hasPermission: async (userId, permission) => {
|
||||||
|
const { PermissionManager } =
|
||||||
|
await import("../utils/permission-manager.js");
|
||||||
|
return PermissionManager.getInstance().hasPermission(
|
||||||
|
userId,
|
||||||
|
permission,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
audit: (entry) => writeServiceAudit(entry),
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors PluginBroker.audit: attribution comes from the runtime, never from
|
||||||
|
* the plugin, and the details only ever record the shape of a call.
|
||||||
|
*/
|
||||||
|
async function writeServiceAudit(
|
||||||
|
entry: serviceRegistry.ServiceAuditEntry,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { logAudit } = await import("../utils/audit-logger.js");
|
||||||
|
await logAudit({
|
||||||
|
userId: entry.userId,
|
||||||
|
username: `plugin:${entry.consumerPluginId}`,
|
||||||
|
action: `plugin_service_${entry.registration.service.replace(/\./g, "_")}_${entry.method}`,
|
||||||
|
resourceType: "plugin",
|
||||||
|
resourceId: entry.registration.pluginId,
|
||||||
|
resourceName: entry.registration.pluginName,
|
||||||
|
details: safeDetails(
|
||||||
|
`${entry.registration.service}.${entry.method}`,
|
||||||
|
entry.args,
|
||||||
|
),
|
||||||
|
success: entry.success,
|
||||||
|
errorMessage: entry.errorMessage,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Auditing must never break the caller.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Undoes everything the ctx handed out. Safe to call more than once. */
|
/** Undoes everything the ctx handed out. Safe to call more than once. */
|
||||||
export async function disposeInProcessHandle(
|
export async function disposeInProcessHandle(
|
||||||
handle: InProcessHandle,
|
handle: InProcessHandle,
|
||||||
@@ -148,6 +232,13 @@ export async function disposeInProcessHandle(
|
|||||||
}
|
}
|
||||||
handle.providedKeys = [];
|
handle.providedKeys = [];
|
||||||
|
|
||||||
|
// Taking the services with it is what makes a consumer's held handle start
|
||||||
|
// throwing PluginServiceUnavailableError rather than calling a dead provider.
|
||||||
|
for (const registration of handle.providedServices) {
|
||||||
|
serviceRegistry.revokeService(registration.service, registration);
|
||||||
|
}
|
||||||
|
handle.providedServices = [];
|
||||||
|
|
||||||
for (const unsubscribe of handle.unsubscribers) {
|
for (const unsubscribe of handle.unsubscribers) {
|
||||||
try {
|
try {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
|
|||||||
@@ -254,11 +254,118 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
|
|||||||
return loaded;
|
return loaded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Puts a plugin's declared permissions into the role catalog.
|
||||||
|
*
|
||||||
|
* Until this ran, contributes.permissionGroup was validated but never
|
||||||
|
* consumed, so a plugin permission could not be granted: PUT /rbac/roles/:id
|
||||||
|
* rejects any string isValidPermission does not know. Registering here is what
|
||||||
|
* makes a plugin permission appear in the admin role editor exactly like
|
||||||
|
* hosts.view does.
|
||||||
|
*/
|
||||||
|
async function registerPluginPermissions(plugin: LoadedPlugin): Promise<void> {
|
||||||
|
const group = plugin.manifest.contributes?.permissionGroup;
|
||||||
|
if (!group) return;
|
||||||
|
|
||||||
|
const { registerPermissionGroup } =
|
||||||
|
await import("../utils/permission-catalog.js");
|
||||||
|
registerPermissionGroup({
|
||||||
|
group: group.group,
|
||||||
|
permissions: group.permissions,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (group.defaultForRole) {
|
||||||
|
await applyRoleDefaults(plugin.id, group.defaultForRole);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the plugin's declared role suggestion.
|
||||||
|
*
|
||||||
|
* Additive only, and only for a permission the role does not already list.
|
||||||
|
* An admin's later revoke must never be re-granted on the next restart, which
|
||||||
|
* is the same rule ensureSystemRoles follows for the built-in roles.
|
||||||
|
*/
|
||||||
|
async function applyRoleDefaults(
|
||||||
|
pluginId: string,
|
||||||
|
defaults: Record<string, string[]>,
|
||||||
|
): Promise<void> {
|
||||||
|
const { createCurrentRoleRepository } =
|
||||||
|
await import("../database/repositories/factory.js");
|
||||||
|
const { PermissionManager } = await import("../utils/permission-manager.js");
|
||||||
|
const repository = createCurrentRoleRepository();
|
||||||
|
|
||||||
|
for (const [roleName, wanted] of Object.entries(defaults)) {
|
||||||
|
try {
|
||||||
|
const role = await repository.findRoleByName(roleName);
|
||||||
|
if (!role) continue;
|
||||||
|
|
||||||
|
let current: unknown;
|
||||||
|
try {
|
||||||
|
current = role.permissions ? JSON.parse(role.permissions) : [];
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(current)) continue;
|
||||||
|
|
||||||
|
// A wildcard the role already holds covers these, so adding them would
|
||||||
|
// only be noise in the editor.
|
||||||
|
const missing = wanted.filter(
|
||||||
|
(permission) => !coveredBy(current as string[], permission),
|
||||||
|
);
|
||||||
|
if (missing.length === 0) continue;
|
||||||
|
|
||||||
|
await repository.updateRole(role.id, {
|
||||||
|
permissions: JSON.stringify([...(current as string[]), ...missing]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberIds = await repository.listRoleUserIds(role.id);
|
||||||
|
for (const memberId of memberIds) {
|
||||||
|
PermissionManager.getInstance().invalidateUserPermissionCache(memberId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Could not apply ${pluginId} role defaults for ${roleName}: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`,
|
||||||
|
{ operation: "plugin_permissions" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coveredBy(permissions: string[], permission: string): boolean {
|
||||||
|
if (permissions.includes("*") || permissions.includes(permission)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const parts = permission.split(".");
|
||||||
|
for (let i = parts.length; i > 0; i--) {
|
||||||
|
if (permissions.includes(`${parts.slice(0, i).join(".")}.*`)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export async function activatePlugin(
|
export async function activatePlugin(
|
||||||
pluginId: string,
|
pluginId: string,
|
||||||
ownerUserId?: string,
|
ownerUserId?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
|
const { loader: pluginLoader, broker: pluginBroker } = getPluginRuntime();
|
||||||
|
|
||||||
|
// Before activate: a plugin's own activate() may already want to check one
|
||||||
|
// of its permissions.
|
||||||
|
const plugin = pluginLoader.get(pluginId);
|
||||||
|
if (plugin) {
|
||||||
|
try {
|
||||||
|
await registerPluginPermissions(plugin);
|
||||||
|
} catch (error) {
|
||||||
|
pluginLogger.error(
|
||||||
|
`Failed to register permissions for ${pluginId}`,
|
||||||
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
|
{ operation: "plugin_permissions" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await pluginLoader.activate(pluginId, ownerUserId);
|
await pluginLoader.activate(pluginId, ownerUserId);
|
||||||
|
|
||||||
const runtime = pluginBroker.get(pluginId);
|
const runtime = pluginBroker.get(pluginId);
|
||||||
@@ -267,8 +374,17 @@ export async function activatePlugin(
|
|||||||
|
|
||||||
export async function deactivatePlugin(pluginId: string): Promise<void> {
|
export async function deactivatePlugin(pluginId: string): Promise<void> {
|
||||||
const { loader: pluginLoader } = getPluginRuntime();
|
const { loader: pluginLoader } = getPluginRuntime();
|
||||||
|
const plugin = pluginLoader.get(pluginId);
|
||||||
|
|
||||||
await pluginLoader.deactivate(pluginId);
|
await pluginLoader.deactivate(pluginId);
|
||||||
unregisterPluginRouter(pluginId);
|
unregisterPluginRouter(pluginId);
|
||||||
|
|
||||||
|
const group = plugin?.manifest.contributes?.permissionGroup;
|
||||||
|
if (group) {
|
||||||
|
const { unregisterPermissionGroup } =
|
||||||
|
await import("../utils/permission-catalog.js");
|
||||||
|
unregisterPermissionGroup(group.group);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function shutdownPlugins(): Promise<void> {
|
export async function shutdownPlugins(): Promise<void> {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
type InProcessHandle,
|
type InProcessHandle,
|
||||||
type PluginModule,
|
type PluginModule,
|
||||||
} from "./host-ctx.js";
|
} from "./host-ctx.js";
|
||||||
|
import { resolveRequirements } from "./service-registry.js";
|
||||||
|
|
||||||
export type PluginState =
|
export type PluginState =
|
||||||
| "loaded"
|
| "loaded"
|
||||||
@@ -346,10 +347,25 @@ export class PluginLoader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Structural only: is the service present at a satisfying version. A
|
||||||
|
// user's permission is checked per call instead, because activation is
|
||||||
|
// per-instance and permissions are per-user.
|
||||||
|
const resolution = resolveRequirements(plugin.manifest);
|
||||||
|
if (!resolution.satisfied) {
|
||||||
|
throw new Error(`Plugin ${plugin.id} ${resolution.errors.join("; ")}`);
|
||||||
|
}
|
||||||
|
for (const service of resolution.missingOptional) {
|
||||||
|
pluginLogger.info(
|
||||||
|
`Plugin ${plugin.id} optional service "${service}" is not available`,
|
||||||
|
{ operation: "plugin_activate" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handle: InProcessHandle = {
|
const handle: InProcessHandle = {
|
||||||
module: { activate, deactivate },
|
module: { activate, deactivate },
|
||||||
unsubscribers: [],
|
unsubscribers: [],
|
||||||
providedKeys: [],
|
providedKeys: [],
|
||||||
|
providedServices: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const ctx = createInProcessContext(plugin.manifest, handle);
|
const ctx = createInProcessContext(plugin.manifest, handle);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
* runtime, for the same reason. If you change the schema, change both.
|
* runtime, for the same reason. If you change the schema, change both.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import semver from "semver";
|
||||||
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
|
import { isFirstParty, TRANSPORT_OWNER_CAPABILITY } from "./first-party.js";
|
||||||
|
|
||||||
export const PLUGIN_PERMISSIONS = [
|
export const PLUGIN_PERMISSIONS = [
|
||||||
@@ -69,6 +70,8 @@ const ID_PATTERN = /^[a-z0-9-]+$/;
|
|||||||
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z-.]+)?(\+[0-9A-Za-z-.]+)?$/;
|
||||||
const API_VERSION_PATTERN = /^[0-9]+$/;
|
const API_VERSION_PATTERN = /^[0-9]+$/;
|
||||||
const API_PREFIX_PATTERN = /^[a-z0-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-]+)+$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The plugin SDK major version this build implements. A manifest asking for a
|
* The plugin SDK major version this build implements. A manifest asking for a
|
||||||
@@ -113,9 +116,31 @@ export interface PluginManifest {
|
|||||||
apiPrefix?: string;
|
apiPrefix?: string;
|
||||||
};
|
};
|
||||||
dependencies?: { plugins?: Record<string, string> };
|
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[];
|
||||||
sidecars: Array<{ id: string; binary: string }>;
|
sidecars: Array<{ id: string; binary: 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 function validateManifest(manifest: unknown): string[] {
|
export function validateManifest(manifest: unknown): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
@@ -209,6 +234,9 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
errors.push('Field "sidecars" must be an array');
|
errors.push('Field "sidecars" must be an array');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
errors.push(...validateProvides(m.provides));
|
||||||
|
errors.push(...validateRequires(m.requires));
|
||||||
|
|
||||||
if (m.contributes && typeof m.contributes === "object") {
|
if (m.contributes && typeof m.contributes === "object") {
|
||||||
errors.push(
|
errors.push(
|
||||||
...validateContributes(m.contributes as Record<string, unknown>),
|
...validateContributes(m.contributes as Record<string, unknown>),
|
||||||
@@ -218,6 +246,85 @@ export function validateManifest(manifest: unknown): string[] {
|
|||||||
return errors;
|
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 validateContributes(contributes: Record<string, unknown>): string[] {
|
function validateContributes(contributes: Record<string, unknown>): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
@@ -331,6 +438,21 @@ export function parseManifest(raw: unknown): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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`,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refused outright rather than ignored: a plugin that asked to run in-process
|
// 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 was quietly downgraded to a worker would fail later in a confusing way,
|
||||||
// and the manifest would still claim reach it does not have.
|
// and the manifest would still claim reach it does not have.
|
||||||
|
|||||||
@@ -0,0 +1,414 @@
|
|||||||
|
/**
|
||||||
|
* Declared, versioned, per-user-permissioned service contracts between plugins.
|
||||||
|
*
|
||||||
|
* This is the typed sibling of registry.ts. That one is a bare key/value map a
|
||||||
|
* plugin can put anything into; this one only carries services a manifest
|
||||||
|
* declared, at a declared version, behind a declared role permission.
|
||||||
|
*
|
||||||
|
* ## What the permission actually gates
|
||||||
|
*
|
||||||
|
* Be precise, because there are two unrelated permission systems in this
|
||||||
|
* runtime and conflating them is the easy mistake:
|
||||||
|
*
|
||||||
|
* - permissions.ts gates what a PLUGIN may do (manifest + admin grant).
|
||||||
|
* - this file gates what a USER may do, through PermissionManager, the same
|
||||||
|
* RBAC check requirePermission() runs on an HTTP route.
|
||||||
|
*
|
||||||
|
* A service permission is an ordinary role permission string. It shows up in
|
||||||
|
* the admin role editor like hosts.view does, and an admin can grant or revoke
|
||||||
|
* it per role or per user at any time, whether or not the consuming plugin is
|
||||||
|
* installed.
|
||||||
|
*
|
||||||
|
* The check runs on every call rather than once when the handle is made. That
|
||||||
|
* is deliberate and is the whole reason the gate lives in the proxy: a revoke
|
||||||
|
* mid-session has to take effect on the next call, with no restart and no
|
||||||
|
* reinstall. PermissionManager's per-user cache is already invalidated by the
|
||||||
|
* RBAC routes that change a role, so "next call" is immediate in practice.
|
||||||
|
*
|
||||||
|
* ## Resolution is structural, permission is per-call
|
||||||
|
*
|
||||||
|
* resolveRequirements only asks "is this service present at a satisfying
|
||||||
|
* version". It deliberately does NOT consult any user's permissions:
|
||||||
|
* activation is per-instance and permissions are per-user, so gating
|
||||||
|
* activation on one user's grants would be meaningless. A user who lacks the
|
||||||
|
* permission gets a clear denial from the call instead of the requiring plugin
|
||||||
|
* mysteriously failing to start.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import semver from "semver";
|
||||||
|
import { pluginLogger } from "../utils/logger.js";
|
||||||
|
import type { PluginManifest, PluginServiceRequire } from "./manifest.js";
|
||||||
|
|
||||||
|
export interface ServiceRegistration {
|
||||||
|
service: string;
|
||||||
|
version: string;
|
||||||
|
permission: string;
|
||||||
|
/** The plugin that provided it. */
|
||||||
|
pluginId: string;
|
||||||
|
/** Display name, for audit lines. */
|
||||||
|
pluginName: string;
|
||||||
|
implementation: Record<string, unknown>;
|
||||||
|
/**
|
||||||
|
* Bumped on every provide. A handle captures the value it was made against,
|
||||||
|
* so a provider that was revoked and re-registered invalidates old handles
|
||||||
|
* rather than silently rebinding them to a different object.
|
||||||
|
*/
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const services = new Map<string, ServiceRegistration>();
|
||||||
|
/**
|
||||||
|
* What a service looked like before it was revoked.
|
||||||
|
*
|
||||||
|
* A handle taken while the provider was up must keep throwing a named error
|
||||||
|
* after it goes away, rather than degrading to "undefined is not a function",
|
||||||
|
* which would tell a consumer nothing about why its dependency vanished.
|
||||||
|
*/
|
||||||
|
const retired = new Map<string, { pluginId: string; methods: Set<string> }>();
|
||||||
|
let generationCounter = 0;
|
||||||
|
|
||||||
|
/** Thrown when the provider went away while a consumer still held a handle. */
|
||||||
|
export class PluginServiceUnavailableError extends Error {
|
||||||
|
readonly code = "EPLUGINSVCGONE";
|
||||||
|
|
||||||
|
constructor(service: string, providerPluginId: string) {
|
||||||
|
super(
|
||||||
|
`Service "${service}" is no longer provided (was: ${providerPluginId}). ` +
|
||||||
|
`The providing plugin has been disabled or replaced.`,
|
||||||
|
);
|
||||||
|
this.name = "PluginServiceUnavailableError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when the acting user lacks the service's permission.
|
||||||
|
*
|
||||||
|
* `status` and `body` are the same 403 shape requirePermission() sends, so a
|
||||||
|
* consumer serving an HTTP request can pass it straight through.
|
||||||
|
*/
|
||||||
|
export class PluginServicePermissionError extends Error {
|
||||||
|
readonly code = "EPLUGINSVCPERM";
|
||||||
|
readonly status = 403;
|
||||||
|
readonly body: { error: string; required: string };
|
||||||
|
|
||||||
|
constructor(service: string, permission: string) {
|
||||||
|
super(
|
||||||
|
`You do not have access to "${service}". It requires the "${permission}" permission.`,
|
||||||
|
);
|
||||||
|
this.name = "PluginServicePermissionError";
|
||||||
|
this.body = { error: "Insufficient permissions", required: permission };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thrown when a call cannot be attributed to a user. */
|
||||||
|
export class PluginServiceActorError extends Error {
|
||||||
|
readonly code = "EPLUGINSVCACTOR";
|
||||||
|
|
||||||
|
constructor(service: string) {
|
||||||
|
super(
|
||||||
|
`A call to "${service}" has no acting user. Pass one with ` +
|
||||||
|
`ctx.services.get(name, { userId }) or handle.asUser(userId).`,
|
||||||
|
);
|
||||||
|
this.name = "PluginServiceActorError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function provideService(
|
||||||
|
registration: Omit<ServiceRegistration, "generation">,
|
||||||
|
): ServiceRegistration {
|
||||||
|
const existing = services.get(registration.service);
|
||||||
|
if (existing) {
|
||||||
|
pluginLogger.warn(
|
||||||
|
`Service "${registration.service}" is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`,
|
||||||
|
{ operation: "plugin_service_registry" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry: ServiceRegistration = {
|
||||||
|
...registration,
|
||||||
|
generation: ++generationCounter,
|
||||||
|
};
|
||||||
|
services.set(registration.service, entry);
|
||||||
|
retired.delete(registration.service);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a provider, but only the exact registration given. A plugin that
|
||||||
|
* crashed and restarted must not revoke the replacement its own restart
|
||||||
|
* installed, so this is identity-checked rather than by name.
|
||||||
|
*/
|
||||||
|
export function revokeService(
|
||||||
|
service: string,
|
||||||
|
registration?: ServiceRegistration,
|
||||||
|
): boolean {
|
||||||
|
const current = services.get(service);
|
||||||
|
if (!current) return false;
|
||||||
|
if (registration && current.generation !== registration.generation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
retired.set(service, {
|
||||||
|
pluginId: current.pluginId,
|
||||||
|
methods: new Set(
|
||||||
|
Object.keys(current.implementation).filter(
|
||||||
|
(key) => typeof current.implementation[key] === "function",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return services.delete(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegistration(
|
||||||
|
service: string,
|
||||||
|
): ServiceRegistration | undefined {
|
||||||
|
return services.get(service);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listServices(): ServiceRegistration[] {
|
||||||
|
return [...services.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam. */
|
||||||
|
export function clearServiceRegistry(): void {
|
||||||
|
services.clear();
|
||||||
|
retired.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequirementResolution {
|
||||||
|
satisfied: boolean;
|
||||||
|
/** Human-readable reasons for each unsatisfied hard requirement. */
|
||||||
|
errors: string[];
|
||||||
|
/** Optional requirements that are not currently available. */
|
||||||
|
missingOptional: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural resolution of a manifest's `requires` against what is registered
|
||||||
|
* right now. Generic over the service name -- nothing is special-cased.
|
||||||
|
*/
|
||||||
|
export function resolveRequirements(
|
||||||
|
manifest: PluginManifest,
|
||||||
|
): RequirementResolution {
|
||||||
|
const errors: string[] = [];
|
||||||
|
const missingOptional: string[] = [];
|
||||||
|
|
||||||
|
for (const requirement of manifest.requires ?? []) {
|
||||||
|
const registration = services.get(requirement.service);
|
||||||
|
|
||||||
|
if (!registration) {
|
||||||
|
recordUnsatisfied(
|
||||||
|
requirement,
|
||||||
|
`requires service "${requirement.service}" (${requirement.versionRange}), which no active plugin provides`,
|
||||||
|
errors,
|
||||||
|
missingOptional,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!semver.satisfies(registration.version, requirement.versionRange)) {
|
||||||
|
recordUnsatisfied(
|
||||||
|
requirement,
|
||||||
|
`requires service "${requirement.service}" ${requirement.versionRange}, but ${registration.pluginId} provides ${registration.version}`,
|
||||||
|
errors,
|
||||||
|
missingOptional,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { satisfied: errors.length === 0, errors, missingOptional };
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordUnsatisfied(
|
||||||
|
requirement: PluginServiceRequire,
|
||||||
|
message: string,
|
||||||
|
errors: string[],
|
||||||
|
missingOptional: string[],
|
||||||
|
): void {
|
||||||
|
if (requirement.optional) {
|
||||||
|
missingOptional.push(requirement.service);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errors.push(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceCallContext {
|
||||||
|
/** Resolves the user this call acts as. */
|
||||||
|
resolveUserId: () => string | undefined;
|
||||||
|
/** Checks a role permission for a user. */
|
||||||
|
hasPermission: (userId: string, permission: string) => Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* Writes the audit line. Must never throw.
|
||||||
|
*
|
||||||
|
* Awaited before the call's own result is handed back, so an action is on
|
||||||
|
* the trail before the caller can act on it. The broker can afford to fire
|
||||||
|
* and forget because it replies over postMessage; here the caller is
|
||||||
|
* holding the promise.
|
||||||
|
*/
|
||||||
|
audit: (entry: ServiceAuditEntry) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceAuditEntry {
|
||||||
|
userId: string;
|
||||||
|
consumerPluginId: string;
|
||||||
|
registration: ServiceRegistration;
|
||||||
|
method: string;
|
||||||
|
args: unknown[];
|
||||||
|
success: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps a registration in a handle whose every method runs the permission
|
||||||
|
* check before delegating.
|
||||||
|
*
|
||||||
|
* A Proxy rather than a hand-written wrapper so this stays generic over
|
||||||
|
* whatever shape the provider chose to hand out. Only own, callable properties
|
||||||
|
* of that object are reachable -- a consumer can call exactly what the
|
||||||
|
* provider exposed and nothing else. This is a set of individually declared
|
||||||
|
* contracts, not a general "call any method on any plugin" bus.
|
||||||
|
*/
|
||||||
|
export function createServiceHandle<T extends object>(
|
||||||
|
service: string,
|
||||||
|
consumerPluginId: string,
|
||||||
|
context: ServiceCallContext,
|
||||||
|
): T {
|
||||||
|
const target = Object.create(null) as T;
|
||||||
|
|
||||||
|
return new Proxy(target, {
|
||||||
|
get(_target, property) {
|
||||||
|
if (typeof property !== "string") return undefined;
|
||||||
|
|
||||||
|
if (property === "asUser") {
|
||||||
|
return (userId: string) =>
|
||||||
|
createServiceHandle<T>(service, consumerPluginId, {
|
||||||
|
...context,
|
||||||
|
resolveUserId: () => userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved at call time, not here: the provider may have gone away
|
||||||
|
// between taking the handle and using it. A method that WAS reachable
|
||||||
|
// must keep throwing the typed error rather than degrading to
|
||||||
|
// `undefined is not a function`, which tells a consumer nothing about
|
||||||
|
// why its dependency vanished.
|
||||||
|
const registration = services.get(service);
|
||||||
|
if (!registration) {
|
||||||
|
const gone = retired.get(service);
|
||||||
|
if (!gone?.methods.has(property)) return undefined;
|
||||||
|
return () => {
|
||||||
|
throw new PluginServiceUnavailableError(service, gone.pluginId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = registration.implementation[property];
|
||||||
|
if (typeof value !== "function") return undefined;
|
||||||
|
|
||||||
|
return (...args: unknown[]) =>
|
||||||
|
invokeGuarded(
|
||||||
|
service,
|
||||||
|
property,
|
||||||
|
args,
|
||||||
|
consumerPluginId,
|
||||||
|
context,
|
||||||
|
registration.generation,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
has(_target, property) {
|
||||||
|
const registration = services.get(service);
|
||||||
|
if (!registration || typeof property !== "string") return false;
|
||||||
|
return typeof registration.implementation[property] === "function";
|
||||||
|
},
|
||||||
|
|
||||||
|
ownKeys() {
|
||||||
|
const registration = services.get(service);
|
||||||
|
if (!registration) return [];
|
||||||
|
return Object.keys(registration.implementation).filter(
|
||||||
|
(key) => typeof registration.implementation[key] === "function",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
getOwnPropertyDescriptor(_target, property) {
|
||||||
|
const registration = services.get(service);
|
||||||
|
if (!registration || typeof property !== "string") return undefined;
|
||||||
|
if (typeof registration.implementation[property] !== "function") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return { configurable: true, enumerable: true, writable: false };
|
||||||
|
},
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeGuarded(
|
||||||
|
service: string,
|
||||||
|
method: string,
|
||||||
|
args: unknown[],
|
||||||
|
consumerPluginId: string,
|
||||||
|
context: ServiceCallContext,
|
||||||
|
expectedGeneration: number,
|
||||||
|
): Promise<unknown> {
|
||||||
|
const registration = services.get(service);
|
||||||
|
|
||||||
|
// Re-checked here rather than trusted from the get trap: a call is async and
|
||||||
|
// the provider can be disabled between resolving the method and running it.
|
||||||
|
if (!registration || registration.generation !== expectedGeneration) {
|
||||||
|
throw new PluginServiceUnavailableError(
|
||||||
|
service,
|
||||||
|
registration?.pluginId ?? consumerPluginId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = context.resolveUserId();
|
||||||
|
if (!userId) throw new PluginServiceActorError(service);
|
||||||
|
|
||||||
|
const allowed = await context.hasPermission(userId, registration.permission);
|
||||||
|
if (!allowed) {
|
||||||
|
const error = new PluginServicePermissionError(
|
||||||
|
service,
|
||||||
|
registration.permission,
|
||||||
|
);
|
||||||
|
await context.audit({
|
||||||
|
userId,
|
||||||
|
consumerPluginId,
|
||||||
|
registration,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error.message,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const implementation = registration.implementation[method];
|
||||||
|
if (typeof implementation !== "function") {
|
||||||
|
throw new PluginServiceUnavailableError(service, registration.pluginId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await (
|
||||||
|
implementation as (...callArgs: unknown[]) => unknown
|
||||||
|
).apply(registration.implementation, args);
|
||||||
|
|
||||||
|
await context.audit({
|
||||||
|
userId,
|
||||||
|
consumerPluginId,
|
||||||
|
registration,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await context.audit({
|
||||||
|
userId,
|
||||||
|
consumerPluginId,
|
||||||
|
registration,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
success: false,
|
||||||
|
errorMessage: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,103 @@ describe("plugin manifest validation", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("provides / requires", () => {
|
||||||
|
const permissionGroup = {
|
||||||
|
group: "testplugin",
|
||||||
|
permissions: ["testplugin.greet.use"],
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts a declared service and requirement", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
validManifest({
|
||||||
|
provides: [
|
||||||
|
{
|
||||||
|
service: "testplugin.greet",
|
||||||
|
version: "1.0.0",
|
||||||
|
permission: "testplugin.greet.use",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
requires: [
|
||||||
|
{ service: "other.thing", versionRange: "^1.0.0", optional: true },
|
||||||
|
],
|
||||||
|
contributes: { permissionGroup },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a service name that is not dotted", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
validManifest({
|
||||||
|
provides: [{ service: "greet", version: "1.0.0", permission: "x.y" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors.some((e) => e.includes("provides[0].service"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-semver service version", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
validManifest({
|
||||||
|
provides: [{ service: "a.b", version: "1.0", permission: "x.y" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors.some((e) => e.includes("provides[0].version"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a duplicate provided service", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
validManifest({
|
||||||
|
provides: [
|
||||||
|
{ service: "a.b", version: "1.0.0", permission: "x.y" },
|
||||||
|
{ service: "a.b", version: "2.0.0", permission: "x.y" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors.some((e) => e.includes("duplicate"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid semver range", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
validManifest({
|
||||||
|
requires: [{ service: "a.b", versionRange: "not a range" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors.some((e) => e.includes("versionRange"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-boolean optional", () => {
|
||||||
|
const errors = validateManifest(
|
||||||
|
validManifest({
|
||||||
|
requires: [
|
||||||
|
{ service: "a.b", versionRange: "^1.0.0", optional: "yes" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(errors.some((e) => e.includes("optional"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a service gated by a permission the group never declares", () => {
|
||||||
|
// Otherwise every call through it would deny against a permission no
|
||||||
|
// admin could ever grant.
|
||||||
|
const { manifest, errors } = parseManifest(
|
||||||
|
validManifest({
|
||||||
|
provides: [
|
||||||
|
{
|
||||||
|
service: "testplugin.greet",
|
||||||
|
version: "1.0.0",
|
||||||
|
permission: "testplugin.undeclared",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
contributes: { permissionGroup },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(manifest).toBeUndefined();
|
||||||
|
expect(errors[0]).toContain("testplugin.undeclared");
|
||||||
|
expect(errors[0]).toContain("permissionGroup");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an unknown permission", () => {
|
it("rejects an unknown permission", () => {
|
||||||
const errors = validateManifest(
|
const errors = validateManifest(
|
||||||
validManifest({ permissions: ["credentials.read"] }),
|
validManifest({ permissions: ["credentials.read"] }),
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
/**
|
||||||
|
* Two real plugins on disk, one providing a service and one requiring it,
|
||||||
|
* driven through the actual PluginLoader.
|
||||||
|
*
|
||||||
|
* The ids are the real first-party ones because the in-process allowlist is
|
||||||
|
* hardcoded and deliberately unfakeable -- the same thing in-process-loader
|
||||||
|
* does. The service ("testplugin.greet") and the permission gating it
|
||||||
|
* ("testplugin.greet.use") are fixture-owned, so nothing about the shipped
|
||||||
|
* ssh-terminal or docker plugins is under test here; only their ids are
|
||||||
|
* borrowed to reach the in-process tier.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { Fixture } from "./fixture-plugin.js";
|
||||||
|
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
/** Permissions the acting user currently holds. Mutated mid-test. */
|
||||||
|
granted: new Set<string>(),
|
||||||
|
auditEntries: [] as Record<string, unknown>[],
|
||||||
|
/** Records every delegated call, to prove the gate runs before the provider. */
|
||||||
|
providerCalls: [] as string[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
pluginLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/audit-logger.js", () => ({
|
||||||
|
logAudit: vi.fn(async (params: Record<string, unknown>) => {
|
||||||
|
state.auditEntries.push(params);
|
||||||
|
}),
|
||||||
|
getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../utils/permission-manager.js", () => ({
|
||||||
|
PermissionManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
hasPermission: async (_userId: string, permission: string) =>
|
||||||
|
state.granted.has(permission),
|
||||||
|
invalidateUserPermissionCache: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { PluginLoader } = await import("../../plugins/loader.js");
|
||||||
|
const { TRANSPORT_OWNER_CAPABILITY } =
|
||||||
|
await import("../../plugins/first-party.js");
|
||||||
|
const {
|
||||||
|
clearServiceRegistry,
|
||||||
|
getRegistration,
|
||||||
|
PluginServicePermissionError,
|
||||||
|
PluginServiceUnavailableError,
|
||||||
|
} = await import("../../plugins/service-registry.js");
|
||||||
|
const { pluginEvents } = await import("../../plugins/events.js");
|
||||||
|
const { createFixturePlugin } = await import("./fixture-plugin.js");
|
||||||
|
|
||||||
|
const SERVICE = "testplugin.greet";
|
||||||
|
const PERMISSION = "testplugin.greet.use";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider publishes a greet service and records every delegated call to
|
||||||
|
* a file, so the test can prove a denied call never reached it. A module-level
|
||||||
|
* variable would not work: the plugin is imported by URL into this process and
|
||||||
|
* lives in its own ESM module instance.
|
||||||
|
*/
|
||||||
|
function providerSource(callLog: string): string {
|
||||||
|
const literal = JSON.stringify(callLog);
|
||||||
|
return `
|
||||||
|
import fs from "node:fs";
|
||||||
|
export async function activate(ctx) {
|
||||||
|
ctx.services.provide(${JSON.stringify(SERVICE)}, {
|
||||||
|
hello: async (name) => {
|
||||||
|
fs.appendFileSync(${literal}, name + "\\n");
|
||||||
|
return "hello " + name;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The consumer takes a handle at activation and stashes it on globalThis so
|
||||||
|
* the test can call it as a specific user afterwards.
|
||||||
|
*/
|
||||||
|
const CONSUMER_SOURCE = `
|
||||||
|
export async function activate(ctx) {
|
||||||
|
globalThis.__consumerHandle = ctx.services.get(${JSON.stringify(SERVICE)});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function providerFixture(): Fixture {
|
||||||
|
return createFixturePlugin({
|
||||||
|
id: "ssh-terminal",
|
||||||
|
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||||
|
manifestOverrides: {
|
||||||
|
category: "Terminal",
|
||||||
|
provides: [
|
||||||
|
{ service: SERVICE, version: "1.2.0", permission: PERMISSION },
|
||||||
|
],
|
||||||
|
contributes: {
|
||||||
|
permissionGroup: { group: "testplugin", permissions: [PERMISSION] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumerFixture(
|
||||||
|
requires: Array<Record<string, unknown>> = [
|
||||||
|
{ service: SERVICE, versionRange: "^1.2.0" },
|
||||||
|
],
|
||||||
|
): Fixture {
|
||||||
|
return createFixturePlugin({
|
||||||
|
id: "docker",
|
||||||
|
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||||
|
backendSource: CONSUMER_SOURCE,
|
||||||
|
manifestOverrides: { category: "Infrastructure", requires },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type GreetHandle = {
|
||||||
|
hello: (name: string) => Promise<string>;
|
||||||
|
asUser: (userId: string) => { hello: (name: string) => Promise<string> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function handle(): GreetHandle {
|
||||||
|
return (globalThis as Record<string, unknown>)
|
||||||
|
.__consumerHandle as GreetHandle;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plugin service contracts", () => {
|
||||||
|
let provider: Fixture | null = null;
|
||||||
|
let consumer: Fixture | null = null;
|
||||||
|
let loader: InstanceType<typeof PluginLoader> | null = null;
|
||||||
|
let callLog = "";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.granted = new Set([PERMISSION]);
|
||||||
|
state.auditEntries = [];
|
||||||
|
state.providerCalls = [];
|
||||||
|
clearServiceRegistry();
|
||||||
|
pluginEvents.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await loader?.shutdown();
|
||||||
|
loader = null;
|
||||||
|
provider?.cleanup();
|
||||||
|
consumer?.cleanup();
|
||||||
|
provider = null;
|
||||||
|
consumer = null;
|
||||||
|
delete (globalThis as Record<string, unknown>).__consumerHandle;
|
||||||
|
clearServiceRegistry();
|
||||||
|
pluginEvents.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function activateBoth(
|
||||||
|
consumerRequires?: Array<Record<string, unknown>>,
|
||||||
|
) {
|
||||||
|
provider = providerFixture();
|
||||||
|
callLog = path.join(provider.root, "calls.txt");
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(provider.dir, "backend", "index.mjs"),
|
||||||
|
providerSource(callLog),
|
||||||
|
);
|
||||||
|
|
||||||
|
consumer = consumerRequires
|
||||||
|
? consumerFixture(consumerRequires)
|
||||||
|
: consumerFixture();
|
||||||
|
|
||||||
|
loader = new PluginLoader();
|
||||||
|
const providerPlugin = await loader.load(provider.dir);
|
||||||
|
await loader.activate(providerPlugin.id);
|
||||||
|
|
||||||
|
const consumerPlugin = await loader.load(consumer.dir);
|
||||||
|
await loader.activate(consumerPlugin.id);
|
||||||
|
|
||||||
|
return { providerPlugin, consumerPlugin };
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerCallCount(): number {
|
||||||
|
if (!fs.existsSync(callLog)) return 0;
|
||||||
|
return fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean)
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("resolves structurally when both plugins are active", async () => {
|
||||||
|
const { providerPlugin, consumerPlugin } = await activateBoth();
|
||||||
|
|
||||||
|
expect(providerPlugin.state).toBe("active");
|
||||||
|
expect(consumerPlugin.state).toBe("active");
|
||||||
|
|
||||||
|
const registration = getRegistration(SERVICE);
|
||||||
|
expect(registration?.version).toBe("1.2.0");
|
||||||
|
expect(registration?.pluginId).toBe("ssh-terminal");
|
||||||
|
expect(registration?.permission).toBe(PERMISSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails activation when a hard requirement is unsatisfied", async () => {
|
||||||
|
consumer = consumerFixture([{ service: SERVICE, versionRange: "^9.0.0" }]);
|
||||||
|
|
||||||
|
loader = new PluginLoader();
|
||||||
|
const plugin = await loader.load(consumer.dir);
|
||||||
|
|
||||||
|
await expect(loader.activate(plugin.id)).rejects.toThrow(
|
||||||
|
/no active plugin provides|provides 1\.2\.0/,
|
||||||
|
);
|
||||||
|
expect(plugin.state).toBe("crashed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("activates anyway when an unsatisfied requirement is optional", async () => {
|
||||||
|
consumer = consumerFixture([
|
||||||
|
{ service: "testplugin.absent", versionRange: "^1.0.0", optional: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
loader = new PluginLoader();
|
||||||
|
const plugin = await loader.load(consumer.dir);
|
||||||
|
await loader.activate(plugin.id);
|
||||||
|
|
||||||
|
expect(plugin.state).toBe("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a call from a user who holds the permission", async () => {
|
||||||
|
await activateBoth();
|
||||||
|
|
||||||
|
await expect(handle().asUser("alice").hello("world")).resolves.toBe(
|
||||||
|
"hello world",
|
||||||
|
);
|
||||||
|
expect(providerCallCount()).toBe(1);
|
||||||
|
|
||||||
|
const entry = state.auditEntries.at(-1);
|
||||||
|
expect(entry?.success).toBe(true);
|
||||||
|
expect(entry?.username).toBe("plugin:docker");
|
||||||
|
expect(entry?.userId).toBe("alice");
|
||||||
|
expect(entry?.action).toBe("plugin_service_testplugin_greet_hello");
|
||||||
|
expect(entry?.resourceId).toBe("ssh-terminal");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies a user without the permission, with the standard shape", async () => {
|
||||||
|
await activateBoth();
|
||||||
|
state.granted.delete(PERMISSION);
|
||||||
|
|
||||||
|
await handle()
|
||||||
|
.asUser("mallory")
|
||||||
|
.hello("world")
|
||||||
|
.then(
|
||||||
|
() => expect.unreachable("should have been denied"),
|
||||||
|
(error: InstanceType<typeof PluginServicePermissionError>) => {
|
||||||
|
expect(error.name).toBe("PluginServicePermissionError");
|
||||||
|
expect(error.status).toBe(403);
|
||||||
|
expect(error.body).toEqual({
|
||||||
|
error: "Insufficient permissions",
|
||||||
|
required: PERMISSION,
|
||||||
|
});
|
||||||
|
expect(error.message).toContain("do not have access");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The gate runs before delegation: the provider never saw the call.
|
||||||
|
expect(providerCallCount()).toBe(0);
|
||||||
|
|
||||||
|
const entry = state.auditEntries.at(-1);
|
||||||
|
expect(entry?.success).toBe(false);
|
||||||
|
expect(entry?.userId).toBe("mallory");
|
||||||
|
expect(String(entry?.errorMessage)).toContain(PERMISSION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("honours a revoke mid-session on the very next call", async () => {
|
||||||
|
await activateBoth();
|
||||||
|
const live = handle().asUser("alice");
|
||||||
|
|
||||||
|
await expect(live.hello("first")).resolves.toBe("hello first");
|
||||||
|
|
||||||
|
// No restart, no reinstall, same handle: the check is per call, which is
|
||||||
|
// exactly why it lives in the proxy rather than at handle creation.
|
||||||
|
state.granted.delete(PERMISSION);
|
||||||
|
|
||||||
|
await expect(live.hello("second")).rejects.toThrow(
|
||||||
|
PluginServicePermissionError,
|
||||||
|
);
|
||||||
|
expect(providerCallCount()).toBe(1);
|
||||||
|
|
||||||
|
// And granting it back takes effect just as immediately.
|
||||||
|
state.granted.add(PERMISSION);
|
||||||
|
await expect(live.hello("third")).resolves.toBe("hello third");
|
||||||
|
expect(providerCallCount()).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a clear error once the provider is deactivated, without tearing the consumer down", async () => {
|
||||||
|
const { consumerPlugin } = await activateBoth();
|
||||||
|
const live = handle().asUser("alice");
|
||||||
|
|
||||||
|
await expect(live.hello("before")).resolves.toBe("hello before");
|
||||||
|
|
||||||
|
await loader!.deactivate("ssh-terminal");
|
||||||
|
|
||||||
|
expect(getRegistration(SERVICE)).toBeUndefined();
|
||||||
|
|
||||||
|
// Documented behaviour: the consumer stays active and decides for itself
|
||||||
|
// what to do. Deactivating one plugin must not silently stop others.
|
||||||
|
expect(consumerPlugin.state).toBe("active");
|
||||||
|
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await live.hello("after");
|
||||||
|
} catch (error) {
|
||||||
|
thrown = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(thrown).toBeInstanceOf(PluginServiceUnavailableError);
|
||||||
|
expect((thrown as { code: string }).code).toBe("EPLUGINSVCGONE");
|
||||||
|
expect((thrown as Error).message).toContain(SERVICE);
|
||||||
|
expect(providerCallCount()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to provide a service the manifest never declared", async () => {
|
||||||
|
provider = createFixturePlugin({
|
||||||
|
id: "ssh-terminal",
|
||||||
|
permissions: [TRANSPORT_OWNER_CAPABILITY],
|
||||||
|
backendSource: `
|
||||||
|
export async function activate(ctx) {
|
||||||
|
ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" });
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
manifestOverrides: { category: "Terminal" },
|
||||||
|
});
|
||||||
|
|
||||||
|
loader = new PluginLoader();
|
||||||
|
const plugin = await loader.load(provider.dir);
|
||||||
|
|
||||||
|
await expect(loader.activate(plugin.id)).rejects.toThrow(
|
||||||
|
/not declared in the manifest's provides array/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* Unit coverage for the declared service registry: registration, versioned
|
||||||
|
* structural resolution, and the per-call permission gate on a handle.
|
||||||
|
*
|
||||||
|
* The two-fixture end-to-end test lives in service-contract.test.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../../utils/logger.js", () => ({
|
||||||
|
pluginLogger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const {
|
||||||
|
clearServiceRegistry,
|
||||||
|
createServiceHandle,
|
||||||
|
getRegistration,
|
||||||
|
listServices,
|
||||||
|
provideService,
|
||||||
|
resolveRequirements,
|
||||||
|
revokeService,
|
||||||
|
PluginServiceActorError,
|
||||||
|
PluginServicePermissionError,
|
||||||
|
PluginServiceUnavailableError,
|
||||||
|
} = await import("../../plugins/service-registry.js");
|
||||||
|
|
||||||
|
type Registration = ReturnType<typeof provideService>;
|
||||||
|
|
||||||
|
function register(
|
||||||
|
overrides: Partial<Parameters<typeof provideService>[0]> = {},
|
||||||
|
): Registration {
|
||||||
|
return provideService({
|
||||||
|
service: "testplugin.greet",
|
||||||
|
version: "1.0.0",
|
||||||
|
permission: "testplugin.greet.use",
|
||||||
|
pluginId: "provider-plugin",
|
||||||
|
pluginName: "Provider Plugin",
|
||||||
|
implementation: { hello: async (name: string) => `hello ${name}` },
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifestRequiring(
|
||||||
|
requires: Array<{
|
||||||
|
service: string;
|
||||||
|
versionRange: string;
|
||||||
|
optional?: boolean;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return { id: "consumer-plugin", requires } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("service registry", () => {
|
||||||
|
beforeEach(() => clearServiceRegistry());
|
||||||
|
afterEach(() => clearServiceRegistry());
|
||||||
|
|
||||||
|
it("round-trips a registration", () => {
|
||||||
|
const registration = register();
|
||||||
|
|
||||||
|
expect(getRegistration("testplugin.greet")).toBe(registration);
|
||||||
|
expect(listServices()).toHaveLength(1);
|
||||||
|
expect(registration.generation).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only revokes the registration that is actually current", () => {
|
||||||
|
const first = register();
|
||||||
|
const second = register();
|
||||||
|
|
||||||
|
// A crashed-and-restarted provider must not revoke the replacement its own
|
||||||
|
// restart installed.
|
||||||
|
expect(revokeService("testplugin.greet", first)).toBe(false);
|
||||||
|
expect(getRegistration("testplugin.greet")).toBe(second);
|
||||||
|
|
||||||
|
expect(revokeService("testplugin.greet", second)).toBe(true);
|
||||||
|
expect(getRegistration("testplugin.greet")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a revoke of something never registered", () => {
|
||||||
|
expect(revokeService("nothing.here")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolution", () => {
|
||||||
|
it("is satisfied when the version is in range", () => {
|
||||||
|
register({ version: "1.2.0" });
|
||||||
|
|
||||||
|
const resolution = resolveRequirements(
|
||||||
|
manifestRequiring([
|
||||||
|
{ service: "testplugin.greet", versionRange: "^1.2.0" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.satisfied).toBe(true);
|
||||||
|
expect(resolution.errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails a hard requirement nothing provides", () => {
|
||||||
|
const resolution = resolveRequirements(
|
||||||
|
manifestRequiring([
|
||||||
|
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.satisfied).toBe(false);
|
||||||
|
expect(resolution.errors[0]).toContain("no active plugin provides");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the provided version is out of range", () => {
|
||||||
|
register({ version: "2.0.0" });
|
||||||
|
|
||||||
|
const resolution = resolveRequirements(
|
||||||
|
manifestRequiring([
|
||||||
|
{ service: "testplugin.greet", versionRange: "^1.0.0" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.satisfied).toBe(false);
|
||||||
|
expect(resolution.errors[0]).toContain("provides 2.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips an unsatisfied optional requirement", () => {
|
||||||
|
const resolution = resolveRequirements(
|
||||||
|
manifestRequiring([
|
||||||
|
{
|
||||||
|
service: "testplugin.greet",
|
||||||
|
versionRange: "^1.0.0",
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(resolution.satisfied).toBe(true);
|
||||||
|
expect(resolution.missingOptional).toEqual(["testplugin.greet"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a manifest with no requires as satisfied", () => {
|
||||||
|
expect(resolveRequirements({ id: "x" } as never).satisfied).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handle", () => {
|
||||||
|
function handleFor(allowed: boolean, ...actor: [] | [string | undefined]) {
|
||||||
|
const userId = actor.length === 0 ? "user-1" : actor[0];
|
||||||
|
const audits: Array<Record<string, unknown>> = [];
|
||||||
|
const handle = createServiceHandle<{
|
||||||
|
hello: (name: string) => Promise<string>;
|
||||||
|
}>("testplugin.greet", "consumer-plugin", {
|
||||||
|
resolveUserId: () => userId,
|
||||||
|
hasPermission: async () => allowed,
|
||||||
|
audit: (entry) => void audits.push(entry as never),
|
||||||
|
});
|
||||||
|
return { handle, audits };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("delegates to the implementation when permitted", async () => {
|
||||||
|
register();
|
||||||
|
const { handle, audits } = handleFor(true);
|
||||||
|
|
||||||
|
await expect(handle.hello("world")).resolves.toBe("hello world");
|
||||||
|
expect(audits).toHaveLength(1);
|
||||||
|
expect(audits[0].success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies without the permission and never reaches the provider", async () => {
|
||||||
|
const hello = vi.fn(async () => "should not run");
|
||||||
|
register({ implementation: { hello } });
|
||||||
|
const { handle, audits } = handleFor(false);
|
||||||
|
|
||||||
|
await expect(handle.hello("world")).rejects.toThrow(
|
||||||
|
PluginServicePermissionError,
|
||||||
|
);
|
||||||
|
// The gate has to run before delegation, not after.
|
||||||
|
expect(hello).not.toHaveBeenCalled();
|
||||||
|
expect(audits[0].success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("denies with the same 403 shape requirePermission sends", async () => {
|
||||||
|
register();
|
||||||
|
const { handle } = handleFor(false);
|
||||||
|
|
||||||
|
await handle.hello("world").then(
|
||||||
|
() => expect.unreachable("should have denied"),
|
||||||
|
(error: InstanceType<typeof PluginServicePermissionError>) => {
|
||||||
|
expect(error.status).toBe(403);
|
||||||
|
expect(error.body).toEqual({
|
||||||
|
error: "Insufficient permissions",
|
||||||
|
required: "testplugin.greet.use",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a call it cannot attribute to a user", async () => {
|
||||||
|
register();
|
||||||
|
const { handle } = handleFor(true, undefined);
|
||||||
|
|
||||||
|
await expect(handle.hello("world")).rejects.toThrow(
|
||||||
|
PluginServiceActorError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws once the provider is revoked", async () => {
|
||||||
|
const registration = register();
|
||||||
|
const { handle } = handleFor(true);
|
||||||
|
|
||||||
|
await expect(handle.hello("world")).resolves.toBe("hello world");
|
||||||
|
|
||||||
|
revokeService("testplugin.greet", registration);
|
||||||
|
|
||||||
|
expect(() => handle.hello("world")).toThrow(
|
||||||
|
/no longer provided|is not a function/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes only the functions the provider handed out", () => {
|
||||||
|
register({
|
||||||
|
implementation: {
|
||||||
|
hello: async () => "hi",
|
||||||
|
secret: "not a function",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { handle } = handleFor(true);
|
||||||
|
|
||||||
|
expect(typeof handle.hello).toBe("function");
|
||||||
|
expect(
|
||||||
|
(handle as unknown as Record<string, unknown>).secret,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(Object.keys(handle)).toEqual(["hello"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebinds the acting user with asUser", async () => {
|
||||||
|
register();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const handle = createServiceHandle<{
|
||||||
|
hello: (name: string) => Promise<string>;
|
||||||
|
asUser: (userId: string) => { hello: (n: string) => Promise<string> };
|
||||||
|
}>("testplugin.greet", "consumer-plugin", {
|
||||||
|
resolveUserId: () => undefined,
|
||||||
|
hasPermission: async (userId) => {
|
||||||
|
seen.push(userId);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
audit: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.asUser("user-42").hello("world");
|
||||||
|
expect(seen).toEqual(["user-42"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces an unavailable service rather than a generic undefined", () => {
|
||||||
|
const { handle } = handleFor(true);
|
||||||
|
expect(
|
||||||
|
(handle as unknown as Record<string, unknown>).hello,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("audits a failure thrown by the provider itself", async () => {
|
||||||
|
register({
|
||||||
|
implementation: {
|
||||||
|
hello: async () => {
|
||||||
|
throw new Error("provider exploded");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { handle, audits } = handleFor(true);
|
||||||
|
|
||||||
|
await expect(handle.hello("world")).rejects.toThrow("provider exploded");
|
||||||
|
expect(audits[0].success).toBe(false);
|
||||||
|
expect(audits[0].errorMessage).toBe("provider exploded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the unavailable error with a stable code", () => {
|
||||||
|
const error = new PluginServiceUnavailableError("a.b", "provider");
|
||||||
|
expect(error.code).toBe("EPLUGINSVCGONE");
|
||||||
|
expect(error.message).toContain("a.b");
|
||||||
|
expect(error.message).toContain("provider");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user