mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
f72aba0c91
The last 20 wire methods move onto the decorators: db.* and the three unconditional methods (DbRpc), meta.* (MetaRpc) and the host-mediated surface of users/broadcasts/notify/ai/oauth/scheduler (HostSurfaceRpc). That closes the ledger at 113/113, so the legacy has() blocks in rpc-host.ts are gone and the router is down to what was never domain-specific: build the granted subset of the dispatch map, dispatch, audit, map errors. PluginHostDepsFactory was the 26-argument wiring sheet behind those blocks. With the handlers in the domains it has two dependencies left, so it is now PluginRpcHostFactory: the plugin's own sqlite handle, the inter-plugin router bound with this plugin as the caller, and the audit sink. The 18 host-to-plugin hook calls get the same treatment. They used to spell out the fn name and the timeout at each controller, which meant nothing checked that a hook:* grant on the consent screen had a consumer behind it, that the fn matched what the SDK documents, or that two call sites agreed on the budget. PluginHooks declares all 18 with @PluginHook and performs them, and the controllers call it by name. With both halves complete, requireTotalCoverage goes on: a KNOWN_METHOD without a handler, or a hook without a consumer, now fails app boot instead of surfacing as a PERMISSION_DENIED nobody can explain.
203 lines
9.3 KiB
TypeScript
203 lines
9.3 KiB
TypeScript
/**
|
|
* The capability router is the plugin permission boundary (#plugins, M1).
|
|
*
|
|
* Every handler now lives on a decorated *.rpc.ts class in its own domain, and each of
|
|
* those has its own suite. What is left here is the router itself: an ungranted method
|
|
* is never reachable, an unknown one is told apart from a denied one, a thrown error
|
|
* maps to the right wire code, and the audit sink sees the call — without ever
|
|
* spawning a child (the router runs in the host).
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
import { PluginRpcHost, BadParams, ForbiddenResource, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
|
|
import { PluginController, PluginMethod, PluginOpenMethod } from '../../../src/nest/plugins/host/rpc-kit/decorators';
|
|
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
|
|
import type { PluginRpcContext } from '../../../src/nest/plugins/host/rpc-kit/types';
|
|
import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope';
|
|
import { makeDeps } from '../../helpers/rpc-host-deps';
|
|
|
|
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
|
|
const ok = (r: RpcResponse | RpcError): r is RpcResponse => r.ok === true;
|
|
const code = (r: RpcResponse | RpcError): string => (r as RpcError).error.code;
|
|
|
|
/**
|
|
* A throwaway controller over two real wire methods, so the router can be exercised
|
|
* without dragging a domain's services in. `tags.list` and `tags.create` are used
|
|
* because their permissions differ, which is what the split-grant cases need.
|
|
*/
|
|
@PluginController()
|
|
class ProbeRpc {
|
|
constructor(private readonly behaviour: (params: Record<string, unknown>, ctx: PluginRpcContext) => unknown) {}
|
|
|
|
@PluginMethod('tags.list', { permission: 'db:read:tags' })
|
|
read(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
|
return this.behaviour(params, ctx);
|
|
}
|
|
|
|
@PluginMethod('tags.create', { permission: 'db:write:tags' })
|
|
write(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
|
return this.behaviour(params, ctx);
|
|
}
|
|
|
|
@PluginOpenMethod('events.emit')
|
|
open(params: Record<string, unknown>, ctx: PluginRpcContext): unknown {
|
|
return this.behaviour(params, ctx);
|
|
}
|
|
}
|
|
|
|
const makeHost = (
|
|
granted: string[],
|
|
behaviour: (params: Record<string, unknown>, ctx: PluginRpcContext) => unknown = () => ({ done: true }),
|
|
deps: HostDeps = makeDeps(),
|
|
): PluginRpcHost =>
|
|
new PluginRpcHost('p', new Set(granted), deps, createTestPluginRegistry([new ProbeRpc(behaviour)]));
|
|
|
|
describe('PluginRpcHost — capability enforcement', () => {
|
|
it('RPCHOST-001 registers only granted methods; an ungranted one is PERMISSION_DENIED', async () => {
|
|
const seen = vi.fn(() => ({ done: true }));
|
|
const host = makeHost(['db:read:tags'], seen);
|
|
expect(ok(await host.dispatch(req('tags.list'), 42))).toBe(true);
|
|
expect(code(await host.dispatch(req('tags.create'), 42))).toBe('PERMISSION_DENIED');
|
|
// The refusal happens before the handler, so the domain never runs at all.
|
|
expect(seen).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('RPCHOST-002 an unknown method is UNKNOWN_METHOD, not PERMISSION_DENIED', async () => {
|
|
const host = makeHost(['db:read:tags']);
|
|
const res = await host.dispatch(req('fs.readFile', { path: '/etc/passwd' }));
|
|
expect(code(res)).toBe('UNKNOWN_METHOD');
|
|
expect((res as RpcError).error.message).toBe('unknown method fs.readFile');
|
|
});
|
|
|
|
it('RPCHOST-003 a denied known method names the plugin, so the log says who asked', async () => {
|
|
const res = await makeHost([]).dispatch(req('tags.list'), 42);
|
|
expect((res as RpcError).error.message).toBe('tags.list requires a permission "p" was not granted');
|
|
});
|
|
|
|
it('RPCHOST-004 with no permissions at all, every permissioned method is denied', async () => {
|
|
const host = makeHost([]);
|
|
for (const method of ['tags.list', 'tags.create']) {
|
|
expect(code(await host.dispatch(req(method), 42))).toBe('PERMISSION_DENIED');
|
|
}
|
|
});
|
|
|
|
it('RPCHOST-005 an unconditional method needs no grant', async () => {
|
|
expect(ok(await makeHost([]).dispatch(req('events.emit'), 42))).toBe(true);
|
|
});
|
|
|
|
it('RPCHOST-006 the host-bound acting user reaches the handler, never a params field', async () => {
|
|
let seen: PluginRpcContext | undefined;
|
|
const host = makeHost(['db:read:tags'], (_p, ctx) => {
|
|
seen = ctx;
|
|
return null;
|
|
});
|
|
await host.dispatch(req('tags.list', { asUserId: 999 }), 42);
|
|
expect(seen?.actingUserId).toBe(42);
|
|
expect(seen?.pluginId).toBe('p');
|
|
await host.dispatch(req('tags.list'), undefined);
|
|
expect(seen?.actingUserId).toBeUndefined();
|
|
});
|
|
|
|
it('RPCHOST-007 the context exposes the plugin db handle and the inter-plugin peers', async () => {
|
|
const deps = makeDeps();
|
|
let seen: PluginRpcContext | undefined;
|
|
const host = makeHost(['db:read:tags'], (_p, ctx) => {
|
|
seen = ctx;
|
|
return null;
|
|
}, deps);
|
|
await host.dispatch(req('tags.list'), 42);
|
|
expect(seen?.data).toBe(deps.data);
|
|
await seen?.plugins.call('other', 'sum', [1], 42);
|
|
expect(deps.callPlugin).toHaveBeenCalledWith('other', 'sum', [1], 42);
|
|
seen?.plugins.emit('ping', { a: 1 });
|
|
expect(deps.emitPluginEvent).toHaveBeenCalledWith('ping', { a: 1 });
|
|
});
|
|
|
|
it('RPCHOST-008 BadParams and ForbiddenResource map to their own wire codes', async () => {
|
|
expect(code(await makeHost(['db:read:tags'], () => {
|
|
throw new BadParams('sql must be a string');
|
|
}).dispatch(req('tags.list'), 42))).toBe('BAD_PARAMS');
|
|
expect(code(await makeHost(['db:read:tags'], () => {
|
|
throw new ForbiddenResource('no access to trip 9');
|
|
}).dispatch(req('tags.list'), 42))).toBe('RESOURCE_FORBIDDEN');
|
|
});
|
|
|
|
it('RPCHOST-009 any other error becomes HOST_ERROR with its message', async () => {
|
|
const res = await makeHost(['db:read:tags'], () => {
|
|
throw new Error('disk gone');
|
|
}).dispatch(req('tags.list'), 42);
|
|
expect(code(res)).toBe('HOST_ERROR');
|
|
expect((res as RpcError).error.message).toBe('disk gone');
|
|
});
|
|
|
|
it('RPCHOST-010 a non-Error throw still maps to HOST_ERROR, with a generic message', async () => {
|
|
const res = await makeHost(['db:read:tags'], () => {
|
|
throw 'raw string';
|
|
}).dispatch(req('tags.list'), 42);
|
|
expect(code(res)).toBe('HOST_ERROR');
|
|
expect((res as RpcError).error.message).toBe('internal error');
|
|
});
|
|
|
|
it('RPCHOST-011 an async handler is awaited before the response is built', async () => {
|
|
const host = makeHost(['db:read:tags'], async () => {
|
|
await Promise.resolve();
|
|
return { late: true };
|
|
});
|
|
expect(((await host.dispatch(req('tags.list'), 42)) as RpcResponse).result).toEqual({ late: true });
|
|
});
|
|
|
|
it('RPCHOST-012 a rejected promise is mapped like a throw', async () => {
|
|
const host = makeHost(['db:read:tags'], async () => {
|
|
throw new BadParams('async refusal');
|
|
});
|
|
expect(code(await host.dispatch(req('tags.list'), 42))).toBe('BAD_PARAMS');
|
|
});
|
|
|
|
it('RPCHOST-013 missing params are treated as an empty object, not a crash', async () => {
|
|
let seen: Record<string, unknown> | undefined;
|
|
const host = makeHost(['db:read:tags'], (params) => {
|
|
seen = params;
|
|
return null;
|
|
});
|
|
expect(ok(await host.dispatch({ k: 'req', id: 'x', method: 'tags.list' }, 42))).toBe(true);
|
|
expect(seen).toEqual({});
|
|
});
|
|
|
|
it('RPCHOST-014 the audit sink sees the outcome of an auditable call, denials included', async () => {
|
|
const audit = vi.fn();
|
|
const deps = { ...makeDeps(), audit };
|
|
expect(ok(await makeHost(['db:read:tags'], () => null, deps).dispatch(req('tags.list'), 42))).toBe(true);
|
|
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ pluginId: 'p', actingUserId: 42, method: 'tags.list', code: 'ok' }));
|
|
audit.mockClear();
|
|
await makeHost([], () => null, deps).dispatch(req('tags.list'), 42);
|
|
expect(audit).toHaveBeenCalledWith(expect.objectContaining({ method: 'tags.list', code: 'PERMISSION_DENIED' }));
|
|
});
|
|
|
|
it('RPCHOST-015 a throwing audit sink never breaks the call it was auditing', async () => {
|
|
const audit = vi.fn(() => {
|
|
throw new Error('audit table is gone');
|
|
});
|
|
const host = makeHost(['db:read:tags'], () => ({ done: true }), { ...makeDeps(), audit });
|
|
expect(ok(await host.dispatch(req('tags.list'), 42))).toBe(true);
|
|
});
|
|
|
|
it('RPCHOST-016 dispose() closes the plugin data db, and a second close is harmless', () => {
|
|
const deps = makeDeps();
|
|
const host = makeHost(['db:read:tags'], () => null, deps);
|
|
host.dispose();
|
|
expect(deps.data.close).toHaveBeenCalled();
|
|
(deps.data.close as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
|
throw new Error('already closed');
|
|
});
|
|
expect(() => host.dispose()).not.toThrow();
|
|
});
|
|
|
|
it('RPCHOST-017 a method the registry does not own is rejected, not silently answered', async () => {
|
|
// The registry is the ONLY source of handlers now, so a class that was never
|
|
// listed in its module makes every one of its methods PERMISSION_DENIED. This is
|
|
// the failure mode that has no other symptom, which is why each domain suite also
|
|
// asserts its class is in the module providers.
|
|
const bare = new PluginRpcHost('p', new Set(['db:read:tags']), makeDeps(), createTestPluginRegistry([]));
|
|
expect(code(await bare.dispatch(req('tags.list'), 42))).toBe('PERMISSION_DENIED');
|
|
});
|
|
});
|