mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
Add a decorator kit for the plugin RPC surface
The method-to-permission association exists twice today: declaratively in METHOD_PERMISSION, and imperatively as 44 if (has(...)) blocks in rpc-host.ts, with nothing asserting the two agree. protocol-paths.test.ts checks that each method has some permission, never that it is the same one the router gates on, and plugin-audit.ts inherits any drift silently. @PluginMethod binds the two together. Its permission argument is typed as MethodPermission<M>, so it resolves to the single literal the table assigns to that method and a mismatch does not compile. validate() re-checks the same argument against the same table at boot, because types evaporate under an as cast. @PluginHook does the same against HOOK_PERMISSION. UNCONDITIONAL_METHODS was missing from envelope.ts, so the three methods the router registers outside every grant check had no vocabulary of their own. Adding it here, with a compile-time proof that it stays disjoint from KNOWN_METHODS: giving those three a METHOD_PERMISSION row would change which methods isAuditable considers, and settings.get is deliberately the one unconditional method that is not audited. The kit is written to stay extractable into its own package, so an ESLint rule pins what it may import. Note the rule is a deny 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.
This commit is contained in:
@@ -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 {}
|
||||
@@ -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<M>` 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.
|
||||
@@ -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<M extends KnownMethod>(
|
||||
method: M,
|
||||
options: { permission: MethodPermission<M> },
|
||||
): 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<H extends HookKey>(hook: H, options: PluginHookOptions<H>): MethodDecorator {
|
||||
return pluginMethodDecorator((methodName) => ({
|
||||
kind: 'hook',
|
||||
methodName,
|
||||
hook,
|
||||
permission: options.permission,
|
||||
fn: options.fn,
|
||||
timeoutMs: options.timeoutMs ?? 5000,
|
||||
}));
|
||||
}
|
||||
@@ -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<ClassRef>();
|
||||
const entries = new WeakMap<ClassRef, Map<string, PluginRpcEntry>>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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<object>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<string>();
|
||||
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<string, BoundEntry[]>();
|
||||
const hookFns = new Set<string>();
|
||||
const coveredHooks = new Set<string>();
|
||||
|
||||
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<string, string>)[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<string, string | undefined>)[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<string, (params: Record<string, unknown>, actingUserId: number | undefined) => unknown>,
|
||||
granted: ReadonlySet<string>,
|
||||
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<string, (...args: unknown[]) => 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<string, PluginHookContract> {
|
||||
const contracts = new Map<string, PluginHookContract>();
|
||||
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<string> {
|
||||
const names = new Set<string>();
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown>, 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<H extends HookKey> {
|
||||
/** MUST equal HOOK_PERMISSION[hook]. The compiler enforces it, validate() re-checks it. */
|
||||
permission: HookPermission<H>;
|
||||
/** The function on the plugin's `hooks.<hook>` 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;
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
return { sql: str(o.sql, 'ops[].sql'), args: asArgs(o.args) };
|
||||
});
|
||||
}
|
||||
|
||||
export function asPayload(v: unknown): Record<string, unknown> {
|
||||
return v && typeof v === 'object' ? (v as Record<string, unknown>) : { value: v };
|
||||
}
|
||||
@@ -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<A extends string, B extends string> = [Extract<A, B>] extends [never] ? true : never;
|
||||
/** Compile-time proof of the invariant the comment above states. */
|
||||
export const UNCONDITIONAL_METHODS_ARE_DISJOINT: AssertDisjoint<UnconditionalMethod, KnownMethod> = true;
|
||||
|
||||
/** Which permission unlocks which method(s). The single source for the router. */
|
||||
export const METHOD_PERMISSION = {
|
||||
'db.exec': 'db:own',
|
||||
|
||||
Reference in New Issue
Block a user