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
+24
View File
@@ -11,9 +11,33 @@ on:
- 'client/**' - 'client/**'
- 'shared/**' - 'shared/**'
- 'nest-mcp/**' - 'nest-mcp/**'
- 'plugin-sdk/**'
- '.github/workflows/test.yml' - '.github/workflows/test.yml'
jobs: jobs:
plugin-facts:
name: Plugin Permission Facts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci --workspace server
# The plugin permission facts live once, in
# server/src/nest/plugins/protocol/envelope.ts. Every other copy - the SDK's
# generated/host-facts.ts and shared's plugin-permissions.ts - is generated from it
# and checked in, because publish-plugin-sdk builds from a standalone checkout with
# no server present. This fails the PR if a copy drifts, if a new permission has no
# admin.plugins.perm.* translation, or if the CLI has no hint for it.
- name: Check the generated plugin facts are in sync
run: cd server && npm run check:plugin-facts
i18n-parity: i18n-parity:
name: i18n Key Parity name: i18n Key Parity
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react' import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react'
import { PLUGIN_PERMISSIONS } from '@trek/shared'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
@@ -179,22 +180,9 @@ const HEALTH: Record<string, string> = {
} }
// Known permissions → human-readable i18n key; unknown ones render as raw code. // Known permissions → human-readable i18n key; unknown ones render as raw code.
const PERM_KEYS = [ // Generated from the host's protocol/envelope.ts by
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content', // server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy.
'db:read:collab', 'db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:daynotes', 'db:read:collections', const PERM_KEYS = PLUGIN_PERMISSIONS
'db:read:categories', 'db:read:tags', 'db:read:todos', 'weather:read', 'rates:read', 'db:write:costs',
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:write:reservations', 'db:write:accommodations', 'db:write:daynotes', 'db:write:packing',
'db:write:tags', 'db:write:todos', 'db:write:atlas', 'db:write:vacay', 'db:write:journal', 'db:write:collections',
'db:write:files', 'db:write:collab', 'db:write:members',
'db:create:trips',
'db:meta',
'notify:send', 'ai:invoke', 'oauth:client',
'events:subscribe', 'jobs:run',
'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', 'geolocation:read',
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
]
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react'
import { PLUGIN_PERMISSIONS } from '@trek/shared'
import { import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
@@ -179,22 +180,9 @@ const HEALTH: Record<string, string> = {
} }
// Known permissions → human-readable i18n key; unknown ones render as raw code. // Known permissions → human-readable i18n key; unknown ones render as raw code.
const PERM_KEYS = [ // Generated from the host's protocol/envelope.ts by
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:read:packing', 'db:read:files', 'db:read:files:content', // server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy.
'db:read:collab', 'db:read:journal', 'db:read:atlas', 'db:read:vacay', 'db:read:daynotes', 'db:read:collections', const PERM_KEYS = PLUGIN_PERMISSIONS
'db:read:categories', 'db:read:tags', 'db:read:todos', 'weather:read', 'rates:read', 'db:write:costs',
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips', 'db:write:reservations', 'db:write:accommodations', 'db:write:daynotes', 'db:write:packing',
'db:write:tags', 'db:write:todos', 'db:write:atlas', 'db:write:vacay', 'db:write:journal', 'db:write:collections',
'db:write:files', 'db:write:collab', 'db:write:members',
'db:create:trips',
'db:meta',
'notify:send', 'ai:invoke', 'oauth:client',
'events:subscribe', 'jobs:run',
'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', 'geolocation:read',
'hook:pdf-section-provider', 'hook:atlas-layer-provider', 'hook:journal-entry-provider', 'hook:trip-card-provider', 'hook:notification-channel', 'hook:user-data', 'http:outbound',
]
const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page']
+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 // 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 // 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. // never again go missing from the scaffolder — test/cli.test.ts fails until it has an entry.
export const KNOWN_PERMISSIONS = [ export { KNOWN_PERMISSIONS } from './generated/host-facts.js';
'db:own', import { KNOWN_PERMISSIONS } from './generated/host-facts.js';
'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',
];
function isKnownPermission(p: string): boolean { function isKnownPermission(p: string): boolean {
return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:'); return KNOWN_PERMISSIONS.includes(p) || p.startsWith('http:outbound:');
+21 -29
View File
@@ -14,36 +14,28 @@
export class PermissionDenied extends Error {} export class PermissionDenied extends Error {}
/** /**
* hooks.<key> → the permission that must ALSO be granted for TREK to ever call it. * hooks.<key> -> the permission that must ALSO be granted for TREK to ever call it, plus
* Mirrors HOOK_PERMISSION in the host's supervisor (plugin-supervisor.ts): a plugin * the three capability gates that are not hooks.* keys.
* provides a hook only if it BOTH implements it and holds this grant. *
* 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>> = { export {
photoProvider: 'hook:photo-provider', HOOK_PERMISSION,
calendarSource: 'hook:calendar-source', USER_DATA_PERMISSION,
placeDetailProvider: 'hook:place-detail-provider', EVENTS_PERMISSION,
warningProvider: 'hook:trip-warning-provider', JOBS_PERMISSION,
tableContributor: 'hook:table-contributor', } from './generated/host-facts.js';
mapMarkerProvider: 'hook:map-marker-provider', import {
mapLayerProvider: 'hook:map-layer-provider', HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
routeProvider: 'hook:route-provider', HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND,
dayScheduleProvider: 'hook:day-schedule-provider', } from './generated/host-facts.js';
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:';
/** The structural shape of a loaded plugin that grantGaps needs. */ /** The structural shape of a loaded plugin that grantGaps needs. */
export interface PluginEntryPoints { 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 * 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 * 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 * 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 here = path.dirname(fileURLToPath(import.meta.url));
const serverPlugins = path.resolve(here, '../../server/src/nest/plugins'); 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 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', () => { describe.skipIf(!inMonorepo)('parity with the host', () => {
it('HOOK_PERMISSION matches the supervisor\'s map exactly', () => { it('the generated host facts match the server's envelope.ts', () => {
const src = fs.readFileSync(supervisor, 'utf8'); // Not a regex scrape any more: server/scripts/gen-plugin-facts.ts imports envelope.ts
const block = src.match(/const HOOK_PERMISSION[^{]*\{([\s\S]*?)\n\};/); // and writes src/generated/host-facts.ts, and its --check mode is a CI gate. This
expect(block, 'could not find HOOK_PERMISSION in plugin-supervisor.ts').toBeTruthy(); // 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 [hook, perm] of Object.entries(HOOK_PERMISSION)) {
for (const [, key, perm] of block![1].matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) host[key] = perm; expect(generated, `${hook} missing from the generated facts`).toContain(`${hook}: '${perm}'`);
expect(envelope, `${hook} missing from envelope.ts`).toContain(`${hook}: '${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(HOOK_PERMISSION).length).toBeGreaterThan(0);
expect(Object.keys(host).length).toBeGreaterThan(0);
expect(HOOK_PERMISSION).toEqual(host);
}); });
it('the vendored egress-policy helpers are byte-identical to the server\'s', () => { it('the vendored egress-policy helpers are byte-identical to the server\'s', () => {
+2
View File
@@ -9,6 +9,8 @@
"start:prod": "node --require tsconfig-paths/register dist/index.js", "start:prod": "node --require tsconfig-paths/register dist/index.js",
"reset-admin": "node reset-admin.js", "reset-admin": "node reset-admin.js",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts",
"check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
+141
View File
@@ -0,0 +1,141 @@
/**
* Regenerates every derived copy of the plugin permission facts from the single source,
* server/src/nest/plugins/protocol/envelope.ts.
*
* node --import tsx server/scripts/gen-plugin-facts.ts # write
* node --import tsx server/scripts/gen-plugin-facts.ts --check # exit 1 on drift
*
* It *imports* envelope.ts rather than scraping it. The guard it replaces
* (plugin-sdk/test/permissions-parity.test.ts) pulled the block out with a regex, which
* goes quietly null the moment someone reformats a closing brace — and that test only ran
* from prepublishOnly, so the failure would have surfaced as a broken npm release.
*
* The generated files are CHECKED IN: publish-plugin-sdk runs inside a standalone
* plugin-sdk/ checkout with no server present, so a build-time artefact would ship an SDK
* with an empty permission list.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
HOOK_PERMISSION, KNOWN_METHODS, KNOWN_PERMISSIONS, METHOD_PERMISSION,
EVENTS_PERMISSION, JOBS_PERMISSION, USER_DATA_PERMISSION, HTTP_OUTBOUND_PREFIX,
} from '../src/nest/plugins/protocol/envelope';
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const HEADER = [
'// GENERATED — do not edit by hand.',
'// Source: server/src/nest/plugins/protocol/envelope.ts',
'// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts',
'',
].join('\n');
const list = (xs: readonly string[]) => xs.map((x) => ` '${x}',`).join('\n');
const pairs = (o: Readonly<Record<string, string>>) =>
Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n');
/**
* Types here are deliberately WIDE — Readonly<Record<string, string>> and string[] —
* matching exactly what plugin-sdk published before this file existed. An `as const`
* literal would be TS7053 under plugin-sdk/tsconfig.json's "strict": true, where
* grantGaps indexes HOOK_PERMISSION[key] with a plain string, and it would be a
* source-breaking type change for any plugin author doing a dynamic lookup. Strictness
* belongs on the host side, where the data source lives.
*/
const SDK_FACTS = `${HEADER}
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
};
export const KNOWN_PERMISSIONS: string[] = [
${list(KNOWN_PERMISSIONS)}
];
export const METHOD_PERMISSION: Readonly<Record<string, string>> = {
${pairs(METHOD_PERMISSION)}
};
export const KNOWN_METHODS: string[] = [
${list(KNOWN_METHODS)}
];
export const USER_DATA_PERMISSION = '${USER_DATA_PERMISSION}';
export const EVENTS_PERMISSION = '${EVENTS_PERMISSION}';
export const JOBS_PERMISSION = '${JOBS_PERMISSION}';
export const HTTP_OUTBOUND_PREFIX = '${HTTP_OUTBOUND_PREFIX}';
`;
const SHARED_FACTS = `${HEADER}
/** Widened on purpose so client callers can .includes() with a plain string. */
export const PLUGIN_PERMISSIONS: readonly string[] = [
${list(KNOWN_PERMISSIONS)}
];
export const PLUGIN_HOOK_PERMISSION: Readonly<Record<string, string>> = {
${pairs(HOOK_PERMISSION)}
};
`;
const OUTPUTS: Array<[string, string]> = [
['plugin-sdk/src/generated/host-facts.ts', SDK_FACTS],
['shared/src/plugin-permissions.ts', SHARED_FACTS],
];
/**
* Coverage assertions the type system cannot make, because the targets are not TypeScript
* unions. Both failure modes are silent today: a new permission reaches the consent screen
* as a raw code with no translation, and `trek-plugin permissions` prints it with no hint.
*/
function coverageProblems(): string[] {
const problems: string[] = [];
// Match the whole quoted key, not a prefix of it. `includes('plugins.perm.db:own')`
// also matches 'plugins.perm.db:ownX', and several permissions are prefixes of others
// ('db:read:files' vs 'db:read:files:content'), so a substring test passes on exactly
// the drift it is supposed to catch.
const enAdmin = fs.readFileSync(path.join(REPO, 'shared/src/i18n/en/admin.ts'), 'utf8');
const missingI18n = KNOWN_PERMISSIONS.filter((p) => !enAdmin.includes(`'admin.plugins.perm.${p}':`));
if (missingI18n.length) {
problems.push(
`shared/src/i18n/en/admin.ts is missing 'admin.plugins.perm.<permission>' for: ${missingI18n.join(', ')}`,
);
}
const ui = fs.readFileSync(path.join(REPO, 'plugin-sdk/src/cli/ui.ts'), 'utf8');
const missingHint = KNOWN_PERMISSIONS.filter((p) => !ui.includes(`'${p}'`));
if (missingHint.length) {
problems.push(
`plugin-sdk/src/cli/ui.ts PERMISSION_FAMILIES does not cover: ${missingHint.join(', ')}`,
);
}
return problems;
}
const check = process.argv.includes('--check');
let failed = false;
for (const [rel, content] of OUTPUTS) {
const abs = path.join(REPO, rel);
const current = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
if (current === content) continue;
if (check) {
console.error(`DRIFT: ${rel} does not match envelope.ts.`);
console.error(' Run: node --import tsx server/scripts/gen-plugin-facts.ts');
failed = true;
} else {
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
console.log(`wrote ${rel}`);
}
}
for (const problem of coverageProblems()) {
console.error(`COVERAGE: ${problem}`);
failed = true;
}
if (failed) process.exit(1);
if (check) console.log('plugin facts are in sync with envelope.ts');
+3 -2
View File
@@ -1,4 +1,5 @@
import cron, { type ScheduledTask } from 'node-cron'; import cron, { type ScheduledTask } from 'node-cron';
import { JOBS_PERMISSION } from '../protocol/envelope';
/** /**
* Host-side scheduler for a plugin's declared background jobs (#plugins). * Host-side scheduler for a plugin's declared background jobs (#plugins).
@@ -23,7 +24,7 @@ export interface ScheduledJob {
} }
/** Grant that must be present for a plugin's jobs to be scheduled at all. */ /** Grant that must be present for a plugin's jobs to be scheduled at all. */
export const JOBS_RUN_PERMISSION = 'jobs:run'; export { JOBS_PERMISSION as JOBS_RUN_PERMISSION } from '../protocol/envelope';
/** /**
* Schedule every valid-cron job (returns the node-cron tasks to keep for teardown). * Schedule every valid-cron job (returns the node-cron tasks to keep for teardown).
@@ -34,7 +35,7 @@ export function scheduleJobs(
jobs: readonly ScheduledJob[], jobs: readonly ScheduledJob[],
run: (jobId: string) => void, run: (jobId: string) => void,
): ScheduledTask[] { ): ScheduledTask[] {
if (!granted.has(JOBS_RUN_PERMISSION)) return []; if (!granted.has(JOBS_PERMISSION)) return [];
const tasks: ScheduledTask[] = []; const tasks: ScheduledTask[] = [];
for (const job of jobs) { for (const job of jobs) {
if (!job.schedule || !cron.validate(job.schedule)) continue; // invalid cron -> never runs if (!job.schedule || !cron.validate(job.schedule)) continue; // invalid cron -> never runs
@@ -33,7 +33,7 @@ import type { PluginDependency } from './install/manifest';
import type { VersionMismatch, PluginDepRow } from './dependencies'; import type { VersionMismatch, PluginDepRow } from './dependencies';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies'; import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
const HTTP_OUTBOUND = 'http:outbound:'; import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND } from './protocol/envelope';
// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard // Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard
// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and // with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and
+63 -2
View File
@@ -161,7 +161,7 @@ export const KNOWN_METHODS = [
export type KnownMethod = (typeof KNOWN_METHODS)[number]; export type KnownMethod = (typeof KNOWN_METHODS)[number];
/** Which permission unlocks which method(s). The single source for the router. */ /** Which permission unlocks which method(s). The single source for the router. */
export const METHOD_PERMISSION: Record<KnownMethod, string> = { export const METHOD_PERMISSION = {
'db.exec': 'db:own', 'db.exec': 'db:own',
'db.query': 'db:own', 'db.query': 'db:own',
'db.migrate': 'db:own', 'db.migrate': 'db:own',
@@ -274,7 +274,11 @@ export const METHOD_PERMISSION: Record<KnownMethod, string> = {
// it rides on the existing jobs:run grant (no new permission, no re-consent). // it rides on the existing jobs:run grant (no new permission, no re-consent).
'scheduler.set': 'jobs:run', 'scheduler.set': 'jobs:run',
'scheduler.cancel': 'jobs:run', 'scheduler.cancel': 'jobs:run',
}; } as const satisfies Record<KnownMethod, KnownPermission>;
/** The permission METHOD_PERMISSION assigns to `M` — the data-source binding a
* @PluginMethod decorator argument is checked against. */
export type MethodPermission<M extends KnownMethod> = (typeof METHOD_PERMISSION)[M];
/** All permission strings the host understands (unknown ones are rejected at activation). */ /** All permission strings the host understands (unknown ones are rejected at activation). */
export const KNOWN_PERMISSIONS = [ export const KNOWN_PERMISSIONS = [
@@ -353,6 +357,63 @@ export const KNOWN_PERMISSIONS = [
'geolocation:read', 'geolocation:read',
] as const; ] as const;
/**
* The union every permission-shaped value must live in. This closes an asymmetry:
* KnownMethod was derived, this was not, so METHOD_PERMISSION's value side was a bare
* `string` — 'db:read:trps' compiled fine and made the method unreachable forever.
*
* NOT usable for `http:outbound:<host>`: that family is open-ended by design and is
* only ever checked through isKnownPermission below.
*/
export type KnownPermission = (typeof KNOWN_PERMISSIONS)[number];
/**
* hooks.<key> -> the permission that must ALSO be granted for the host to ever call it.
*
* A plugin may only act as a provider for a hook it BOTH implements (reported by the
* child at load) AND was granted the matching hook:* permission for. The child reports
* Object.keys(def.hooks) with no knowledge of grants, so the grant check must happen
* host-side.
*
* Moved here verbatim from supervisor/plugin-supervisor.ts, which had a byte-identical
* copy, as did plugin-sdk/src/permissions.ts. The three were in sync, but a hook whose
* permission is missing on one side fails SILENTLY in production - the plugin installs,
* activates and then simply never gets called - so the duplication was a quiet outage
* waiting to happen. `satisfies` is new enforcement on top: 'hook:typo-provider' used to
* compile and leave the hook dead forever.
*/
export const HOOK_PERMISSION = {
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',
} as const satisfies Record<string, KnownPermission>;
export type HookKey = keyof typeof HOOK_PERMISSION;
/** The hook:* permission HOOK_PERMISSION assigns to `H`. */
export type HookPermission<H extends HookKey> = (typeof HOOK_PERMISSION)[H];
/** Gates the GDPR handlers (deleteUserData / exportUserData). Not a hooks.* key. */
export const USER_DATA_PERMISSION = 'hook:user-data' satisfies KnownPermission;
/** Gates event subscriptions - without it the host delivers the plugin nothing. */
export const EVENTS_PERMISSION = 'events:subscribe' satisfies KnownPermission;
/** Gates jobs, and the ctx.scheduler timers that fire `scheduled`. */
export const JOBS_PERMISSION = 'jobs:run' satisfies KnownPermission;
/** Prefix of the host-scoped egress family. Deliberately NOT a KnownPermission -
* the host half is open-ended, so it is only ever checked by isKnownPermission. */
export const HTTP_OUTBOUND_PREFIX = 'http:outbound:';
export function isKnownPermission(p: string): boolean { export function isKnownPermission(p: string): boolean {
return (KNOWN_PERMISSIONS as readonly string[]).includes(p) || p.startsWith('http:outbound:'); return (KNOWN_PERMISSIONS as readonly string[]).includes(p) || p.startsWith('http:outbound:');
} }
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { fork, type ChildProcess } from 'node:child_process'; import { fork, type ChildProcess } from 'node:child_process';
import { readEnv } from '../../../app-config'; import { readEnv } from '../../../app-config';
import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths'; import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths';
import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope'; import { HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, type Envelope, type RpcError, type RpcRequest } from '../protocol/envelope';
import type { PluginRpcHost } from '../host/rpc-host'; import type { PluginRpcHost } from '../host/rpc-host';
import { scheduleJobs, stopJobs, type ScheduledJob } from '../host/plugin-jobs'; import { scheduleJobs, stopJobs, type ScheduledJob } from '../host/plugin-jobs';
import { SNAPSHOT_GRANT, type PluginEventMeta } from '../../../plugin-event-sink'; import { SNAPSHOT_GRANT, type PluginEventMeta } from '../../../plugin-event-sink';
@@ -93,27 +93,6 @@ const DEFAULTS: Required<SupervisorTuning> = {
maxRssBytes: readEnv().plugins.maxRssMb * 1024 * 1024, maxRssBytes: readEnv().plugins.maxRssMb * 1024 * 1024,
}; };
// A plugin may only act as a provider for a hook it BOTH implements (reported by
// the child at load) AND was granted the matching hook:* permission for. The child
// reports Object.keys(def.hooks) with no knowledge of grants, so the grant check
// must happen host-side here — otherwise the hook:* consent is never enforced.
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 class PluginSupervisor { export class PluginSupervisor {
private running = new Map<string, Supervised>(); private running = new Map<string, Supervised>();
@@ -261,7 +240,9 @@ export class PluginSupervisor {
* An unknown hook, or one with no permission mapping, resolves to nobody. * An unknown hook, or one with no permission mapping, resolves to nobody.
*/ */
providersOf(hook: string): string[] { providersOf(hook: string): string[] {
const perm = HOOK_PERMISSION[hook]; // Cast: `hook` is a plain string off the child's report, and HOOK_PERMISSION is
// now a literal object, so indexing it needs the same widening envelope.ts uses.
const perm = (HOOK_PERMISSION as Readonly<Record<string, string | undefined>>)[hook];
if (!perm) return []; if (!perm) return [];
const out: string[] = []; const out: string[] = [];
for (const [id, sup] of this.running) { for (const [id, sup] of this.running) {
@@ -287,7 +268,7 @@ export class PluginSupervisor {
/** /**
* Announce a core event to every plugin that subscribed to it (or to '*') AND holds * Announce a core event to every plugin that subscribed to it (or to '*') AND holds
* the 'events:subscribe' grant. Fire-and-forget: the invoke is NOT awaited (a core * the EVENTS_PERMISSION grant. Fire-and-forget: the invoke is NOT awaited (a core
* broadcast must never block on a plugin) and carries no user (trip reads refused). * broadcast must never block on a plugin) and carries no user (trip reads refused).
* The event name + tripId + a { entity, entityId } hint are sent, plus — ONLY for * The event name + tripId + a { entity, entityId } hint are sent, plus — ONLY for
* a plugin whose granted set includes the family's db:read:* permission — the * a plugin whose granted set includes the family's db:read:* permission — the
@@ -298,7 +279,7 @@ export class PluginSupervisor {
*/ */
deliverEvent(tripId: number, event: string, meta?: PluginEventMeta): void { deliverEvent(tripId: number, event: string, meta?: PluginEventMeta): void {
for (const [id, sup] of this.running) { for (const [id, sup] of this.running) {
if (!sup.granted.has('events:subscribe')) continue; if (!sup.granted.has(EVENTS_PERMISSION)) continue;
if (!sup.events.includes(event) && !sup.events.includes('*')) continue; if (!sup.events.includes(event) && !sup.events.includes('*')) continue;
if (sup.status === 'active') { if (sup.status === 'active') {
this.sendEvent(sup, tripId, event, meta); this.sendEvent(sup, tripId, event, meta);
@@ -336,7 +317,7 @@ export class PluginSupervisor {
const q = this.pendingEvents.get(sup.id); const q = this.pendingEvents.get(sup.id);
if (!q) return; if (!q) return;
this.pendingEvents.delete(sup.id); this.pendingEvents.delete(sup.id);
if (!sup.granted.has('events:subscribe')) return; if (!sup.granted.has(EVENTS_PERMISSION)) return;
const now = Date.now(); const now = Date.now();
for (const item of q) { for (const item of q) {
if (item.expiresAt <= now) continue; if (item.expiresAt <= now) continue;
@@ -388,7 +369,7 @@ export class PluginSupervisor {
*/ */
async collectUserExport(id: string, userId: number): Promise<{ ok: true; data: unknown } | { ok: false } | undefined> { async collectUserExport(id: string, userId: number): Promise<{ ok: true; data: unknown } | { ok: false } | undefined> {
const sup = this.running.get(id); const sup = this.running.get(id);
if (!sup || sup.status !== 'active' || !sup.granted.has('hook:user-data')) return undefined; // not applicable if (!sup || sup.status !== 'active' || !sup.granted.has(USER_DATA_PERMISSION)) return undefined; // not applicable
try { try {
const res = (await this.invoke(id, 'invoke.exportUserData', { userId }, { actingUserId: undefined, timeoutMs: 30_000 })) as { data?: unknown } | undefined; const res = (await this.invoke(id, 'invoke.exportUserData', { userId }, { actingUserId: undefined, timeoutMs: 30_000 })) as { data?: unknown } | undefined;
return { ok: true, data: res?.data }; return { ok: true, data: res?.data };
@@ -3,7 +3,7 @@
* the code/data path resolution (both the env-override and default branches). * the code/data path resolution (both the env-override and default branches).
*/ */
import { describe, it, expect, afterEach } from 'vitest'; import { describe, it, expect, afterEach } from 'vitest';
import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS } from '../../../src/nest/plugins/protocol/envelope'; import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS, HOOK_PERMISSION } from '../../../src/nest/plugins/protocol/envelope';
import path from 'node:path'; import path from 'node:path';
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry, serverCodeRoot, pluginPermissionArgs, pluginRealCodeDir, ensurePluginModuleType } from '../../../src/nest/plugins/paths'; import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry, serverCodeRoot, pluginPermissionArgs, pluginRealCodeDir, ensurePluginModuleType } from '../../../src/nest/plugins/paths';
@@ -21,9 +21,19 @@ describe('envelope helpers', () => {
expect(isKnownPermission('')).toBe(false); expect(isKnownPermission('')).toBe(false);
}); });
it('every known method maps to a permission', () => { // The compile side of this is `as const satisfies Record<KnownMethod, KnownPermission>`
// on METHOD_PERMISSION. These are the runtime mirror, and they also cover what the type
// system cannot: isKnownPermission's http:outbound: prefix branch.
it('every known method maps to a permission the host actually knows', () => {
for (const m of KNOWN_METHODS) { for (const m of KNOWN_METHODS) {
expect(METHOD_PERMISSION[m]).toBeTruthy(); const perm = METHOD_PERMISSION[m];
expect(isKnownPermission(perm), `${m} -> ${perm}`).toBe(true);
}
});
it('every hook permission is a known permission', () => {
for (const [hook, perm] of Object.entries(HOOK_PERMISSION)) {
expect(isKnownPermission(perm), `${hook} -> ${perm}`).toBe(true);
} }
}); });
}); });
+4
View File
@@ -57,3 +57,7 @@ export * from './sanitize/sanitize';
// i18n registry (language list + pure helpers — no locale data) // i18n registry (language list + pure helpers — no locale data)
export * from './i18n/languages'; export * from './i18n/languages';
// Plugin permission list, generated from the host's protocol/envelope.ts
// (server/scripts/gen-plugin-facts.ts). The admin consent screens render from it.
export * from './plugin-permissions';
+88
View File
@@ -0,0 +1,88 @@
// GENERATED — do not edit by hand.
// Source: server/src/nest/plugins/protocol/envelope.ts
// Regenerate: node --import tsx server/scripts/gen-plugin-facts.ts
/** Widened on purpose so client callers can .includes() with a plain string. */
export const PLUGIN_PERMISSIONS: readonly 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 PLUGIN_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',
};