Make the plugin permission facts live in one place

The hook->permission map existed verbatim twice (the host supervisor and the
plugin SDK) and the permission list four times (envelope, SDK manifest, and
both admin panels in the client). All of them were in sync when measured -
63 entries each, 15 hook entries each - so this changes no behaviour. It
removes a quiet outage: a hook whose permission is added on only one side
fails silently in production, with the plugin installing, activating and then
simply never being called.

protocol/envelope.ts is the single source now. METHOD_PERMISSION was typed
Record<KnownMethod, string>, so its value side was unconstrained -
'db:read:trps' compiled and made the method unreachable forever. It is
`as const satisfies Record<KnownMethod, KnownPermission>` now, which catches a
typo'd value, a missing key and an extra key. The derived KnownPermission and
MethodPermission<M> are what the @PluginMethod decorator will check its
argument against.

Deliberately no explicit annotation on METHOD_PERMISSION: with one, typeof is
that Record, MethodPermission<M> widens to the whole union, and the check
silently evaporates.

server/scripts/gen-plugin-facts.ts imports envelope.ts and writes the SDK and
shared copies. It does not scrape. The guard it replaces pulled the block out
of plugin-supervisor.ts with a regex that goes null on a reformat, and it ran
only from prepublishOnly - drift would have surfaced as a broken npm release.

The generated files are checked in because publish-plugin-sdk builds from a
standalone checkout with no server present.

The SDK's re-exported types stay wide. plugin-sdk compiles under strict, and
grantGaps indexes HOOK_PERMISSION with a plain string, so an `as const` there
is TS7053 and would break the publish. Strictness belongs on the host side.

--check also fails on a permission with no admin.plugins.perm.* translation
and on one the CLI has no hint for. Both are silent today.

plugin-sdk/** was in no workflow path filter, so nothing in CI ever ran its
tests. It is in the filter now, alongside a job that runs the drift check.
This commit is contained in:
Maurice
2026-08-09 00:43:50 +02:00
committed by Maurice
parent f0f69df67e
commit 7d0e90eb47
16 changed files with 713 additions and 130 deletions
+17 -14
View File
@@ -1,5 +1,7 @@
/**
* The SDK hand-mirrors two things the server owns: the hook→permission map, and the
* The hook->permission map is no longer hand-mirrored: gen-plugin-facts.ts generates
* src/generated/host-facts.ts from the server's protocol/envelope.ts, and that
* generator's --check mode is a CI gate. What is still hand-vendored here is the
* pure egress-policy helpers. trek-plugin-sdk ships standalone and cannot import across
* the package boundary, so the copies are real copies — and a silent drift here is the
* worst possible bug in this module: `dev` would confidently green-light a plugin that
@@ -16,23 +18,24 @@ import { HOOK_PERMISSION } from '../src/permissions.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const serverPlugins = path.resolve(here, '../../server/src/nest/plugins');
const supervisor = path.join(serverPlugins, 'supervisor/plugin-supervisor.ts');
const envelopeFile = path.join(serverPlugins, 'protocol/envelope.ts');
const serverEgress = path.join(serverPlugins, 'runtime/egress-policy.ts');
const inMonorepo = fs.existsSync(supervisor) && fs.existsSync(serverEgress);
const inMonorepo = fs.existsSync(envelopeFile) && fs.existsSync(serverEgress);
describe.skipIf(!inMonorepo)('parity with the host', () => {
it('HOOK_PERMISSION matches the supervisor\'s map exactly', () => {
const src = fs.readFileSync(supervisor, 'utf8');
const block = src.match(/const HOOK_PERMISSION[^{]*\{([\s\S]*?)\n\};/);
expect(block, 'could not find HOOK_PERMISSION in plugin-supervisor.ts').toBeTruthy();
it('the generated host facts match the server's envelope.ts', () => {
// Not a regex scrape any more: server/scripts/gen-plugin-facts.ts imports envelope.ts
// and writes src/generated/host-facts.ts, and its --check mode is a CI gate. This
// asserts the checked-in artefact is the one the host would produce, so a standalone
// publish (which has no server present) still ships the right list.
const generated = fs.readFileSync(path.resolve(here, '../src/generated/host-facts.ts'), 'utf8');
const envelope = fs.readFileSync(envelopeFile, 'utf8');
const host: Record<string, string> = {};
for (const [, key, perm] of block![1].matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) host[key] = perm;
// Both directions: a hook the host gates that we don't know about means dev fires it
// when TREK never would; one we gate that the host doesn't means dev refuses for nothing.
expect(Object.keys(host).length).toBeGreaterThan(0);
expect(HOOK_PERMISSION).toEqual(host);
for (const [hook, perm] of Object.entries(HOOK_PERMISSION)) {
expect(generated, `${hook} missing from the generated facts`).toContain(`${hook}: '${perm}'`);
expect(envelope, `${hook} missing from envelope.ts`).toContain(`${hook}: '${perm}'`);
}
expect(Object.keys(HOOK_PERMISSION).length).toBeGreaterThan(0);
});
it('the vendored egress-policy helpers are byte-identical to the server\'s', () => {