diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f307934c5..b86a96344 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,9 +11,33 @@ on: - 'client/**' - 'shared/**' - 'nest-mcp/**' + - 'plugin-sdk/**' - '.github/workflows/test.yml' 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: name: i18n Key Parity runs-on: ubuntu-latest diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index cb641941b..7342da053 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type DragEvent } from 'react' +import { PLUGIN_PERMISSIONS } from '@trek/shared' import { createPortal } from 'react-dom' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, @@ -179,22 +180,9 @@ const HEALTH: Record = { } // Known permissions → human-readable i18n key; unknown ones render as raw code. -const PERM_KEYS = [ - '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', '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', -] +// Generated from the host's protocol/envelope.ts by +// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy. +const PERM_KEYS = PLUGIN_PERMISSIONS const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index fe3b28efa..f1cd670d9 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState, type DragEvent } from 'react' +import { PLUGIN_PERMISSIONS } from '@trek/shared' import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, @@ -179,22 +180,9 @@ const HEALTH: Record = { } // Known permissions → human-readable i18n key; unknown ones render as raw code. -const PERM_KEYS = [ - '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', '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', -] +// Generated from the host's protocol/envelope.ts by +// server/scripts/gen-plugin-facts.ts - this used to be a hand-kept fourth copy. +const PERM_KEYS = PLUGIN_PERMISSIONS const KNOWN_TYPES = ['widget', 'page', 'integration', 'trip-page'] diff --git a/plugin-sdk/src/generated/host-facts.ts b/plugin-sdk/src/generated/host-facts.ts new file mode 100644 index 000000000..cc8a46976 --- /dev/null +++ b/plugin-sdk/src/generated/host-facts.ts @@ -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> = { + 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> = { + '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:'; diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index a2b5e4778..c7099b3bb 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -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:'); diff --git a/plugin-sdk/src/permissions.ts b/plugin-sdk/src/permissions.ts index fe1984f51..093a029f0 100644 --- a/plugin-sdk/src/permissions.ts +++ b/plugin-sdk/src/permissions.ts @@ -14,36 +14,28 @@ export class PermissionDenied extends Error {} /** - * hooks. → 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. -> 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>), exactly as before: + * grantGaps below indexes HOOK_PERMISSION with a plain string, and this package compiles + * under "strict": true. */ -export const HOOK_PERMISSION: Readonly> = { - 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 { diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts index 46e7b5293..fc7713d32 100644 --- a/plugin-sdk/test/permissions-parity.test.ts +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -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 = {}; - 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', () => { diff --git a/server/package.json b/server/package.json index 32fe1c8bc..5409f6864 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,8 @@ "start:prod": "node --require tsconfig-paths/register dist/index.js", "reset-admin": "node reset-admin.js", "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:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\"", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", diff --git a/server/scripts/gen-plugin-facts.ts b/server/scripts/gen-plugin-facts.ts new file mode 100644 index 000000000..0cdc0da22 --- /dev/null +++ b/server/scripts/gen-plugin-facts.ts @@ -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>) => + Object.entries(o).map(([k, v]) => ` ${/^[A-Za-z_$][\w$]*$/.test(k) ? k : `'${k}'`}: '${v}',`).join('\n'); + +/** + * Types here are deliberately WIDE — Readonly> 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> = { +${pairs(HOOK_PERMISSION)} +}; + +export const KNOWN_PERMISSIONS: string[] = [ +${list(KNOWN_PERMISSIONS)} +]; + +export const METHOD_PERMISSION: Readonly> = { +${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> = { +${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.' 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'); diff --git a/server/src/nest/plugins/host/plugin-jobs.ts b/server/src/nest/plugins/host/plugin-jobs.ts index 907421845..942408fe8 100644 --- a/server/src/nest/plugins/host/plugin-jobs.ts +++ b/server/src/nest/plugins/host/plugin-jobs.ts @@ -1,4 +1,5 @@ import cron, { type ScheduledTask } from 'node-cron'; +import { JOBS_PERMISSION } from '../protocol/envelope'; /** * 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. */ -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). @@ -34,7 +35,7 @@ export function scheduleJobs( jobs: readonly ScheduledJob[], run: (jobId: string) => void, ): ScheduledTask[] { - if (!granted.has(JOBS_RUN_PERMISSION)) return []; + if (!granted.has(JOBS_PERMISSION)) return []; const tasks: ScheduledTask[] = []; for (const job of jobs) { if (!job.schedule || !cron.validate(job.schedule)) continue; // invalid cron -> never runs diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 2851ce6e9..f299a8755 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -33,7 +33,7 @@ import type { PluginDependency } from './install/manifest'; import type { VersionMismatch, PluginDepRow } 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 // with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts index 68dc7469d..6c594564c 100644 --- a/server/src/nest/plugins/protocol/envelope.ts +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -161,7 +161,7 @@ export const KNOWN_METHODS = [ export type KnownMethod = (typeof KNOWN_METHODS)[number]; /** Which permission unlocks which method(s). The single source for the router. */ -export const METHOD_PERMISSION: Record = { +export const METHOD_PERMISSION = { 'db.exec': 'db:own', 'db.query': 'db:own', 'db.migrate': 'db:own', @@ -274,7 +274,11 @@ export const METHOD_PERMISSION: Record = { // it rides on the existing jobs:run grant (no new permission, no re-consent). 'scheduler.set': 'jobs:run', 'scheduler.cancel': 'jobs:run', -}; +} as const satisfies Record; + +/** The permission METHOD_PERMISSION assigns to `M` — the data-source binding a + * @PluginMethod decorator argument is checked against. */ +export type MethodPermission = (typeof METHOD_PERMISSION)[M]; /** All permission strings the host understands (unknown ones are rejected at activation). */ export const KNOWN_PERMISSIONS = [ @@ -353,6 +357,63 @@ export const KNOWN_PERMISSIONS = [ 'geolocation:read', ] 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:`: that family is open-ended by design and is + * only ever checked through isKnownPermission below. + */ +export type KnownPermission = (typeof KNOWN_PERMISSIONS)[number]; + +/** + * hooks. -> 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; + +export type HookKey = keyof typeof HOOK_PERMISSION; +/** The hook:* permission HOOK_PERMISSION assigns to `H`. */ +export type HookPermission = (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 { return (KNOWN_PERMISSIONS as readonly string[]).includes(p) || p.startsWith('http:outbound:'); } diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts index 018ae510e..27c142497 100644 --- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import { fork, type ChildProcess } from 'node:child_process'; import { readEnv } from '../../../app-config'; 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 { scheduleJobs, stopJobs, type ScheduledJob } from '../host/plugin-jobs'; import { SNAPSHOT_GRANT, type PluginEventMeta } from '../../../plugin-event-sink'; @@ -93,27 +93,6 @@ const DEFAULTS: Required = { 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> = { - 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 { private running = new Map(); @@ -261,7 +240,9 @@ export class PluginSupervisor { * An unknown hook, or one with no permission mapping, resolves to nobody. */ 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>)[hook]; if (!perm) return []; const out: string[] = []; 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 - * 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). * 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 @@ -298,7 +279,7 @@ export class PluginSupervisor { */ deliverEvent(tripId: number, event: string, meta?: PluginEventMeta): void { 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.status === 'active') { this.sendEvent(sup, tripId, event, meta); @@ -336,7 +317,7 @@ export class PluginSupervisor { const q = this.pendingEvents.get(sup.id); if (!q) return; this.pendingEvents.delete(sup.id); - if (!sup.granted.has('events:subscribe')) return; + if (!sup.granted.has(EVENTS_PERMISSION)) return; const now = Date.now(); for (const item of q) { 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> { 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 { const res = (await this.invoke(id, 'invoke.exportUserData', { userId }, { actingUserId: undefined, timeoutMs: 30_000 })) as { data?: unknown } | undefined; return { ok: true, data: res?.data }; diff --git a/server/tests/unit/plugins/protocol-paths.test.ts b/server/tests/unit/plugins/protocol-paths.test.ts index e2e1d5f50..c7d98b0a2 100644 --- a/server/tests/unit/plugins/protocol-paths.test.ts +++ b/server/tests/unit/plugins/protocol-paths.test.ts @@ -3,7 +3,7 @@ * the code/data path resolution (both the env-override and default branches). */ 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 { 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); }); - it('every known method maps to a permission', () => { + // The compile side of this is `as const satisfies Record` + // 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) { - 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); } }); }); diff --git a/shared/src/index.ts b/shared/src/index.ts index 686da6a1a..589ae0a45 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -57,3 +57,7 @@ export * from './sanitize/sanitize'; // i18n registry (language list + pure helpers — no locale data) 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'; diff --git a/shared/src/plugin-permissions.ts b/shared/src/plugin-permissions.ts new file mode 100644 index 000000000..3004701a9 --- /dev/null +++ b/shared/src/plugin-permissions.ts @@ -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> = { + 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', +};