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.
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 toM, so@PluginMethod('tags.list', { permission: 'db:write:tags' })does not typecheck; - at boot, in
registry.validate(), because types evaporate under anascast or a hand-emitted JS consumer.
@PluginHook gets the same treatment against HOOK_PERMISSION.
The import rule
The kit may import:
@nestjs/commonand@nestjs/core,- type-only from
../../protocol/envelopeand../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:
PluginRpcContext.databecomes a generic parameter instead of importingPluginDataDb.- The four envelope type imports (
KnownMethod,MethodPermission,HookKey,HookPermission) become empty-interface augmentation points, withAssertExactguards in the server proving the augmentation still matches the tables. - Root
package.jsonworkspaces,server/package.jsondeps, the root lockfile, the Dockerfile COPY stages and a source alias inserver/vitest.config.tsall 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.
requireTotalCoverage is now on, so the app refuses to boot instead: validate()
names every KNOWN_METHOD left without a handler, and every hook left without a
host-side consumer. server/tests/unit/plugins/rpc-coverage.test.ts catches the same
gap in CI, before anyone starts the server.