Files
TREK/plugin-sdk/src/permissions.ts
T
Maurice 7d0e90eb47 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.
2026-08-09 00:52:05 +02:00

99 lines
4.4 KiB
TypeScript

/**
* The permission families TREK enforces OUTSIDE the ctx object — and the single
* PermissionDenied both the dev server and the mock host throw.
*
* ctx methods are gated at call time (an ungranted ctx.trips.getById throws). But a
* hook, an event subscription and a job are gated BEFORE the plugin is ever reached:
* the supervisor simply never selects an ungranted plugin as a provider, never
* delivers it an event, and never schedules its jobs. That failure is SILENT in
* production — the plugin installs, activates, and then does nothing at all — so dev
* and the mock driver must be the place it becomes loud.
*/
/** Thrown by the dev server and the mock host for any ungranted capability. */
export class PermissionDenied extends Error {}
/**
* hooks.<key> -> the permission that must ALSO be granted for TREK to ever call it, plus
* the three capability gates that are not hooks.* keys.
*
* These are GENERATED from the host's single source, server/src/nest/plugins/protocol/
* envelope.ts, by server/scripts/gen-plugin-facts.ts. They used to be a hand-kept copy
* guarded by a regex scrape of plugin-supervisor.ts in test/permissions-parity.test.ts -
* which only ran from prepublishOnly, so drift would have surfaced as a broken release.
*
* The re-exported types stay WIDE (Readonly<Record<string, string>>), exactly as before:
* grantGaps below indexes HOOK_PERMISSION with a plain string, and this package compiles
* under "strict": true.
*/
export {
HOOK_PERMISSION,
USER_DATA_PERMISSION,
EVENTS_PERMISSION,
JOBS_PERMISSION,
} from './generated/host-facts.js';
import {
HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND,
} from './generated/host-facts.js';
/** The structural shape of a loaded plugin that grantGaps needs. */
export interface PluginEntryPoints {
jobs?: unknown[];
scheduled?: unknown;
events?: unknown[];
deleteUserData?: unknown;
exportUserData?: unknown;
hooks?: Record<string, unknown>;
}
/** An entry point the plugin implements but has no permission to actually run. */
export interface GrantGap {
/** What the plugin implements, e.g. `hooks.warningProvider` or `jobs`. */
entryPoint: string;
/** The permission the manifest is missing. */
permission: string;
/** What TREK does instead — always a silent no-op, hence the warning. */
consequence: string;
}
/**
* Every entry point the plugin implements without the permission that makes TREK
* call it. Pure, so both the dev banner and the tests can use it.
*
* The consequence is worth spelling out to the author: production does NOT throw
* here, it just never invokes them — "my plugin does nothing and logs nothing" is
* exactly the bug this catches.
*/
export function grantGaps(plugin: PluginEntryPoints, grants: ReadonlySet<string>): GrantGap[] {
const gaps: GrantGap[] = [];
const gap = (entryPoint: string, permission: string, noun: string) => {
if (!grants.has(permission)) gaps.push({ entryPoint, permission, consequence: `TREK will never ${noun}` });
};
for (const key of Object.keys(plugin.hooks ?? {})) {
const perm = HOOK_PERMISSION[key];
// An unknown hooks.* key is the plugin's business (the host ignores it), not a gap.
if (perm && plugin.hooks?.[key]) gap(`hooks.${key}`, perm, `call this hook`);
}
if (plugin.jobs?.length) gap('jobs', JOBS_PERMISSION, 'schedule your jobs');
if (typeof plugin.scheduled === 'function') gap('scheduled', JOBS_PERMISSION, 'let you arm a timer (ctx.scheduler is denied)');
if (plugin.events?.length) gap('events', EVENTS_PERMISSION, 'deliver you any event');
if (typeof plugin.deleteUserData === 'function') gap('deleteUserData', USER_DATA_PERMISSION, 'call your GDPR erasure handler');
if (typeof plugin.exportUserData === 'function') gap('exportUserData', USER_DATA_PERMISSION, 'call your GDPR export handler');
return gaps;
}
/**
* The hosts a plugin may actually reach. TREK builds its runtime network allowlist
* from the `http:outbound:<host>` PERMISSIONS — the manifest's `egress[]` array is
* the consent-screen declaration and is never read at runtime. A bare `http:outbound`
* with no host suffix therefore reaches nothing.
*/
export function grantedHosts(grants: Iterable<string>): string[] {
return [...grants]
.filter((p) => p.startsWith(HTTP_OUTBOUND))
.map((p) => p.slice(HTTP_OUTBOUND.length))
.filter(Boolean); // a bare `http:outbound:` names no host
}