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
+318
View File
@@ -0,0 +1,318 @@
// GENERATED — do not edit by hand.
// Source: server/src/nest/plugins/protocol/envelope.ts
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
photoProvider: 'hook:photo-provider',
calendarSource: 'hook:calendar-source',
placeDetailProvider: 'hook:place-detail-provider',
warningProvider: 'hook:trip-warning-provider',
tableContributor: 'hook:table-contributor',
mapMarkerProvider: 'hook:map-marker-provider',
mapLayerProvider: 'hook:map-layer-provider',
routeProvider: 'hook:route-provider',
dayScheduleProvider: 'hook:day-schedule-provider',
dayTintProvider: 'hook:day-tint-provider',
pdfSectionProvider: 'hook:pdf-section-provider',
atlasLayerProvider: 'hook:atlas-layer-provider',
journalEntryProvider: 'hook:journal-entry-provider',
tripCardProvider: 'hook:trip-card-provider',
notificationChannel: 'hook:notification-channel',
};
export const KNOWN_PERMISSIONS: string[] = [
'db:own',
'db:read:trips',
'db:read:users',
'db:read:costs',
'db:read:packing',
'db:write:packing',
'db:read:files',
'db:read:files:content',
'db:write:files',
'db:read:collab',
'db:write:collab',
'db:write:members',
'db:create:trips',
'db:read:journal',
'db:read:atlas',
'rates:read',
'db:read:vacay',
'db:read:daynotes',
'db:read:collections',
'db:write:collections',
'db:write:atlas',
'db:write:vacay',
'db:write:journal',
'db:read:categories',
'db:read:tags',
'db:write:tags',
'db:read:todos',
'db:write:todos',
'weather:read',
'db:write:daynotes',
'db:write:costs',
'db:write:places',
'db:write:days',
'db:write:itinerary',
'db:write:trips',
'db:write:reservations',
'db:write:accommodations',
'db:meta',
'ws:broadcast:trip',
'ws:broadcast:user',
'hook:photo-provider',
'hook:calendar-source',
'hook:place-detail-provider',
'hook:trip-warning-provider',
'hook:table-contributor',
'hook:map-marker-provider',
'hook:map-layer-provider',
'hook:route-provider',
'hook:day-schedule-provider',
'hook:day-tint-provider',
'hook:pdf-section-provider',
'hook:atlas-layer-provider',
'hook:journal-entry-provider',
'hook:trip-card-provider',
'hook:notification-channel',
'hook:user-data',
'events:subscribe',
'jobs:run',
'http:outbound',
'notify:send',
'ai:invoke',
'oauth:client',
'geolocation:read',
];
export const METHOD_PERMISSION: Readonly<Record<string, string>> = {
'db.exec': 'db:own',
'db.query': 'db:own',
'db.migrate': 'db:own',
'db.tx': 'db:own',
'trips.getById': 'db:read:trips',
'trips.getPlaces': 'db:read:trips',
'trips.getReservations': 'db:read:trips',
'trips.getDays': 'db:read:trips',
'trips.getAccommodations': 'db:read:trips',
'trips.listMine': 'db:read:trips',
'reservations.listMine': 'db:read:trips',
'reservations.create': 'db:write:reservations',
'reservations.update': 'db:write:reservations',
'reservations.delete': 'db:write:reservations',
'accommodations.create': 'db:write:accommodations',
'accommodations.update': 'db:write:accommodations',
'accommodations.delete': 'db:write:accommodations',
'packing.list': 'db:read:packing',
'packing.create': 'db:write:packing',
'packing.update': 'db:write:packing',
'packing.delete': 'db:write:packing',
'packing.listBags': 'db:write:packing',
'packing.createBag': 'db:write:packing',
'packing.updateBag': 'db:write:packing',
'packing.deleteBag': 'db:write:packing',
'packing.setBagMembers': 'db:write:packing',
'files.list': 'db:read:files',
'files.getContent': 'db:read:files:content',
'files.create': 'db:write:files',
'files.createLink': 'db:write:files',
'files.update': 'db:write:files',
'files.softDelete': 'db:write:files',
'collab.listNotes': 'db:read:collab',
'collab.listPolls': 'db:read:collab',
'collab.listMessages': 'db:read:collab',
'collab.createNote': 'db:write:collab',
'collab.createPoll': 'db:write:collab',
'collab.votePoll': 'db:write:collab',
'collab.createMessage': 'db:write:collab',
'trips.addMember': 'db:write:members',
'trips.removeMember': 'db:write:members',
'trips.create': 'db:create:trips',
'journal.listMine': 'db:read:journal',
'journal.getEntries': 'db:read:journal',
'atlas.visited': 'db:read:atlas',
'atlas.bucketList': 'db:read:atlas',
'rates.get': 'rates:read',
'vacay.mine': 'db:read:vacay',
'daynotes.list': 'db:read:daynotes',
'daynotes.create': 'db:write:daynotes',
'daynotes.update': 'db:write:daynotes',
'daynotes.delete': 'db:write:daynotes',
'collections.listMine': 'db:read:collections',
'collections.get': 'db:read:collections',
'collections.create': 'db:write:collections',
'collections.update': 'db:write:collections',
'collections.savePlace': 'db:write:collections',
'collections.copyToTrip': 'db:write:collections',
'collections.deletePlace': 'db:write:collections',
'atlas.markCountry': 'db:write:atlas',
'atlas.unmarkCountry': 'db:write:atlas',
'atlas.markRegion': 'db:write:atlas',
'atlas.unmarkRegion': 'db:write:atlas',
'atlas.createBucketItem': 'db:write:atlas',
'atlas.deleteBucketItem': 'db:write:atlas',
'vacay.toggleEntry': 'db:write:vacay',
'vacay.toggleCompanyHoliday': 'db:write:vacay',
'journal.createEntry': 'db:write:journal',
'journal.updateEntry': 'db:write:journal',
'journal.deleteEntry': 'db:write:journal',
'journal.createJourney': 'db:write:journal',
'journal.deleteJourney': 'db:write:journal',
'weather.get': 'weather:read',
'categories.list': 'db:read:categories',
'tags.list': 'db:read:tags',
'tags.create': 'db:write:tags',
'tags.update': 'db:write:tags',
'tags.delete': 'db:write:tags',
'trips.members': 'db:read:trips',
'todos.list': 'db:read:todos',
'todos.create': 'db:write:todos',
'todos.update': 'db:write:todos',
'todos.delete': 'db:write:todos',
'costs.getByTrip': 'db:read:costs',
'costs.listMine': 'db:read:costs',
'costs.create': 'db:write:costs',
'costs.update': 'db:write:costs',
'costs.delete': 'db:write:costs',
'places.create': 'db:write:places',
'places.update': 'db:write:places',
'places.delete': 'db:write:places',
'days.create': 'db:write:days',
'days.update': 'db:write:days',
'days.delete': 'db:write:days',
'itinerary.assign': 'db:write:itinerary',
'itinerary.unassign': 'db:write:itinerary',
'trips.update': 'db:write:trips',
'meta.get': 'db:meta',
'meta.set': 'db:meta',
'meta.list': 'db:meta',
'meta.delete': 'db:meta',
'users.getById': 'db:read:users',
'ws.broadcastToTrip': 'ws:broadcast:trip',
'ws.broadcastToUser': 'ws:broadcast:user',
'notify.send': 'notify:send',
'ai.complete': 'ai:invoke',
'ai.extract': 'ai:invoke',
'oauth.getToken': 'oauth:client',
'scheduler.set': 'jobs:run',
'scheduler.cancel': 'jobs:run',
};
export const KNOWN_METHODS: string[] = [
'db.exec',
'db.query',
'db.migrate',
'db.tx',
'trips.getById',
'trips.getPlaces',
'trips.getReservations',
'trips.getDays',
'trips.getAccommodations',
'trips.listMine',
'reservations.listMine',
'reservations.create',
'reservations.update',
'reservations.delete',
'accommodations.create',
'accommodations.update',
'accommodations.delete',
'packing.list',
'packing.create',
'packing.update',
'packing.delete',
'packing.listBags',
'packing.createBag',
'packing.updateBag',
'packing.deleteBag',
'packing.setBagMembers',
'files.list',
'files.getContent',
'files.create',
'files.createLink',
'files.update',
'files.softDelete',
'collab.listNotes',
'collab.listPolls',
'collab.listMessages',
'collab.createNote',
'collab.createPoll',
'collab.votePoll',
'collab.createMessage',
'trips.addMember',
'trips.removeMember',
'trips.create',
'journal.listMine',
'journal.getEntries',
'atlas.visited',
'atlas.bucketList',
'rates.get',
'vacay.mine',
'daynotes.list',
'daynotes.create',
'daynotes.update',
'daynotes.delete',
'collections.listMine',
'collections.get',
'collections.create',
'collections.update',
'collections.savePlace',
'collections.copyToTrip',
'collections.deletePlace',
'atlas.markCountry',
'atlas.unmarkCountry',
'atlas.markRegion',
'atlas.unmarkRegion',
'atlas.createBucketItem',
'atlas.deleteBucketItem',
'vacay.toggleEntry',
'vacay.toggleCompanyHoliday',
'journal.createEntry',
'journal.updateEntry',
'journal.deleteEntry',
'journal.createJourney',
'journal.deleteJourney',
'weather.get',
'categories.list',
'tags.list',
'tags.create',
'tags.update',
'tags.delete',
'trips.members',
'todos.list',
'todos.create',
'todos.update',
'todos.delete',
'costs.getByTrip',
'costs.listMine',
'costs.create',
'costs.update',
'costs.delete',
'places.create',
'places.update',
'places.delete',
'days.create',
'days.update',
'days.delete',
'itinerary.assign',
'itinerary.unassign',
'trips.update',
'meta.get',
'meta.set',
'meta.list',
'meta.delete',
'users.getById',
'ws.broadcastToTrip',
'ws.broadcastToUser',
'notify.send',
'ai.complete',
'ai.extract',
'oauth.getToken',
'scheduler.set',
'scheduler.cancel',
];
export const USER_DATA_PERMISSION = 'hook:user-data';
export const EVENTS_PERMISSION = 'events:subscribe';
export const JOBS_PERMISSION = 'jobs:run';
export const HTTP_OUTBOUND_PREFIX = 'http:outbound:';
+2 -20
View File
@@ -75,26 +75,8 @@ export const CHANNEL_EVENTS = [
// This is THE list. `create`'s permission picker is built from it (see PERMISSION_FAMILIES
// in cli/ui.ts, which only supplies the grouping and hints), so a permission added here can
// never again go missing from the scaffolder — test/cli.test.ts fails until it has an entry.
export const KNOWN_PERMISSIONS = [
'db:own',
'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files',
'db:read:files:content', 'db:read:collab',
'db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:daynotes', 'db:read:collections',
'db:read:categories', 'db:read:tags', 'db:read:todos',
'db:write:costs', 'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips',
'db:write:reservations', 'db:write:accommodations', 'db:write:packing', 'db:write:files',
'db:write:collab', 'db:write:members', 'db:write:collections', 'db:write:atlas', 'db:write:vacay',
'db:write:journal', 'db:write:tags', 'db:write:todos', 'db:write:daynotes',
'db:create:trips',
'db:meta',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:place-detail-provider', 'hook:trip-warning-provider',
'hook:table-contributor', 'hook:map-marker-provider', 'hook:map-layer-provider', 'hook:route-provider', 'hook:day-schedule-provider', 'hook:day-tint-provider', 'hook:pdf-section-provider', 'hook:atlas-layer-provider',
'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data',
'events:subscribe', 'jobs:run', 'http:outbound',
'weather:read', 'rates:read', 'notify:send', 'ai:invoke', 'oauth:client',
'geolocation:read',
];
export { KNOWN_PERMISSIONS } from './generated/host-facts.js';
import { KNOWN_PERMISSIONS } from './generated/host-facts.js';
function isKnownPermission(p: string): boolean {
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
+21 -29
View File
@@ -14,36 +14,28 @@
export class PermissionDenied extends Error {}
/**
* hooks.<key> → the permission that must ALSO be granted for TREK to ever call it.
* Mirrors HOOK_PERMISSION in the host's supervisor (plugin-supervisor.ts): a plugin
* provides a hook only if it BOTH implements it and holds this grant.
* 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 const HOOK_PERMISSION: Readonly<Record<string, string>> = {
photoProvider: 'hook:photo-provider',
calendarSource: 'hook:calendar-source',
placeDetailProvider: 'hook:place-detail-provider',
warningProvider: 'hook:trip-warning-provider',
tableContributor: 'hook:table-contributor',
mapMarkerProvider: 'hook:map-marker-provider',
mapLayerProvider: 'hook:map-layer-provider',
routeProvider: 'hook:route-provider',
dayScheduleProvider: 'hook:day-schedule-provider',
dayTintProvider: 'hook:day-tint-provider',
pdfSectionProvider: 'hook:pdf-section-provider',
atlasLayerProvider: 'hook:atlas-layer-provider',
journalEntryProvider: 'hook:journal-entry-provider',
tripCardProvider: 'hook:trip-card-provider',
notificationChannel: 'hook:notification-channel',
};
/** Gates the GDPR handlers (deleteUserData / exportUserData). Not a hooks.* key. */
export const USER_DATA_PERMISSION = 'hook:user-data';
/** Gates event subscriptions — without it the host delivers the plugin nothing. */
export const EVENTS_PERMISSION = 'events:subscribe';
/** Gates jobs, and the ctx.scheduler timers that fire `scheduled`. */
export const JOBS_PERMISSION = 'jobs:run';
const HTTP_OUTBOUND = 'http:outbound:';
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 {
+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', () => {