mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
c89d2bf3a1
Lets a plugin colour-code days in the Plan sidebar. trip-segments can already announce "Kanazawa begins" in the day plan, but it can't make day 12 look like it belongs to Kanazawa. dayScheduleProvider can't cover this: it renders a fixed row, and it's capped at 60 items per provider, so one item per day would tint the first 60 days of a six-month trip and silently stop. This hook is bounded by day count instead. GET /api/day-tints/:tripId returns per-day tones for three regions of a day card - the number badge (also the mobile day chip), the header row, and the expanded activity list. `tone` is a shorthand filling every region a contribution doesn't name; unnamed regions render as they do today. Tones only, never hex, so the host can pick the alpha per theme and per region. One contribution per day, resolved whole: first entry wins within a provider, first granted provider wins across plugins.
107 lines
4.9 KiB
TypeScript
107 lines
4.9 KiB
TypeScript
/**
|
|
* The permission families TREK enforces OUTSIDE the ctx object — and the single
|
|
* PermissionDenied both the dev server and the mock host throw.
|
|
*
|
|
* ctx methods are gated at call time (an ungranted ctx.trips.getById throws). But a
|
|
* hook, an event subscription and a job are gated BEFORE the plugin is ever reached:
|
|
* the supervisor simply never selects an ungranted plugin as a provider, never
|
|
* delivers it an event, and never schedules its jobs. That failure is SILENT in
|
|
* production — the plugin installs, activates, and then does nothing at all — so dev
|
|
* and the mock driver must be the place it becomes loud.
|
|
*/
|
|
|
|
/** Thrown by the dev server and the mock host for any ungranted capability. */
|
|
export class PermissionDenied extends Error {}
|
|
|
|
/**
|
|
* hooks.<key> → the permission that must ALSO be granted for TREK to ever call it.
|
|
* 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.
|
|
*/
|
|
export const HOOK_PERMISSION: Readonly<Record<string, string>> = {
|
|
photoProvider: 'hook:photo-provider',
|
|
calendarSource: 'hook:calendar-source',
|
|
placeDetailProvider: 'hook:place-detail-provider',
|
|
warningProvider: 'hook:trip-warning-provider',
|
|
tableContributor: 'hook:table-contributor',
|
|
mapMarkerProvider: 'hook:map-marker-provider',
|
|
mapLayerProvider: 'hook:map-layer-provider',
|
|
routeProvider: 'hook:route-provider',
|
|
dayScheduleProvider: 'hook:day-schedule-provider',
|
|
dayTintProvider: 'hook:day-tint-provider',
|
|
pdfSectionProvider: 'hook:pdf-section-provider',
|
|
atlasLayerProvider: 'hook:atlas-layer-provider',
|
|
journalEntryProvider: 'hook:journal-entry-provider',
|
|
tripCardProvider: 'hook:trip-card-provider',
|
|
notificationChannel: 'hook:notification-channel',
|
|
};
|
|
|
|
/** Gates the GDPR handlers (deleteUserData / exportUserData). Not a hooks.* key. */
|
|
export const USER_DATA_PERMISSION = 'hook:user-data';
|
|
/** Gates event subscriptions — without it the host delivers the plugin nothing. */
|
|
export const EVENTS_PERMISSION = 'events:subscribe';
|
|
/** Gates jobs, and the ctx.scheduler timers that fire `scheduled`. */
|
|
export const JOBS_PERMISSION = 'jobs:run';
|
|
|
|
const HTTP_OUTBOUND = 'http:outbound:';
|
|
|
|
/** The structural shape of a loaded plugin that grantGaps needs. */
|
|
export interface PluginEntryPoints {
|
|
jobs?: unknown[];
|
|
scheduled?: unknown;
|
|
events?: unknown[];
|
|
deleteUserData?: unknown;
|
|
exportUserData?: unknown;
|
|
hooks?: Record<string, unknown>;
|
|
}
|
|
|
|
/** An entry point the plugin implements but has no permission to actually run. */
|
|
export interface GrantGap {
|
|
/** What the plugin implements, e.g. `hooks.warningProvider` or `jobs`. */
|
|
entryPoint: string;
|
|
/** The permission the manifest is missing. */
|
|
permission: string;
|
|
/** What TREK does instead — always a silent no-op, hence the warning. */
|
|
consequence: string;
|
|
}
|
|
|
|
/**
|
|
* Every entry point the plugin implements without the permission that makes TREK
|
|
* call it. Pure, so both the dev banner and the tests can use it.
|
|
*
|
|
* The consequence is worth spelling out to the author: production does NOT throw
|
|
* here, it just never invokes them — "my plugin does nothing and logs nothing" is
|
|
* exactly the bug this catches.
|
|
*/
|
|
export function grantGaps(plugin: PluginEntryPoints, grants: ReadonlySet<string>): GrantGap[] {
|
|
const gaps: GrantGap[] = [];
|
|
const gap = (entryPoint: string, permission: string, noun: string) => {
|
|
if (!grants.has(permission)) gaps.push({ entryPoint, permission, consequence: `TREK will never ${noun}` });
|
|
};
|
|
|
|
for (const key of Object.keys(plugin.hooks ?? {})) {
|
|
const perm = HOOK_PERMISSION[key];
|
|
// An unknown hooks.* key is the plugin's business (the host ignores it), not a gap.
|
|
if (perm && plugin.hooks?.[key]) gap(`hooks.${key}`, perm, `call this hook`);
|
|
}
|
|
if (plugin.jobs?.length) gap('jobs', JOBS_PERMISSION, 'schedule your jobs');
|
|
if (typeof plugin.scheduled === 'function') gap('scheduled', JOBS_PERMISSION, 'let you arm a timer (ctx.scheduler is denied)');
|
|
if (plugin.events?.length) gap('events', EVENTS_PERMISSION, 'deliver you any event');
|
|
if (typeof plugin.deleteUserData === 'function') gap('deleteUserData', USER_DATA_PERMISSION, 'call your GDPR erasure handler');
|
|
if (typeof plugin.exportUserData === 'function') gap('exportUserData', USER_DATA_PERMISSION, 'call your GDPR export handler');
|
|
return gaps;
|
|
}
|
|
|
|
/**
|
|
* The hosts a plugin may actually reach. TREK builds its runtime network allowlist
|
|
* from the `http:outbound:<host>` PERMISSIONS — the manifest's `egress[]` array is
|
|
* the consent-screen declaration and is never read at runtime. A bare `http:outbound`
|
|
* with no host suffix therefore reaches nothing.
|
|
*/
|
|
export function grantedHosts(grants: Iterable<string>): string[] {
|
|
return [...grants]
|
|
.filter((p) => p.startsWith(HTTP_OUTBOUND))
|
|
.map((p) => p.slice(HTTP_OUTBOUND.length))
|
|
.filter(Boolean); // a bare `http:outbound:` names no host
|
|
}
|