fix(sdk): allow empty egress if and only if operatorEgress is true

This commit is contained in:
jubnl
2026-07-11 20:06:54 +02:00
parent a4972f8f13
commit ac201deb35
9 changed files with 127 additions and 26 deletions
+34 -16
View File
@@ -10,7 +10,7 @@ import os from 'node:os';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import {
intro, outro, note, logSuccess, logWarn, spinner,
intro, outro, note, logInfo, logSuccess, logWarn, spinner,
promptText, promptSelect, promptMultiselect, promptConfirm,
PERMISSION_CATALOG,
} from './ui.js';
@@ -30,8 +30,14 @@ export interface ScaffoldOptions {
author?: string;
description?: string;
permissions?: string[];
/** External hosts the plugin may call — required by the manifest when `http:outbound` is granted. */
/**
* External hosts the plugin may call — required by the manifest when `http:outbound` is
* granted, UNLESS the plugin is an operatorEgress one (below), whose hosts an admin adds
* after install because the author cannot know them (a self-hosted Gotify, an ntfy).
*/
egress?: string[];
/** The admin supplies the hosts post-install. Defaults on for a channel, and whenever `http:outbound` is wanted with no `egress`. */
operatorEgress?: boolean;
/** Addon ids that must be enabled for this plugin to activate. */
requiredAddons?: string[];
/** Other plugins this one depends on, each pinned by a semver range. */
@@ -62,14 +68,20 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
const displayName = name.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
let perms = opts.permissions?.length ? opts.permissions : ['db:own'];
let egress = opts.egress ?? [];
const egress = opts.egress ?? [];
if (isChannel) {
// The two grants a channel cannot work without, plus a placeholder host the
// author swaps for their own — the manifest rejects http:outbound with no egress.
if (!egress.length) egress = ['gotify.example.com'];
const required = ['hook:notification-channel', ...egress.map((h) => `http:outbound:${h}`)];
// The grants a channel cannot work without. With known hosts it gets a per-host grant
// each; with none — the usual case for a self-hosted target — it gets the bare
// `http:outbound` and relies on operatorEgress below, rather than a fake placeholder
// host that would otherwise ship in the published manifest.
const outbound = egress.length ? egress.map((h) => `http:outbound:${h}`) : ['http:outbound'];
const required = ['hook:notification-channel', ...outbound];
perms = [...new Set([...perms.filter((p) => p !== 'db:own'), ...required])];
}
// A plugin that wants outbound but names no host is only valid as an operatorEgress
// plugin — the admin supplies the hosts after install.
const wantsOutbound = perms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'));
const operatorEgress = opts.operatorEgress ?? (isChannel || (wantsOutbound && egress.length === 0));
const manifest: Record<string, unknown> = {
id: name,
@@ -79,7 +91,7 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
author: opts.author || 'Your Name',
description: opts.description || (isChannel ? `Deliver TREK notifications over ${displayName}.` : 'Describe what your plugin does.'),
type,
trek: '>=3.2.1 <4.0.0',
trek: '>=3.3.0 <4.0.0',
nativeModules: false,
permissions: perms,
// Dependency declarations (empty by default). `requiredAddons` lists addon ids
@@ -92,7 +104,7 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
// A notification channel usually targets a SELF-HOSTED service, whose hostname the
// author cannot know at publish time. Declaring operatorEgress lets the admin add the
// real host after install; without it the plugin only ever reaches the hosts above.
if (isChannel) manifest.operatorEgress = true;
if (operatorEgress) manifest.operatorEgress = true;
// A settings-page button so the user can verify their credentials without waiting for
// a real notification. Actions are USER-INITIATED, so ctx.settings.get() works inside.
if (isChannel) manifest.actions = [{ key: 'testConnection', label: 'Test connection' }];
@@ -111,7 +123,9 @@ export function scaffold(name: string, type: string, targetDir: string, opts: Sc
label: 'Server URL',
input_type: 'text',
placeholder: 'https://gotify.example.com',
hint: 'Your Gotify server. Must match an entry in the manifest `egress` list.',
hint: egress.length
? 'Your Gotify server. Must match an entry in the manifest `egress` list.'
: 'Your Gotify server. Its host must be allowed by an admin (Admin → Plugins → Allowed hosts).',
required: true,
scope: 'user',
},
@@ -398,17 +412,20 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
});
let egress: string[] | undefined;
// A channel has to reach its service, so it always needs an egress host — the
// scaffold turns each one into a matching http:outbound:<host> grant.
// The scaffold turns each host into a matching http:outbound:<host> grant. Leaving it
// blank is legitimate — a plugin for a service that is only ever SELF-HOSTED has no host
// to name, so it ships as an operatorEgress plugin and the admin adds the real host.
if (permissions.includes('http:outbound') || template === 'notification-channel') {
const raw = await promptText({
message: template === 'notification-channel'
? 'Which host does your notification service live on?'
: 'External hosts it may call (comma-separated)',
? 'Which host does your notification service live on? (blank if it is always self-hosted)'
: 'External hosts it may call (comma-separated; blank if only the admin can know them)',
placeholder: template === 'notification-channel' ? 'gotify.example.com' : 'api.example.com, api.other.com',
validate: (v) => ((v ?? '').split(',').map((s) => s.trim()).filter(Boolean).length ? undefined : 'list at least one host'),
});
egress = raw.split(',').map((s) => s.trim()).filter(Boolean);
if (!egress.length) {
logInfo('No hosts named — the plugin will declare operatorEgress, and the TREK admin adds the real hosts after installing it.');
}
}
const requiredAddons = await promptMultiselect<string>({
@@ -426,7 +443,8 @@ export async function interactiveScaffold(defaultDir: string, presetName?: strin
`location ${dest}`,
`author ${author}`,
`permissions ${permissions.join(', ') || '(none)'}`,
egress ? `egress ${egress.join(', ')}` : undefined,
egress?.length ? `egress ${egress.join(', ')}` : undefined,
egress && !egress.length ? 'egress (admin-supplied — operatorEgress)' : undefined,
requiredAddons.length ? `addons ${requiredAddons.join(', ')}` : undefined,
].filter(Boolean).join('\n'),
'Review',
+4 -1
View File
@@ -90,7 +90,10 @@ export async function preflight(entry: Entry, opts: { all?: boolean } = {}): Pro
manifestPerms = Array.isArray(m.permissions) ? (m.permissions as string[]) : [];
if (manifestPerms.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
const egress = Array.isArray(m.egress) ? (m.egress as string[]) : [];
if (!egress.length) fail(`${tag}: http:outbound declared but egress[] is empty`);
// Empty egress[] is legal only with operatorEgress (hosts are admin-supplied).
if (!egress.length && !mOperatorEgress) {
fail(`${tag}: http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)`);
}
if (egress.includes('*')) fail(`${tag}: egress[] must not contain a bare "*"`);
}
if (!failures.some((f) => f.startsWith(`${tag}: manifest`))) ok(`${tag}: manifest at commit matches the entry`);
+5 -1
View File
@@ -120,7 +120,11 @@ export function validateManifest(raw: unknown): ValidationResult {
const egress = Array.isArray(m.egress) ? m.egress.map(String) : [];
const wantsOutbound = permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'));
if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty');
// Empty egress[] is legal only with operatorEgress: the hosts are admin-supplied
// post-install. Until an admin adds one the child blocks ALL outbound — not allow-all.
if (wantsOutbound && egress.length === 0 && m.operatorEgress !== true) {
errors.push('http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)');
}
if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"');
for (const h of egress) if (!HOST_RE.test(h)) errors.push(`invalid egress host "${h}"`);
+19 -2
View File
@@ -19,11 +19,15 @@ describe('scaffold egress (http:outbound)', () => {
expect(validateManifest(m).ok).toBe(true);
});
it('without egress the same manifest is invalid — which the wizard now prevents', () => {
it('without egress it scaffolds an operatorEgress plugin (the admin names the hosts)', () => {
// The author of a plugin for an always-self-hosted service has no host to write down.
// The scaffold must NOT invent a placeholder — it declares operatorEgress instead, which
// is the one way an empty egress[] is a valid manifest.
scaffold('net-plug', 'integration', tmp, { permissions: ['http:outbound'] });
const m = JSON.parse(fs.readFileSync(path.join(tmp, 'net-plug', 'trek-plugin.json'), 'utf8'));
expect(m.egress).toBeUndefined();
expect(validateManifest(m).ok).toBe(false); // http:outbound requires an egress allow-list
expect(m.operatorEgress).toBe(true);
expect(validateManifest(m).ok).toBe(true);
});
it('omits egress entirely when none is given (no empty array noise)', () => {
@@ -141,6 +145,19 @@ describe('notification-channel template', () => {
expect(validateManifest(m).ok).toBe(true);
});
it('names no host when the author names none — bare http:outbound + operatorEgress', () => {
// The common case: a Gotify/ntfy channel targets the USER's own server. Inventing a
// `gotify.example.com` placeholder would ship a host nobody actually calls in the
// published manifest, and grant an outbound host the plugin never needs.
const m = make();
expect(m.permissions).toContain('hook:notification-channel');
expect(m.permissions).toContain('http:outbound');
expect(m.permissions.some((p: string) => p.startsWith('http:outbound:'))).toBe(false);
expect('egress' in m).toBe(false);
expect(m.operatorEgress).toBe(true);
expect(validateManifest(m).ok).toBe(true);
});
it('declares the per-user credential as a secret, required, user-scoped field', () => {
const m = make();
const token = m.settings.find((s: { key: string }) => s.key === 'appToken');
+9
View File
@@ -57,6 +57,15 @@ describe('validateManifest', () => {
expect(validateManifest({ ...base, permissions: ['http:outbound'] }).ok).toBe(false);
expect(validateManifest({ ...base, permissions: ['http:outbound'], egress: ['api.x.com'] }).ok).toBe(true);
});
it('waives the egress requirement for an operatorEgress plugin', () => {
// A self-hosted target (Gotify, ntfy) has no host the author can name at publish
// time — the admin supplies it after install, and TREK blocks all outbound until then.
expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true }).ok).toBe(true);
// The waiver is tied to the flag: no flag, still an error.
expect(validateManifest({ ...base, permissions: ['http:outbound'], operatorEgress: false }).ok).toBe(false);
// …and the flag alone never grants reach: it still needs an http:outbound permission.
expect(validateManifest({ ...base, permissions: ['db:own'], operatorEgress: true }).ok).toBe(false);
});
it('rejects native modules and non-objects', () => {
expect(validateManifest({ ...base, nativeModules: true }).ok).toBe(false);
expect(validateManifest('nope').ok).toBe(false);
+10 -2
View File
@@ -167,8 +167,16 @@ export function parseManifest(raw: unknown): PluginManifest {
if (m.operatorEgress === true && !permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:'))) {
throw new ManifestError('operatorEgress requires an http:outbound permission');
}
if (permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) && egress.length === 0) {
throw new ManifestError('http:outbound declared but egress[] is empty');
// An empty egress[] is only legal for an operatorEgress plugin: its hosts are
// admin-supplied post-install, so the manifest has nothing to declare. It is NOT
// an allow-all — the child's guard is built from the (still empty) union, so every
// outbound call is refused until an admin adds a host.
if (
permissions.some((p) => p === 'http:outbound' || p.startsWith('http:outbound:')) &&
egress.length === 0 &&
m.operatorEgress !== true
) {
throw new ManifestError('http:outbound declared but egress[] is empty (set operatorEgress: true if the hosts are admin-supplied)');
}
if (egress.includes('*')) throw new ManifestError('egress[] must not contain a bare "*"');
const badEgress = egress.find((h) => !HOST_RE.test(h));
@@ -26,6 +26,7 @@ import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest';
import { makeHostAllow } from '../../../src/nest/plugins/runtime/egress-policy';
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
testDb.prepare(
@@ -93,6 +94,25 @@ describe('operator-supplied egress hosts', () => {
expect(m.operatorEgress).toBe(true);
});
it('OEG-009 — an operatorEgress plugin may ship an EMPTY egress[]; anyone else may not', () => {
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
// A self-hosted target (Gotify, ntfy) has no host the author can name at publish time.
const m = parseManifest({ ...base, permissions: ['http:outbound'], operatorEgress: true });
expect(m.egress).toEqual([]);
expect(m.operatorEgress).toBe(true);
// Without the flag an empty egress[] is still refused — this is what stops a plugin
// from asking for outbound while declaring no reach at all.
expect(() => parseManifest({ ...base, permissions: ['http:outbound'] })).toThrow(/egress\[\] is empty/);
});
it('OEG-010 — an operatorEgress plugin with no configured hosts still reaches nothing', async () => {
// It ACTIVATES (it may have useful offline features), but the child's allow-list is the
// union of its http:outbound:<host> grants and the admin's hosts — both empty here.
install('gotify', true, ['http:outbound']);
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
expect(makeHostAllow([])('gotify.mydomain.com')).toBe(false);
});
it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => {
install('gotify', true);
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
+20 -2
View File
@@ -748,6 +748,24 @@ Declare `operatorEgress` and the **admin** supplies the real hosts after install
}
```
If there is **no** host you can name — a plugin for a service that is *only* ever self-hosted —
`operatorEgress` is what lets you declare outbound with an **empty `egress[]`** (omit the key).
It is the one case where that is legal; without the flag, `http:outbound` with no egress is a
manifest error:
```json
{
"permissions": ["hook:notification-channel", "http:outbound"],
"operatorEgress": true // every host comes from the admin
}
```
Such a plugin still **installs and activates normally** — it may have useful non-network
features — but until an admin adds a host it reaches *nothing*: the child's allow-list is the
union of your `http:outbound:<host>` grants and the admin's hosts, and both are empty, so every
outbound call fails with `egress: <host> is not in the plugin's declared hosts`. An empty
`egress[]` is never an allow-all.
The admin then opens **Admin → Plugins → ⋯ → Allowed hosts** and adds `gotify.alice.net`. The
runtime unions that into the child's allow-list and **re-spawns the plugin** — the egress guard
is installed once at child start and a second `init` is deliberately refused, so a live child's
@@ -998,11 +1016,11 @@ so a test catches a missing `scheduled`/`deleteUserData`/job before release.
| `license` | string | shown in the store detail (read from the manifest, not enforced). |
| `nativeModules` | boolean | must be `false`/absent — `true` is rejected. |
| `permissions` | string[] | see below. |
| `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present. |
| `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present — **unless** `operatorEgress` is `true`, in which case it may be empty/omitted and the admin supplies the hosts. |
| `capabilities.widget` | object | `{ title, slot, defaultSize }` — `slot` is `sidebar` (default), `hero`, `place-detail`, `day-detail`, or `reservation-detail`. |
| `capabilities.tripPage` | object | `{ replaces?, position? }` for `trip-page` plugins — `replaces` names core planner tabs to hide while active (`transports`, `buchungen`, `listen`, `finanzplan`, `dateien`, `collab`; never `plan`), `position` is the tab's 0-based index in the bar (0–50; omitted = appended). |
| `actions` | array | Buttons on the plugin's own settings page — `{ key, label, hint?, danger? }` (max 8). Implement each as `actions[key](ctx)` on the definition. **User-initiated**, so `ctx.settings.get()` returns the clicking user's value. See [Settings-page actions](#settings-page-actions). |
| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). |
| `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission, and is the only way to declare one with an empty `egress[]`. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). |
| `capabilities.notificationChannel` | object | `{ title?, events? }` for a plugin implementing the `notificationChannel` hook — `title` names the column in the notification preferences matrix (default: the plugin's `name`), `events` **narrows** which events the channel carries (default: every non-admin event; admin-scoped events are never deliverable). Requires the `hook:notification-channel` permission. See [Notification channels](#notification-channels). |
| `capabilities.provides` | string[] | function names this plugin exposes to its dependents via `ctx.plugins.call` (see [Talking to other plugins](#talking-to-other-plugins)). |
| `capabilities.emits` | string[] | event names this plugin publishes to its dependents via `ctx.events.emit`. |
+6 -2
View File
@@ -67,7 +67,7 @@ ungranted capability is physically unreachable**, not just disallowed. See
| `hook:trip-card-provider` | Add small **badges** to the dashboard trip cards via the `hooks.tripCardProvider` provider hook | Implement `TripCardProvider` in `hooks` — `getCards(tripIds, ctx)` is called ONCE with all the trip cards currently on the user's dashboard (each already access-checked for the acting user), returns `{tripId, id, label, value?, icon?, tone?, url?}[]`. **Declarative only** — plugin JS never runs on the dashboard. The host String-coerces + length-caps every field, enum-whitelists the tone, allowlists the url (http/https/mailto), caps the count (≤40/plugin) and drops any badge whose `tripId` the dashboard didn't ask about; a failing provider is skipped. Exposed at `GET /api/trip-card-contributions?tripIds=…`. |
| `hook:notification-channel` | Register a new **notification channel** (Gotify, Pushover, …) via the `hooks.notificationChannel` provider hook | Implement `NotificationChannel` in `hooks` — `send(msg, config, ctx)` receives a notification TREK has **already rendered** into the recipient's language (`{event, title, body, url?, tripName?}`) plus that recipient's own decrypted `scope:'user'` settings as `config`. Unlike every other hook this one is **host-initiated for an arbitrary recipient**, so it runs **userless**: `ctx.settings.get()` returns `undefined` and trip reads are refused — the recipient's credentials arrive as `config` precisely so the plugin never gains the right to read anything *as* them. Optional `test(config, ctx)` backs the "Send test" button. Declare the channel with `capabilities.notificationChannel: { title?, events? }`; `title` names the column in the preferences matrix (default: the plugin's name) and `events` may **narrow** which events it carries (default: every non-admin event — admin-scoped events are never deliverable to a plugin). Throw on failure: the host logs it and isolates it, so a dead channel can't stop the others. |
| `hook:user-data` | Honour GDPR **data-subject rights** — erase and export the data the plugin stores about a user — via the `deleteUserData` / `exportUserData` handlers | Implement `deleteUserData({userId}, ctx)` and/or `exportUserData({userId}, ctx)` on the plugin definition (not on `ctx`). Both are **userless** (no acting user; the plugin only learns the `userId` and touches its OWN db). When a TREK account is deleted, the host queues an erasure for every plugin holding this grant and retries it **durably** until the plugin ACKs — even across restarts — so implement `deleteUserData` idempotently. `exportUserData` returns a JSON-serialisable value the host aggregates for an admin at `GET /api/admin/plugins/user-data/:userId/export`. |
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
| `http:outbound` / `http:outbound:<host>` | Make outbound network requests | **Requires** a non-empty `egress[]` — unless the manifest sets `operatorEgress: true`. Only a **per-host** `http:outbound:<host>` actually opens a host at runtime — see below. |
## Outbound network — `http:outbound` vs `http:outbound:<host>`
@@ -86,7 +86,8 @@ Two independent guards restrict a plugin's network, and **both are built from th
- Only the permissions above are accepted; an unknown string fails validation.
- If **any** `http:outbound` permission (bare or per-host) is declared, `egress[]`
must be **non-empty**.
must be **non-empty** — *unless* the manifest sets `operatorEgress: true`, whose
hosts arrive from the admin after install (see below).
- `egress[]` may not contain a bare `*`.
### `operatorEgress` — hosts only the operator knows
@@ -105,6 +106,9 @@ It is not an escape hatch:
- Added hosts are validated exactly like manifest egress (no bare `*`, no whole-TLD
wildcard, no scheme), and are dropped when the plugin is uninstalled.
- It requires an `http:outbound` permission — the manifest rejects it otherwise.
- It is the **only** way to declare outbound with an empty `egress[]` (a plugin whose target
is *always* self-hosted). Such a plugin activates, but reaches nothing until an admin adds
a host — an empty allow-list blocks everything, it never means "any host".
A LAN/loopback host additionally needs `TREK_PLUGIN_ALLOW_PRIVATE_EGRESS=on`, which relaxes
private-address egress for **every** installed plugin.