mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
fix(sdk): allow empty egress if and only if operatorEgress is true
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user