/** * 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 { recordConflict } from "./conflicts.js"; import { runAsActor } from "./actor.js"; import type { PluginManifest, PluginServiceRequire } from "./manifest.js"; export interface ServiceRegistration { service: string; /** * The provider's name, for a service several plugins provide side by side * (sessions.live, keyed by session type). Empty for the one unnamed provider. */ name: string; version: string; permission: string; /** The plugin that provided it. */ pluginId: string; /** Display name, for audit lines. */ pluginName: string; implementation: Record; /** * 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; } /** service -> provider name ("" when unnamed) -> registration. */ const services = new Map>(); /** * What a provider looked like before it was revoked, by the same two keys. * * 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 }>(); let generationCounter = 0; function retiredKey(service: string, name: string): string { return `${service}#${name}`; } function lookup(service: string, name = ""): ServiceRegistration | undefined { return services.get(service)?.get(name); } /** 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 & { name?: string; }, ): ServiceRegistration { const name = registration.name ?? ""; const existing = lookup(registration.service, name); // Two plugins cannot answer to the same service and provider name. The // same plugin replacing its own registration (a restart) is fine. if (existing && existing.pluginId !== registration.pluginId) { recordConflict({ kind: "service", pluginId: registration.pluginId, heldBy: existing.pluginId, name: `${registration.service}${name ? `:${name}` : ""}`, }); throw new Error( `Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId}`, ); } if (existing) { pluginLogger.warn( `Service "${registration.service}"${name ? ` (${name})` : ""} is already provided by ${existing.pluginId} and is being replaced by ${registration.pluginId}`, { operation: "plugin_service_registry" }, ); } const entry: ServiceRegistration = { ...registration, name, generation: ++generationCounter, }; let providers = services.get(registration.service); if (!providers) { providers = new Map(); services.set(registration.service, providers); } providers.set(name, entry); retired.delete(retiredKey(registration.service, name)); 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 name = registration?.name ?? ""; const current = lookup(service, name); if (!current) return false; if (registration && current.generation !== registration.generation) { return false; } retired.set(retiredKey(service, name), { pluginId: current.pluginId, methods: new Set( Object.keys(current.implementation).filter( (key) => typeof current.implementation[key] === "function", ), ), }); const providers = services.get(service)!; providers.delete(name); if (providers.size === 0) services.delete(service); return true; } export function getRegistration( service: string, name = "", ): ServiceRegistration | undefined { return lookup(service, name); } /** The names of every provider of a service right now ("" when unnamed). */ export function listProviderNames(service: string): string[] { return [...(services.get(service)?.keys() ?? [])]; } /** * A plugin service as core itself calls it, or undefined while no plugin * provides a compatible version. * * Core is not a plugin and already authorized the request it is serving, so * this skips the per-call permission check a plugin's handle runs. It is the * same object plugins get. Session sharing reaching the terminal's live * sessions is the first caller. */ export function getServiceImplementation( service: string, versionRange: string, name = "", ): T | undefined { const registration = lookup(service, name); if (!registration) return undefined; if (!semver.satisfies(registration.version, versionRange)) return undefined; return registration.implementation as T; } export function listServices(): ServiceRegistration[] { return [...services.values()].flatMap((providers) => [...providers.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 ?? []) { // Any one provider satisfies it; a consumer of a named service picks the // provider per call. const providers = [...(services.get(requirement.service)?.values() ?? [])]; const registration = providers.find((candidate) => semver.satisfies(candidate.version, requirement.versionRange), ) ?? providers[0]; 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; /** * 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; } 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( service: string, consumerPluginId: string, context: ServiceCallContext, name = "", ): 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( service, consumerPluginId, { ...context, resolveUserId: () => userId }, name, ); } // 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 = lookup(service, name); if (!registration) { const gone = retired.get(retiredKey(service, name)); 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, name, ); }, has(_target, property) { const registration = lookup(service, name); if (!registration || typeof property !== "string") return false; return typeof registration.implementation[property] === "function"; }, ownKeys() { const registration = lookup(service, name); if (!registration) return []; return Object.keys(registration.implementation).filter( (key) => typeof registration.implementation[key] === "function", ); }, getOwnPropertyDescriptor(_target, property) { const registration = lookup(service, name); 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, name: string, ): Promise { const registration = lookup(service, name); // 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 { // Runs as the user the permission was checked for, so the provider reads // the same user from ctx.currentActor() and cannot be told another one. const result = await runAsActor(userId, "service", () => (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; } }