diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs index fa253e46a..08e8f1f23 100644 --- a/server/eslint.config.mjs +++ b/server/eslint.config.mjs @@ -82,4 +82,61 @@ export default tseslint.config( ], }, }, + { + // The plugin RPC decorator kit is written to stay extractable into its own + // package, so its dependencies are pinned to Nest plus the two host modules it + // genuinely needs. See src/nest/plugins/host/rpc-kit/README.md for what an + // extraction would cost, and why the envelope import is the deliberate exception. + files: ['src/nest/plugins/host/rpc-kit/**/*.ts'], + rules: { + // Written as a deny list rather than an allow list on purpose: these patterns + // are gitignore-style, and a leading '**' would exclude the parent directory of + // every allowed path, which makes the '!' re-includes silently ineffective. + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + // The host modules next door. + '../rpc-host', + '../rpc-errors', + '../rpc-params', + '../rate-limit', + '../daily-budget', + '../plugin-audit', + '../plugin-jobs', + '../plugin-guards.service', + '../plugin-host-*', + // Everything one level up, in plugins/ itself. + '../../*.service', + '../../*.module', + '../../*.controller', + '../../dependencies', + '../../dev-link', + '../../kill-switch', + '../../paths', + '../../plugin-backup', + '../../signature-status', + '../../text-sanitize', + '../../install/**', + '../../registry/**', + '../../runtime/**', + '../../supervisor/**', + // The wire protocol, except the one file the kit validates against. + '../../protocol/*', + '!../../protocol/envelope', + // Anything outside the plugin subtree, and the rest of the app. + '../../../**', + '@trek/**', + 'node:*', + ], + message: + 'rpc-kit stays extractable: it may import @nestjs/common, @nestjs/core, its own files, and type-only from ../../protocol/envelope and ../plugin-data.service. See rpc-kit/README.md.', + }, + ], + }, + ], + }, + }, ); diff --git a/server/src/nest/plugins/host/rpc-errors.ts b/server/src/nest/plugins/host/rpc-errors.ts new file mode 100644 index 000000000..8bafd7532 --- /dev/null +++ b/server/src/nest/plugins/host/rpc-errors.ts @@ -0,0 +1,15 @@ +/** + * The two error classes the plugin RPC boundary maps onto wire error codes. + * + * handle() in rpc-host.ts translates them: BadParams -> BAD_PARAMS, + * ForbiddenResource -> RESOURCE_FORBIDDEN, anything else -> HOST_ERROR. Lifted out + * of rpc-host.ts so decorated *.rpc.ts handlers can throw them without importing + * the router itself; rpc-host.ts re-exports both, so existing importers are + * unaffected. + */ + +/** Thrown by a handler when the acting user may not touch the requested resource. */ +export class ForbiddenResource extends Error {} + +/** Thrown when a param is missing or has the wrong shape. */ +export class BadParams extends Error {} diff --git a/server/src/nest/plugins/host/rpc-kit/README.md b/server/src/nest/plugins/host/rpc-kit/README.md new file mode 100644 index 000000000..0a2951d2c --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/README.md @@ -0,0 +1,78 @@ +# rpc-kit — the plugin RPC decorator layer + +Conceptually this is `trek-plugin-host`: `@PluginController`, `@PluginMethod`, +`@PluginOpenMethod` and `@PluginHook`, plus the registry that binds them into the +per-plugin dispatch map. It lives inside `server/` rather than in its own workspace, +and this file records why, and what extracting it later would cost. + +## What the decorators buy + +The method-to-permission association used to exist twice: declaratively in +`METHOD_PERMISSION` (`../../protocol/envelope.ts`) and imperatively as the +`if (has('...'))` blocks in `../rpc-host.ts`, with nothing asserting they agree. +`protocol-paths.test.ts` checked that each method had *some* permission, never that it +was *the same* permission the router gates on, and `plugin-audit.ts`'s `isAuditable` +silently inherits any drift. + +A `@PluginMethod` declaration is checked against that table twice: + +- at compile time, because `MethodPermission` resolves to the single literal the + table assigns to `M`, so `@PluginMethod('tags.list', { permission: 'db:write:tags' })` + does not typecheck; +- at boot, in `registry.validate()`, because types evaporate under an `as` cast or a + hand-emitted JS consumer. + +`@PluginHook` gets the same treatment against `HOOK_PERMISSION`. + +## The import rule + +The kit may import: + +- `@nestjs/common` and `@nestjs/core`, +- **type-only** from `../../protocol/envelope` and `../plugin-data.service`. + +Nothing else. It must not reach into `rpc-host.ts`, the deps factory, the supervisor, +or any domain service. `registry.ts` is the single exception that imports envelope +values (the tables it validates against), which is deliberate: the kit sits in the same +package as its data source, so it can read it directly. + +## If this ever becomes its own package + +`nest-mcp` is the worked example of the same layer as a separate workspace, and the +delta is instructive. Because `nest-mcp` must not import the host, it needs a +`declare module` augmentation for its registries plus two `AssertExact` drift guards in +`src/mcp/nest-mcp-policy.ts`, and a permissive `keyof X extends never ? string` +fallback that silently disables all checking if someone forgets to augment. + +`rpc-kit` needs none of that today. Extracting it would mean: + +1. `PluginRpcContext.data` becomes a generic parameter instead of importing + `PluginDataDb`. +2. The four envelope type imports (`KnownMethod`, `MethodPermission`, `HookKey`, + `HookPermission`) become empty-interface augmentation points, with `AssertExact` + guards in the server proving the augmentation still matches the tables. +3. Root `package.json` `workspaces`, `server/package.json` deps, the root lockfile, the + Dockerfile COPY stages and a source alias in `server/vitest.config.ts` all change. + +Note that a workspace would *not* let `trek-plugin-sdk` consume it: `nest-mcp` is +`"private": true`, and so would this be. The SDK gets its copy of the permission facts +from the generated `plugin-sdk/src/generated/host-facts.ts` instead. + +## Files + +| File | What it is | +|---|---| +| `metadata.ts` | WeakSet/WeakMap keyed by constructor. Deliberately not `reflect-metadata`, so a bare vitest worker can build a registry with no Nest app. | +| `types.ts` | `PluginRpcContext`, the entry union, the option shapes. | +| `decorators.ts` | The four decorators. | +| `registry.ts` | `register` / `validate` / `bindInto` / `hookContracts`. Plain class, no DI. | +| `registry.service.ts` | The DI-discovered subclass. Scans providers at boot and validates. | +| `testing.ts` | `createTestPluginRegistry`, same fail-fast contract as the boot path. | + +## The failure mode to know about + +A `@PluginController()` class that is missing from its module's `providers: []` is +invisible to discovery. Its methods are simply never bound, and every call to them +comes back `PERMISSION_DENIED` with no other symptom. The compiler cannot see this. +What catches it is `server/tests/unit/plugins/rpc-coverage.test.ts`, and, once the +rollout is finished, `requireTotalCoverage: true` turning it into a boot failure. diff --git a/server/src/nest/plugins/host/rpc-kit/decorators.ts b/server/src/nest/plugins/host/rpc-kit/decorators.ts new file mode 100644 index 000000000..d18fde004 --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/decorators.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@nestjs/common'; +import { addEntry, markController, type ClassRef } from './metadata'; +import type { HookKey, KnownMethod, MethodPermission, UnconditionalMethod } from '../../protocol/envelope'; +import type { PluginHookOptions, PluginRpcEntry } from './types'; + +/** + * Marks a class as a plugin RPC controller. Implies `@Injectable()`. + * + * The class MUST be listed in its domain module's `providers: []`. Discovery scans + * only marked PROVIDERS (explicit opt-in), so a decorated class nobody registered + * contributes nothing and every call to its methods comes back PERMISSION_DENIED + * with no other symptom. The coverage ledger test catches that in CI, and + * `requireTotalCoverage` turns it into a boot failure once the rollout is done. + * + * NEVER put this on a Nest `@Controller`: `Injectable()` rewrites + * SCOPE_OPTIONS_METADATA and would clobber a `@Controller({ scope })` option. + * register() rejects it. + */ +export function PluginController(): ClassDecorator { + return (target) => { + Injectable()(target); + markController(target as unknown as ClassRef); + }; +} + +function pluginMethodDecorator(entry: (methodName: string) => PluginRpcEntry): MethodDecorator { + return (target, propertyKey) => { + addEntry((target as { constructor: unknown }).constructor as ClassRef, entry(String(propertyKey))); + }; +} + +/** + * Registers the method as the handler for RPC method `method`. + * Handler signature: `(params, ctx: PluginRpcContext) => unknown`. + * + * @PluginMethod('tags.list', { permission: 'db:read:tags' }) + * + * Both arguments are checked against protocol/envelope.ts at COMPILE time: a name + * outside KNOWN_METHODS does not typecheck, and a permission other than the one + * METHOD_PERMISSION assigns to that method fails with the expected literal named in + * the error. That is the binding the whole kit exists for. + */ +export function PluginMethod( + method: M, + options: { permission: MethodPermission }, +): MethodDecorator { + return pluginMethodDecorator((methodName) => ({ + kind: 'method', + methodName, + method, + permission: options.permission, + })); +} + +/** + * The carve-out: a method registered with NO grant check, matching the three the + * router registers outside every has() block. + * + * A separate decorator rather than an option on @PluginMethod, so that "I forgot the + * permission" can never look like the carve-out. It also sidesteps a trap specific to + * this package: server/tsconfig.json has "strict": false, so a `permission: null` + * sentinel would be assignable to every string literal and the check would be void. + */ +export function PluginOpenMethod(method: UnconditionalMethod): MethodDecorator { + return pluginMethodDecorator((methodName) => ({ kind: 'open', methodName, method })); +} + +/** + * Declares a HOST-side consumer of one provider-hook function. + * + * @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' }) + * + * It does NOT impose a fan-out shape, because the real call graph is not uniform: + * routeProvider is single-target on a 20s budget, calendarSource fans out over two + * fns with different budgets, photoProvider has three separate sites, and + * notificationChannel is invoked from PluginRuntimeService rather than a controller. + * What the declaration buys is the compile-checked hook/permission pair, one place + * where the timeout lives, and the boot assertion that no granted hook is left + * without a consumer. + */ +export function PluginHook(hook: H, options: PluginHookOptions): MethodDecorator { + return pluginMethodDecorator((methodName) => ({ + kind: 'hook', + methodName, + hook, + permission: options.permission, + fn: options.fn, + timeoutMs: options.timeoutMs ?? 5000, + })); +} diff --git a/server/src/nest/plugins/host/rpc-kit/metadata.ts b/server/src/nest/plugins/host/rpc-kit/metadata.ts new file mode 100644 index 000000000..a3dd22d8e --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/metadata.ts @@ -0,0 +1,47 @@ +import type { PluginRpcEntry } from './types'; + +/** + * Metadata store. A package-private WeakMap/WeakSet keyed by class constructor + * instead of reflect-metadata: it has zero runtime prerequisites (no polyfill + * import order to get right), works in bare vitest workers and in + * `createTestPluginRegistry` without a Nest app, and is trivially + * extraction-clean. Same reasoning as nest-mcp's metadata store. + */ + +/** A class constructor, without using the banned bare `Function` type. */ +export type ClassRef = abstract new (...args: never[]) => unknown; + +const controllers = new WeakSet(); +const entries = new WeakMap>(); + +export function markController(ctor: ClassRef): void { + controllers.add(ctor); +} + +export function isPluginController(ctor: unknown): ctor is ClassRef { + return typeof ctor === 'function' && controllers.has(ctor as ClassRef); +} + +export function addEntry(ctor: ClassRef, entry: PluginRpcEntry): void { + let map = entries.get(ctor); + if (!map) { + map = new Map(); + entries.set(ctor, map); + } + map.set(entry.methodName, entry); +} + +/** + * Look up the entry recorded for `methodName`, walking the constructor prototype + * chain so inherited decorated methods are found on subclasses. + */ +export function getEntry(ctor: ClassRef, methodName: string): PluginRpcEntry | undefined { + let current: ClassRef | null = ctor; + while (current) { + const found = entries.get(current)?.get(methodName); + if (found) return found; + const parent: unknown = Object.getPrototypeOf(current); + current = typeof parent === 'function' ? (parent as ClassRef) : null; + } + return undefined; +} diff --git a/server/src/nest/plugins/host/rpc-kit/registry.service.ts b/server/src/nest/plugins/host/rpc-kit/registry.service.ts new file mode 100644 index 000000000..2a1f89e18 --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/registry.service.ts @@ -0,0 +1,40 @@ +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { DiscoveryService, MetadataScanner } from '@nestjs/core'; +import { isPluginController } from './metadata'; +import { PluginRpcRegistry } from './registry'; + +/** + * DI-discovered registry: at boot, scans every PROVIDER marked with + * `@PluginController()` and records its decorated methods. + * + * `getControllers()` is deliberately not scanned. @PluginController implies + * @Injectable(), which rewrites the scope metadata @Controller() sets, so the + * decorator belongs on provider classes only and register() rejects the rest. + */ +@Injectable() +export class PluginRpcRegistryService extends PluginRpcRegistry implements OnModuleInit { + constructor( + private readonly discovery: DiscoveryService, + private readonly scanner: MetadataScanner, + ) { + // Flipped to true in the closing PR of the rollout, once the legacy router map + // in rpc-host.ts is empty and every KNOWN_METHOD has a decorated owner. + super({ requireTotalCoverage: false }); + } + + onModuleInit(): void { + // A provider can be wrapped once per module that lists it, so dedupe by instance + // or every entry is recorded twice and the duplicate check fires spuriously. + const seen = new Set(); + for (const wrapper of this.discovery.getProviders()) { + const { instance, metatype } = wrapper; + if (!instance || typeof instance !== 'object' || seen.has(instance)) continue; + if (!isPluginController(metatype)) continue; + seen.add(instance); + this.register(instance, this.scanner.getAllMethodNames(Object.getPrototypeOf(instance))); + } + // Fail app boot on a wrong decorator argument or a double-owned method rather + // than failing per-plugin at activation time. + this.validate(); + } +} diff --git a/server/src/nest/plugins/host/rpc-kit/registry.ts b/server/src/nest/plugins/host/rpc-kit/registry.ts new file mode 100644 index 000000000..f52c54034 --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/registry.ts @@ -0,0 +1,252 @@ +import { + HOOK_PERMISSION, + KNOWN_METHODS, + METHOD_PERMISSION, + UNCONDITIONAL_METHODS, + isKnownPermission, +} from '../../protocol/envelope'; +import { getEntry, isPluginController, type ClassRef } from './metadata'; +import type { + PluginHookContract, + PluginRpcContext, + PluginRpcEntry, + PluginRpcListing, + PluginRpcRegistryOptions, +} from './types'; + +interface BoundEntry { + instance: object; + className: string; + entry: PluginRpcEntry; +} + +/** The `ClassName.methodName` convention every problem message uses. */ +function describeBound(bound: BoundEntry): string { + return `${bound.className}.${bound.entry.methodName}`; +} + +/** Nest's @Controller() path metadata key. Read only to reject a mis-decorated class. */ +const NEST_PATH_METADATA = 'path'; + +/** + * reflect-metadata is loaded by Nest at boot, but the kit deliberately does not + * depend on it (see metadata.ts), so a bare vitest worker may not have it. Probing + * through an optional shape keeps the @Controller guard best-effort instead of a + * hard prerequisite. + */ +const reflectMaybe = Reflect as typeof Reflect & { + hasMetadata?: (metadataKey: string, target: object) => boolean; +}; + +/** Own + inherited method names, excluding constructor and Object.prototype. */ +function enumerateMethodNames(instance: object): string[] { + const names = new Set(); + let proto: object | null = Object.getPrototypeOf(instance); + while (proto && proto !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (name === 'constructor') continue; + const descriptor = Object.getOwnPropertyDescriptor(proto, name); + if (typeof descriptor?.value === 'function') names.add(name); + } + proto = Object.getPrototypeOf(proto); + } + return [...names]; +} + +/** + * Holds every decorated plugin RPC entry bound to its provider instance, checks the + * decorator arguments against protocol/envelope.ts, and fills a per-plugin dispatch + * map with the subset that plugin was granted. + * + * Plain class with no DI so `createTestPluginRegistry` can build one without a Nest + * app; `PluginRpcRegistryService` is the DI-discovered subclass. + */ +export class PluginRpcRegistry { + private readonly bound: BoundEntry[] = []; + + constructor(private readonly options: PluginRpcRegistryOptions = {}) {} + + /** + * Records every decorated method of `instance`. `methodNames` (e.g. from Nest's + * MetadataScanner) narrows the scan; omitted, the prototype chain is enumerated. + */ + register(instance: object, methodNames?: readonly string[]): void { + const ctor = (instance as { constructor: unknown }).constructor; + if (!isPluginController(ctor)) { + throw new Error(`${(ctor as ClassRef).name} is not decorated with @PluginController()`); + } + if (reflectMaybe.hasMetadata?.(NEST_PATH_METADATA, ctor as object)) { + throw new Error( + `${(ctor as ClassRef).name} is a Nest @Controller. @PluginController must go on a provider class, ` + + 'because @Injectable() rewrites the scope metadata @Controller() set', + ); + } + const className = (ctor as ClassRef).name; + const names = methodNames ?? enumerateMethodNames(instance); + for (const name of new Set(names)) { + const entry = getEntry(ctor as ClassRef, name); + if (entry) this.bound.push({ instance, className, entry }); + } + } + + /** + * Re-checks every decorator argument against the runtime tables and throws ONCE + * with all problems listed. The type layer already pins these, but types evaporate + * under an `as` cast or a hand-emitted JS consumer, so the same invariant is + * asserted on both paths. + */ + validate(): void { + const problems: string[] = []; + const byMethod = new Map(); + const hookFns = new Set(); + const coveredHooks = new Set(); + + for (const bound of this.bound) { + const entry = bound.entry; + if (entry.kind === 'method') { + if (!(KNOWN_METHODS as readonly string[]).includes(entry.method)) { + problems.push(`method "${entry.method}" (${describeBound(bound)}) is not in KNOWN_METHODS`); + continue; + } + const expected = (METHOD_PERMISSION as Record)[entry.method]; + if (entry.permission !== expected) { + problems.push( + `method "${entry.method}" (${describeBound(bound)}) declares "${entry.permission}" ` + + `but METHOD_PERMISSION says "${expected}"`, + ); + } + if (!isKnownPermission(entry.permission)) { + problems.push( + `method "${entry.method}" (${describeBound(bound)}) declares unknown permission "${entry.permission}"`, + ); + } + } else if (entry.kind === 'open') { + if (!(UNCONDITIONAL_METHODS as readonly string[]).includes(entry.method)) { + problems.push(`open method "${entry.method}" (${describeBound(bound)}) is not in UNCONDITIONAL_METHODS`); + } + } else { + const expected = (HOOK_PERMISSION as Record)[entry.hook]; + if (!expected) { + problems.push(`hook "${entry.hook}" (${describeBound(bound)}) is not a HOOK_PERMISSION key`); + } else if (entry.permission !== expected) { + problems.push( + `hook "${entry.hook}" (${describeBound(bound)}) declares "${entry.permission}" ` + + `but HOOK_PERMISSION says "${expected}"`, + ); + } + // Uniqueness is per (hook, fn), NOT one consumer per hook: photoProvider has + // three call sites and calendarSource two distinct fns. + const key = `${entry.hook}#${entry.fn}`; + if (hookFns.has(key)) { + problems.push(`hook fn "${key}" is declared twice (${describeBound(bound)})`); + } + hookFns.add(key); + coveredHooks.add(entry.hook); + } + + if (entry.kind !== 'hook') { + const list = byMethod.get(entry.method) ?? []; + list.push(bound); + byMethod.set(entry.method, list); + } + } + + for (const [method, list] of byMethod) { + if (list.length > 1) { + problems.push(`method "${method}" is handled by ${list.map(describeBound).join(' and ')}`); + } + } + + if (this.options.requireTotalCoverage) { + for (const method of [...KNOWN_METHODS, ...UNCONDITIONAL_METHODS]) { + if (!byMethod.has(method)) problems.push(`method "${method}" has no handler`); + } + for (const hook of Object.keys(HOOK_PERMISSION)) { + if (!coveredHooks.has(hook)) { + problems.push(`hook "${hook}" has no host-side consumer (dead grant on the consent screen)`); + } + } + } + + if (problems.length) throw new Error(`Invalid plugin RPC registry: ${problems.join('; ')}`); + } + + /** + * Fills `map` with the handlers this plugin's grants unlock. Registration IS + * authorization, exactly as the legacy router does it: an ungranted method is + * ABSENT from the map rather than present-and-guarded, so an ungranted call still + * falls through the untouched miss branch in handle() to PERMISSION_DENIED (known) + * or UNKNOWN_METHOD (unknown). + * + * Per-plugin and per-host, never cached, because grants change on re-consent and + * the supervisor then rebuilds the host. + * + * Throws on a name the caller's map already holds, so a half-migrated method + * registered BOTH in the legacy constructor and here is a loud boot failure + * instead of a silent override. + */ + bindInto( + map: Map, actingUserId: number | undefined) => unknown>, + granted: ReadonlySet, + makeCtx: (actingUserId: number | undefined) => PluginRpcContext, + ): void { + for (const bound of this.bound) { + const entry = bound.entry; + if (entry.kind === 'hook') continue; + if (entry.kind === 'method' && !granted.has(entry.permission)) continue; + if (map.has(entry.method)) { + throw new Error( + `plugin RPC method "${entry.method}" is registered twice ` + + `(${describeBound(bound)} and the legacy router)`, + ); + } + const handler = (bound.instance as Record unknown>)[entry.methodName].bind( + bound.instance, + ); + map.set(entry.method, (params, actingUserId) => handler(params, makeCtx(actingUserId))); + } + } + + /** Every declared (hook, fn) contract, keyed `${hook}#${fn}`. */ + hookContracts(): ReadonlyMap { + const contracts = new Map(); + for (const bound of this.bound) { + const entry = bound.entry; + if (entry.kind !== 'hook') continue; + contracts.set(`${entry.hook}#${entry.fn}`, { + hook: entry.hook, + fn: entry.fn, + permission: entry.permission, + timeoutMs: entry.timeoutMs, + consumer: describeBound(bound), + }); + } + return contracts; + } + + /** The contract a call site relies on, or a throw naming what it asked for. */ + hookContract(hook: string, fn: string): PluginHookContract { + const contract = this.hookContracts().get(`${hook}#${fn}`); + if (!contract) throw new Error(`no @PluginHook declaration for "${hook}#${fn}"`); + return contract; + } + + /** The wire methods this registry owns, for the coverage ledger test. */ + methodNames(): Set { + const names = new Set(); + for (const bound of this.bound) { + if (bound.entry.kind !== 'hook') names.add(bound.entry.method); + } + return names; + } + + list(): PluginRpcListing[] { + return this.bound.map(({ className, entry }) => ({ + kind: entry.kind, + name: entry.kind === 'hook' ? `${entry.hook}#${entry.fn}` : entry.method, + permission: entry.kind === 'open' ? null : entry.permission, + className, + methodName: entry.methodName, + })); + } +} diff --git a/server/src/nest/plugins/host/rpc-kit/testing.ts b/server/src/nest/plugins/host/rpc-kit/testing.ts new file mode 100644 index 000000000..c395ff9ca --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/testing.ts @@ -0,0 +1,19 @@ +import { PluginRpcRegistry } from './registry'; +import type { PluginRpcRegistryOptions } from './types'; + +/** + * Builds a registry from hand-constructed `@PluginController()` instances, with no + * Nest app required. This is why the metadata store is a WeakMap rather than + * reflect-metadata: a bare vitest worker can assemble the same registry the + * container would. + */ +export function createTestPluginRegistry( + instances: readonly object[], + options: PluginRpcRegistryOptions = {}, +): PluginRpcRegistry { + const registry = new PluginRpcRegistry(options); + for (const instance of instances) registry.register(instance); + // Same fail-fast contract as PluginRpcRegistryService.onModuleInit. + registry.validate(); + return registry; +} diff --git a/server/src/nest/plugins/host/rpc-kit/types.ts b/server/src/nest/plugins/host/rpc-kit/types.ts new file mode 100644 index 000000000..2346ad02c --- /dev/null +++ b/server/src/nest/plugins/host/rpc-kit/types.ts @@ -0,0 +1,70 @@ +import type { HookKey, HookPermission, KnownMethod, UnconditionalMethod } from '../../protocol/envelope'; +import type { PluginDataDb } from '../plugin-data.service'; + +/** + * Everything a plugin RPC handler may see. Built PER DISPATCH, never cached. + * + * `actingUserId` is HOST-bound: the supervisor resolves it from its invocation map + * before dispatch, and it is never taken from params. A plugin could otherwise set + * an `asUserId` field to any id and read another user's trips. + * + * `data` is a GETTER, resolved on every access and never captured. The reason is + * spelled out on `get data()` in plugin-host-deps.factory.ts: disable()/re-enable + * builds a NEW host while the old one's dispose() may still close the handle, so a + * captured handle leaves the new host throwing 'database connection is not open'. + * A constructor-injected DB in a singleton *Rpc class would additionally be a + * cross-plugin data leak, since these classes are per-app, not per-plugin. + */ +export interface PluginRpcContext { + readonly pluginId: string; + readonly actingUserId: number | undefined; + readonly data: PluginDataDb; +} + +export type PluginRpcHandler = (params: Record, ctx: PluginRpcContext) => unknown; + +/** + * What a decorator records. `methodName` is the name of the decorated class method, + * `method` the wire name it answers to; they are deliberately independent. + */ +export type PluginRpcEntry = + | { kind: 'method'; methodName: string; method: KnownMethod; permission: string } + | { kind: 'open'; methodName: string; method: UnconditionalMethod } + | { kind: 'hook'; methodName: string; hook: HookKey; permission: string; fn: string; timeoutMs: number }; + +export interface PluginHookOptions { + /** MUST equal HOOK_PERMISSION[hook]. The compiler enforces it, validate() re-checks it. */ + permission: HookPermission; + /** The function on the plugin's `hooks.` object, e.g. 'getWarnings'. */ + fn: string; + /** Host to plugin budget for this fn. Defaults to 5000 to match the common case. */ + timeoutMs?: number; +} + +/** One declared (hook, fn) contract, for the hook call sites and their tests. */ +export interface PluginHookContract { + hook: HookKey; + fn: string; + permission: string; + timeoutMs: number; + /** `ClassName.methodName` of the host-side consumer that declared it. */ + consumer: string; +} + +export interface PluginRpcListing { + kind: PluginRpcEntry['kind']; + /** The wire method, or `${hook}#${fn}` for a hook declaration. */ + name: string; + /** null means unconditional (no grant check). */ + permission: string | null; + className: string; + methodName: string; +} + +export interface PluginRpcRegistryOptions { + /** + * Flip to true once the legacy router map is empty: validate() then also fails when + * a KNOWN_METHOD has no handler, or a hook has no host-side consumer. + */ + requireTotalCoverage?: boolean; +} diff --git a/server/src/nest/plugins/host/rpc-params.ts b/server/src/nest/plugins/host/rpc-params.ts new file mode 100644 index 000000000..97a8b4ade --- /dev/null +++ b/server/src/nest/plugins/host/rpc-params.ts @@ -0,0 +1,43 @@ +import { BadParams } from './rpc-errors'; + +/** + * The param coercions every plugin RPC handler shares, lifted verbatim out of + * rpc-host.ts so decorated *.rpc.ts handlers use the same ones. + * + * Their leniency is part of the published contract: num() accepts numeric strings + * and asPayload() wraps a non-object in { value }, so tightening either would reject + * calls that shipped plugins make today. + */ + +export const num = (v: unknown, name: string): number => { + const n = typeof v === 'string' ? Number(v) : v; + if (typeof n !== 'number' || !Number.isFinite(n)) throw new BadParams(`${name} must be a number`); + return n; +}; + +export const str = (v: unknown, name: string): string => { + if (typeof v !== 'string') throw new BadParams(`${name} must be a string`); + return v; +}; + +export function asArgs(v: unknown): unknown[] { + if (v == null) return []; + if (Array.isArray(v)) return v; + throw new BadParams('args must be an array'); +} + +/** + * Coerce a db.tx `ops` param into a validated {sql, args}[] before it reaches the + * data db: each op must carry a string sql and, if present, an array of args. + */ +export function asTxOps(v: unknown): Array<{ sql: string; args?: unknown[] }> { + if (!Array.isArray(v)) throw new BadParams('ops must be an array of { sql, args }'); + return v.map((op) => { + const o = (op ?? {}) as Record; + return { sql: str(o.sql, 'ops[].sql'), args: asArgs(o.args) }; + }); +} + +export function asPayload(v: unknown): Record { + return v && typeof v === 'object' ? (v as Record) : { value: v }; +} diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index 6c594564c..1e920579b 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -160,6 +160,25 @@ export const KNOWN_METHODS = [ ] as const; export type KnownMethod = (typeof KNOWN_METHODS)[number]; +/** + * The three methods the router registers UNCONDITIONALLY (see rpc-host.ts, the block + * after the last `if (has(...))`). `plugins.call` and `events.emit` are authorized by + * the router's declared-dependency-edge check instead of by a grant, and `settings.get` + * only ever returns THIS plugin's config for the acting user, so it needs none. + * + * They MUST stay disjoint from KNOWN_METHODS. Putting them there would give each one a + * METHOD_PERMISSION row, and that table is what isAuditable (plugin-audit.ts) reads; + * settings.get is deliberately the one unconditional method that is NOT audited. It + * would also change the UNKNOWN_METHOD/PERMISSION_DENIED split documented above. + */ +export const UNCONDITIONAL_METHODS = ['plugins.call', 'events.emit', 'settings.get'] as const; +export type UnconditionalMethod = (typeof UNCONDITIONAL_METHODS)[number]; + +/** `true` only when the two unions share no member; otherwise `never`, which fails to compile. */ +type AssertDisjoint = [Extract] extends [never] ? true : never; +/** Compile-time proof of the invariant the comment above states. */ +export const UNCONDITIONAL_METHODS_ARE_DISJOINT: AssertDisjoint = true; + /** Which permission unlocks which method(s). The single source for the router. */ export const METHOD_PERMISSION = { 'db.exec': 'db:own', diff --git a/server/tests/unit/plugins/rpc-kit/decorators.test.ts b/server/tests/unit/plugins/rpc-kit/decorators.test.ts new file mode 100644 index 000000000..806065fa6 --- /dev/null +++ b/server/tests/unit/plugins/rpc-kit/decorators.test.ts @@ -0,0 +1,136 @@ +/** + * The four plugin RPC decorators. The compile-time half of their contract is + * enforced by tsc and cannot be asserted here; what these tests pin is what each + * decorator RECORDS, because that is what validate() and bindInto() later read. + */ +import { describe, it, expect } from 'vitest'; +import { SCOPE_OPTIONS_METADATA } from '@nestjs/common/constants'; +import { + PluginController, + PluginHook, + PluginMethod, + PluginOpenMethod, +} from '../../../../src/nest/plugins/host/rpc-kit/decorators'; +import { getEntry, isPluginController, type ClassRef } from '../../../../src/nest/plugins/host/rpc-kit/metadata'; + +const entryOf = (ctor: unknown, methodName: string) => getEntry(ctor as ClassRef, methodName); + +describe('rpc-kit decorators', () => { + it('RPCKIT-DEC-001 @PluginController marks the class', () => { + @PluginController() + class Sample {} + expect(isPluginController(Sample)).toBe(true); + }); + + it('RPCKIT-DEC-002 @PluginController implies @Injectable', () => { + @PluginController() + class Sample {} + // What Injectable() writes. Nest resolves the class as a provider off this. + expect(Reflect.getMetadata(SCOPE_OPTIONS_METADATA, Sample)).toBeUndefined(); + expect(Reflect.hasMetadata(SCOPE_OPTIONS_METADATA, Sample)).toBe(true); + }); + + it('RPCKIT-DEC-003 @PluginMethod records the wire method and its permission', () => { + @PluginController() + class Sample { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + } + expect(entryOf(Sample, 'list')).toEqual({ + kind: 'method', + methodName: 'list', + method: 'tags.list', + permission: 'db:read:tags', + }); + }); + + it('RPCKIT-DEC-004 the class method name and the wire method are independent', () => { + @PluginController() + class Sample { + @PluginMethod('tags.delete', { permission: 'db:write:tags' }) + removeTheTag() { + return { deleted: true }; + } + } + const entry = entryOf(Sample, 'removeTheTag'); + expect(entry).toMatchObject({ methodName: 'removeTheTag', method: 'tags.delete' }); + }); + + it('RPCKIT-DEC-005 @PluginOpenMethod records no permission at all', () => { + @PluginController() + class Sample { + @PluginOpenMethod('settings.get') + get() { + return { value: undefined }; + } + } + expect(entryOf(Sample, 'get')).toEqual({ kind: 'open', methodName: 'get', method: 'settings.get' }); + }); + + it('RPCKIT-DEC-006 @PluginHook records hook, fn and permission', () => { + @PluginController() + class Sample { + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' }) + declare() {} + } + expect(entryOf(Sample, 'declare')).toMatchObject({ + kind: 'hook', + hook: 'warningProvider', + fn: 'getWarnings', + permission: 'hook:trip-warning-provider', + }); + }); + + it('RPCKIT-DEC-007 @PluginHook defaults the budget to 5000ms', () => { + @PluginController() + class Sample { + @PluginHook('mapMarkerProvider', { permission: 'hook:map-marker-provider', fn: 'getMarkers' }) + declare() {} + } + expect(entryOf(Sample, 'declare')).toMatchObject({ timeoutMs: 5000 }); + }); + + it('RPCKIT-DEC-008 an explicit timeout wins over the default', () => { + @PluginController() + class Sample { + @PluginHook('routeProvider', { permission: 'hook:route-provider', fn: 'getRoute', timeoutMs: 20_000 }) + declare() {} + } + expect(entryOf(Sample, 'declare')).toMatchObject({ timeoutMs: 20_000 }); + }); + + it('RPCKIT-DEC-009 several decorated methods on one class are all recorded', () => { + @PluginController() + class Sample { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + @PluginMethod('tags.create', { permission: 'db:write:tags' }) + create() { + return {}; + } + undecorated() { + return null; + } + } + expect(entryOf(Sample, 'list')).toMatchObject({ method: 'tags.list' }); + expect(entryOf(Sample, 'create')).toMatchObject({ method: 'tags.create' }); + expect(entryOf(Sample, 'undecorated')).toBeUndefined(); + }); + + it('RPCKIT-DEC-010 a method decorator alone does not mark the class', () => { + class Sample { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + } + // The entry is recorded, but register() will still reject the class. That split + // is deliberate: listing the class in providers: [] must stay an explicit opt-in. + expect(entryOf(Sample, 'list')).toBeDefined(); + expect(isPluginController(Sample)).toBe(false); + }); +}); diff --git a/server/tests/unit/plugins/rpc-kit/metadata.test.ts b/server/tests/unit/plugins/rpc-kit/metadata.test.ts new file mode 100644 index 000000000..83aab455a --- /dev/null +++ b/server/tests/unit/plugins/rpc-kit/metadata.test.ts @@ -0,0 +1,94 @@ +/** + * The metadata store under the plugin RPC decorators. It is a WeakMap/WeakSet keyed + * by constructor rather than reflect-metadata, so these tests run without a Nest app + * and without any polyfill import order to get right. + */ +import { describe, it, expect } from 'vitest'; +import { + addEntry, + getEntry, + isPluginController, + markController, + type ClassRef, +} from '../../../../src/nest/plugins/host/rpc-kit/metadata'; +import type { PluginRpcEntry } from '../../../../src/nest/plugins/host/rpc-kit/types'; + +const methodEntry = (methodName: string, method: 'tags.list' | 'tags.create' = 'tags.list'): PluginRpcEntry => ({ + kind: 'method', + methodName, + method, + permission: method === 'tags.list' ? 'db:read:tags' : 'db:write:tags', +}); + +describe('rpc-kit metadata store', () => { + it('RPCKIT-META-001 an unmarked class is not a plugin controller', () => { + class Plain {} + expect(isPluginController(Plain)).toBe(false); + }); + + it('RPCKIT-META-002 markController makes the class recognisable', () => { + class Marked {} + markController(Marked as unknown as ClassRef); + expect(isPluginController(Marked)).toBe(true); + }); + + it('RPCKIT-META-003 a non-function is never a controller', () => { + expect(isPluginController({})).toBe(false); + expect(isPluginController(undefined)).toBe(false); + expect(isPluginController('Marked')).toBe(false); + }); + + it('RPCKIT-META-004 an entry is retrievable by its method name', () => { + class Holder {} + const entry = methodEntry('list'); + addEntry(Holder as unknown as ClassRef, entry); + expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(entry); + }); + + it('RPCKIT-META-005 an unknown method name resolves to undefined', () => { + class Holder {} + addEntry(Holder as unknown as ClassRef, methodEntry('list')); + expect(getEntry(Holder as unknown as ClassRef, 'missing')).toBeUndefined(); + }); + + it('RPCKIT-META-006 a second entry for the same name replaces the first', () => { + class Holder {} + addEntry(Holder as unknown as ClassRef, methodEntry('list')); + const second = methodEntry('list', 'tags.create'); + addEntry(Holder as unknown as ClassRef, second); + expect(getEntry(Holder as unknown as ClassRef, 'list')).toBe(second); + }); + + it('RPCKIT-META-007 entries are found on a subclass through the prototype chain', () => { + class Base {} + class Child extends Base {} + const entry = methodEntry('list'); + addEntry(Base as unknown as ClassRef, entry); + expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(entry); + }); + + it('RPCKIT-META-008 a subclass entry wins over the inherited one', () => { + class Base {} + class Child extends Base {} + addEntry(Base as unknown as ClassRef, methodEntry('list')); + const own = methodEntry('list', 'tags.create'); + addEntry(Child as unknown as ClassRef, own); + expect(getEntry(Child as unknown as ClassRef, 'list')).toBe(own); + }); + + it('RPCKIT-META-009 walking the chain stops cleanly at the root', () => { + class Lonely {} + expect(getEntry(Lonely as unknown as ClassRef, 'anything')).toBeUndefined(); + }); + + it('RPCKIT-META-010 two classes keep separate entry maps', () => { + class A {} + class B {} + const a = methodEntry('list'); + const b = methodEntry('list', 'tags.create'); + addEntry(A as unknown as ClassRef, a); + addEntry(B as unknown as ClassRef, b); + expect(getEntry(A as unknown as ClassRef, 'list')).toBe(a); + expect(getEntry(B as unknown as ClassRef, 'list')).toBe(b); + }); +}); diff --git a/server/tests/unit/plugins/rpc-kit/registry.service.test.ts b/server/tests/unit/plugins/rpc-kit/registry.service.test.ts new file mode 100644 index 000000000..a8c38c26f --- /dev/null +++ b/server/tests/unit/plugins/rpc-kit/registry.service.test.ts @@ -0,0 +1,99 @@ +/** + * The DI-discovered registry. It scans providers only, dedupes by instance, and + * validates at boot so a wrong decorator argument fails the app start rather than a + * single plugin activation. + */ +import { describe, it, expect, vi } from 'vitest'; +import type { DiscoveryService, MetadataScanner } from '@nestjs/core'; +import { PluginRpcRegistryService } from '../../../../src/nest/plugins/host/rpc-kit/registry.service'; +import { PluginController, PluginMethod } from '../../../../src/nest/plugins/host/rpc-kit/decorators'; + +@PluginController() +class TagsRpc { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } +} + +@PluginController() +class BadRpc { + @PluginMethod('tags.create', { permission: 'db:read:tags' as 'db:write:tags' }) + create() { + return {}; + } +} + +class PlainProvider { + helper() { + return 1; + } +} + +/** Mimics Nest's provider wrappers, including the same instance listed twice. */ +function serviceFor(instances: object[]): PluginRpcRegistryService { + const discovery = { + getProviders: () => instances.map((instance) => ({ instance, metatype: instance.constructor })), + } as unknown as DiscoveryService; + const scanner = { + getAllMethodNames: (proto: object) => + Object.getOwnPropertyNames(proto).filter((n) => n !== 'constructor'), + } as unknown as MetadataScanner; + return new PluginRpcRegistryService(discovery, scanner); +} + +describe('PluginRpcRegistryService', () => { + it('RPCKIT-SVC-001 registers the marked providers at boot', () => { + const service = serviceFor([new TagsRpc(), new PlainProvider()]); + service.onModuleInit(); + expect(service.methodNames()).toEqual(new Set(['tags.list'])); + }); + + it('RPCKIT-SVC-002 the same instance wrapped twice is registered once', () => { + const instance = new TagsRpc(); + const service = serviceFor([instance, instance]); + service.onModuleInit(); + // Without the dedupe this would be two entries and validate() would report + // tags.list as double-owned. + expect(service.list()).toHaveLength(1); + }); + + it('RPCKIT-SVC-003 unmarked providers are skipped rather than rejected', () => { + const service = serviceFor([new PlainProvider()]); + expect(() => service.onModuleInit()).not.toThrow(); + expect(service.list()).toEqual([]); + }); + + it('RPCKIT-SVC-004 a null instance is skipped', () => { + const discovery = { + getProviders: () => [{ instance: null, metatype: TagsRpc }, { instance: undefined, metatype: TagsRpc }], + } as unknown as DiscoveryService; + const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner; + const service = new PluginRpcRegistryService(discovery, scanner); + expect(() => service.onModuleInit()).not.toThrow(); + expect(service.list()).toEqual([]); + }); + + it('RPCKIT-SVC-005 a wrong decorator argument fails the boot, not the first plugin call', () => { + const service = serviceFor([new BadRpc()]); + expect(() => service.onModuleInit()).toThrow( + /method "tags.create" \(BadRpc.create\) declares "db:read:tags" but METHOD_PERMISSION says "db:write:tags"/, + ); + }); + + it('RPCKIT-SVC-006 total coverage is NOT required while the legacy router still owns methods', () => { + const service = serviceFor([new TagsRpc()]); + expect(() => service.onModuleInit()).not.toThrow(); + }); + + it('RPCKIT-SVC-007 only providers are scanned, never controllers', () => { + const getControllers = vi.fn(() => []); + const discovery = { + getProviders: () => [], + getControllers, + } as unknown as DiscoveryService; + const scanner = { getAllMethodNames: () => [] } as unknown as MetadataScanner; + new PluginRpcRegistryService(discovery, scanner).onModuleInit(); + expect(getControllers).not.toHaveBeenCalled(); + }); +}); diff --git a/server/tests/unit/plugins/rpc-kit/registry.test.ts b/server/tests/unit/plugins/rpc-kit/registry.test.ts new file mode 100644 index 000000000..06fd253a3 --- /dev/null +++ b/server/tests/unit/plugins/rpc-kit/registry.test.ts @@ -0,0 +1,394 @@ +/** + * The registry is where a decorator argument is re-checked against + * protocol/envelope.ts at runtime, and where the granted subset is bound into the + * per-plugin dispatch map. Types evaporate under an `as` cast, so every compile-time + * guarantee has a runtime twin here, and each one gets a test. + */ +import { describe, it, expect, vi } from 'vitest'; +import { Controller } from '@nestjs/common'; +import { + PluginController, + PluginHook, + PluginMethod, + PluginOpenMethod, +} from '../../../../src/nest/plugins/host/rpc-kit/decorators'; +import { PluginRpcRegistry } from '../../../../src/nest/plugins/host/rpc-kit/registry'; +import { createTestPluginRegistry } from '../../../../src/nest/plugins/host/rpc-kit/testing'; +import type { PluginRpcContext } from '../../../../src/nest/plugins/host/rpc-kit/types'; + +const ctxFor = (actingUserId: number | undefined): PluginRpcContext => ({ + pluginId: 'p', + actingUserId, + data: {} as PluginRpcContext['data'], +}); + +/** A well-formed controller: two tags methods with the permissions the table assigns. */ +@PluginController() +class TagsRpc { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list(_params: Record, ctx: PluginRpcContext) { + return { calledBy: ctx.actingUserId }; + } + + @PluginMethod('tags.create', { permission: 'db:write:tags' }) + create(params: Record, ctx: PluginRpcContext) { + return { name: params.name, by: ctx.actingUserId }; + } +} + +describe('PluginRpcRegistry — registration', () => { + it('RPCKIT-REG-001 an undecorated class is rejected by name', () => { + class NotAController {} + expect(() => new PluginRpcRegistry().register(new NotAController())).toThrow( + /NotAController is not decorated with @PluginController/, + ); + }); + + it('RPCKIT-REG-002 a Nest @Controller is rejected, because @Injectable rewrites its scope metadata', () => { + @PluginController() + @Controller('things') + class HttpThing { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + } + expect(() => new PluginRpcRegistry().register(new HttpThing())).toThrow(/is a Nest @Controller/); + }); + + it('RPCKIT-REG-003 only decorated methods are recorded', () => { + @PluginController() + class Mixed { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + helper() { + return 1; + } + } + const registry = createTestPluginRegistry([new Mixed()]); + expect(registry.list()).toHaveLength(1); + expect(registry.methodNames()).toEqual(new Set(['tags.list'])); + }); + + it('RPCKIT-REG-004 an explicit method-name list narrows the scan', () => { + const registry = new PluginRpcRegistry(); + registry.register(new TagsRpc(), ['list']); + expect(registry.methodNames()).toEqual(new Set(['tags.list'])); + }); + + it('RPCKIT-REG-005 inherited decorated methods are picked up', () => { + @PluginController() + class Base { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + } + @PluginController() + class Child extends Base {} + const registry = createTestPluginRegistry([new Child()]); + expect(registry.methodNames()).toEqual(new Set(['tags.list'])); + }); + + it('RPCKIT-REG-006 list() reports kind, permission and the owning class', () => { + const registry = createTestPluginRegistry([new TagsRpc()]); + expect(registry.list()).toEqual( + expect.arrayContaining([ + { kind: 'method', name: 'tags.list', permission: 'db:read:tags', className: 'TagsRpc', methodName: 'list' }, + ]), + ); + }); +}); + +describe('PluginRpcRegistry — validate', () => { + it('RPCKIT-REG-007 a well-formed controller validates', () => { + expect(() => createTestPluginRegistry([new TagsRpc()])).not.toThrow(); + }); + + it('RPCKIT-REG-008 a permission the table does not assign is named with the expected literal', () => { + @PluginController() + class Wrong { + // The cast is the point: this is exactly what a hand-emitted consumer or an + // `as` cast would smuggle past tsc. + @PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' }) + list() { + return []; + } + } + expect(() => createTestPluginRegistry([new Wrong()])).toThrow( + /method "tags.list" \(Wrong.list\) declares "db:write:tags" but METHOD_PERMISSION says "db:read:tags"/, + ); + }); + + it('RPCKIT-REG-008b a permission that does not exist at all is reported as unknown, too', () => { + @PluginController() + class Typo { + @PluginMethod('tags.list', { permission: 'db:read:tapps' as 'db:read:tags' }) + list() { + return []; + } + } + let error: Error | undefined; + try { + createTestPluginRegistry([new Typo()]); + } catch (e) { + error = e as Error; + } + // Both checks fire: it disagrees with the table AND names a permission the host + // does not know. The second is what catches a permission invented out of thin air. + expect(error?.message).toMatch(/but METHOD_PERMISSION says "db:read:tags"/); + expect(error?.message).toMatch( + /method "tags.list" \(Typo.list\) declares unknown permission "db:read:tapps"/, + ); + }); + + it('RPCKIT-REG-009 a method outside KNOWN_METHODS is named', () => { + @PluginController() + class Bogus { + @PluginMethod('tags.lst' as 'tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + } + expect(() => createTestPluginRegistry([new Bogus()])).toThrow( + /method "tags.lst" \(Bogus.list\) is not in KNOWN_METHODS/, + ); + }); + + it('RPCKIT-REG-010 an open method outside UNCONDITIONAL_METHODS is named', () => { + @PluginController() + class Bogus { + @PluginOpenMethod('tags.list' as 'settings.get') + get() { + return {}; + } + } + expect(() => createTestPluginRegistry([new Bogus()])).toThrow( + /open method "tags.list" \(Bogus.get\) is not in UNCONDITIONAL_METHODS/, + ); + }); + + it('RPCKIT-REG-011 two owners of the same method name both classes', () => { + @PluginController() + class Second { + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return []; + } + } + expect(() => createTestPluginRegistry([new TagsRpc(), new Second()])).toThrow( + /method "tags.list" is handled by TagsRpc.list and Second.list/, + ); + }); + + it('RPCKIT-REG-012 a hook permission that disagrees with HOOK_PERMISSION is named', () => { + @PluginController() + class Wrong { + @PluginHook('warningProvider', { + permission: 'hook:route-provider' as 'hook:trip-warning-provider', + fn: 'getWarnings', + }) + declare() {} + } + expect(() => createTestPluginRegistry([new Wrong()])).toThrow( + /hook "warningProvider" \(Wrong.declare\) declares "hook:route-provider" but HOOK_PERMISSION says "hook:trip-warning-provider"/, + ); + }); + + it('RPCKIT-REG-013 a hook name that is not a HOOK_PERMISSION key is named', () => { + @PluginController() + class Wrong { + @PluginHook('warningProvidr' as 'warningProvider', { + permission: 'hook:trip-warning-provider', + fn: 'x', + }) + declare() {} + } + expect(() => createTestPluginRegistry([new Wrong()])).toThrow( + /hook "warningProvidr" \(Wrong.declare\) is not a HOOK_PERMISSION key/, + ); + }); + + it('RPCKIT-REG-014 the same (hook, fn) pair declared twice is rejected', () => { + @PluginController() + class A { + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' }) + declare() {} + } + @PluginController() + class B { + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' }) + declare() {} + } + expect(() => createTestPluginRegistry([new A(), new B()])).toThrow( + /hook fn "warningProvider#getWarnings" is declared twice \(B.declare\)/, + ); + }); + + it('RPCKIT-REG-015 two fns of the SAME hook are allowed — the fan-out is not uniform', () => { + @PluginController() + class Calendar { + @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 }) + name() {} + @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents', timeoutMs: 5000 }) + events() {} + } + expect(() => createTestPluginRegistry([new Calendar()])).not.toThrow(); + }); + + it('RPCKIT-REG-016 every problem is reported at once, not just the first', () => { + @PluginController() + class Wrong { + @PluginMethod('tags.list', { permission: 'db:write:tags' as 'db:read:tags' }) + list() { + return []; + } + @PluginOpenMethod('tags.create' as 'settings.get') + get() { + return {}; + } + } + expect(() => createTestPluginRegistry([new Wrong()])).toThrow( + /METHOD_PERMISSION says "db:read:tags"; open method "tags.create"/, + ); + }); + + it('RPCKIT-REG-017 requireTotalCoverage off tolerates an incomplete registry', () => { + expect(() => createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: false })).not.toThrow(); + }); + + it('RPCKIT-REG-018 requireTotalCoverage on names an unhandled method and a consumerless hook', () => { + let error: Error | undefined; + try { + createTestPluginRegistry([new TagsRpc()], { requireTotalCoverage: true }); + } catch (e) { + error = e as Error; + } + expect(error?.message).toMatch(/method "db.query" has no handler/); + expect(error?.message).toMatch(/method "plugins.call" has no handler/); + expect(error?.message).toMatch(/hook "photoProvider" has no host-side consumer \(dead grant on the consent screen\)/); + // tags.list IS handled, so it must not be listed. + expect(error?.message).not.toMatch(/method "tags.list" has no handler/); + }); +}); + +describe('PluginRpcRegistry — bindInto', () => { + const bind = (registry: PluginRpcRegistry, granted: string[]) => { + const map = new Map, uid: number | undefined) => unknown>(); + registry.bindInto(map, new Set(granted), ctxFor); + return map; + }; + + it('RPCKIT-REG-019 registration is authorization: an ungranted method is ABSENT, not guarded', () => { + const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:read:tags']); + expect([...map.keys()]).toEqual(['tags.list']); + expect(map.has('tags.create')).toBe(false); + }); + + it('RPCKIT-REG-020 no grants means an empty map', () => { + expect(bind(createTestPluginRegistry([new TagsRpc()]), []).size).toBe(0); + }); + + it('RPCKIT-REG-021 the bound handler receives params and a context', () => { + const map = bind(createTestPluginRegistry([new TagsRpc()]), ['db:write:tags']); + expect(map.get('tags.create')!({ name: 'work' }, 42)).toEqual({ name: 'work', by: 42 }); + }); + + it('RPCKIT-REG-022 the handler keeps its `this`', () => { + @PluginController() + class Stateful { + private readonly answer = 7; + @PluginMethod('tags.list', { permission: 'db:read:tags' }) + list() { + return this.answer; + } + } + const map = bind(createTestPluginRegistry([new Stateful()]), ['db:read:tags']); + expect(map.get('tags.list')!({}, 42)).toBe(7); + }); + + it('RPCKIT-REG-022b a host-scoped http:outbound grant is honoured, not rejected as unknown', () => { + // The check uses isKnownPermission, not a set lookup on KNOWN_PERMISSIONS. A naive + // lookup would reject every host-scoped egress grant, because that family is + // open-ended by design. + const registry = createTestPluginRegistry([new TagsRpc()]); + const map = bind(registry, ['db:read:tags', 'http:outbound:api.example.com']); + expect(map.has('tags.list')).toBe(true); + }); + + it('RPCKIT-REG-023 an open method binds with no grant at all', () => { + @PluginController() + class Open { + @PluginOpenMethod('settings.get') + get() { + return { value: undefined }; + } + } + expect([...bind(createTestPluginRegistry([new Open()]), []).keys()]).toEqual(['settings.get']); + }); + + it('RPCKIT-REG-024 hook declarations are never bound as methods', () => { + @PluginController() + class Hooks { + @PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings' }) + declare() {} + } + expect(bind(createTestPluginRegistry([new Hooks()]), ['hook:trip-warning-provider']).size).toBe(0); + }); + + it('RPCKIT-REG-025 a collision with a name the legacy router already owns throws', () => { + const registry = createTestPluginRegistry([new TagsRpc()]); + const map = new Map, uid: number | undefined) => unknown>(); + map.set('tags.list', () => 'legacy'); + expect(() => registry.bindInto(map, new Set(['db:read:tags']), ctxFor)).toThrow( + /plugin RPC method "tags.list" is registered twice \(TagsRpc.list and the legacy router\)/, + ); + }); + + it('RPCKIT-REG-026 the context is built per dispatch, not once per bind', () => { + const makeCtx = vi.fn(ctxFor); + const registry = createTestPluginRegistry([new TagsRpc()]); + const map = new Map, uid: number | undefined) => unknown>(); + registry.bindInto(map, new Set(['db:read:tags']), makeCtx); + expect(makeCtx).not.toHaveBeenCalled(); + expect(map.get('tags.list')!({}, 1)).toEqual({ calledBy: 1 }); + expect(map.get('tags.list')!({}, 2)).toEqual({ calledBy: 2 }); + expect(makeCtx).toHaveBeenCalledTimes(2); + }); +}); + +describe('PluginRpcRegistry — hook contracts', () => { + @PluginController() + class Calendar { + @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getName', timeoutMs: 3000 }) + name() {} + @PluginHook('calendarSource', { permission: 'hook:calendar-source', fn: 'getEvents' }) + events() {} + } + + it('RPCKIT-REG-027 every declared pair is listed with its consumer', () => { + const contracts = createTestPluginRegistry([new Calendar()]).hookContracts(); + expect([...contracts.keys()]).toEqual(['calendarSource#getName', 'calendarSource#getEvents']); + expect(contracts.get('calendarSource#getName')).toEqual({ + hook: 'calendarSource', + fn: 'getName', + permission: 'hook:calendar-source', + timeoutMs: 3000, + consumer: 'Calendar.name', + }); + }); + + it('RPCKIT-REG-028 hookContract returns the budget a call site should use', () => { + const registry = createTestPluginRegistry([new Calendar()]); + expect(registry.hookContract('calendarSource', 'getEvents').timeoutMs).toBe(5000); + }); + + it('RPCKIT-REG-029 asking for an undeclared pair throws and names it', () => { + const registry = createTestPluginRegistry([new Calendar()]); + expect(() => registry.hookContract('calendarSource', 'getNothing')).toThrow( + /no @PluginHook declaration for "calendarSource#getNothing"/, + ); + }); +});