mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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:
@@ -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.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>, ctx: PluginRpcContext) {
|
||||
return { calledBy: ctx.actingUserId };
|
||||
}
|
||||
|
||||
@PluginMethod('tags.create', { permission: 'db:write:tags' })
|
||||
create(params: Record<string, unknown>, 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<string, (p: Record<string, unknown>, 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<string, (p: Record<string, unknown>, 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<string, (p: Record<string, unknown>, 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"/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user