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:
Maurice
2026-08-09 11:35:29 +02:00
committed by Maurice
parent 8c5a207a9b
commit 7b11680cc3
15 changed files with 1453 additions and 0 deletions
@@ -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"/,
);
});
});