mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
c9627af239
TREK's four channels (in-app, email, webhook, ntfy) were a closed set: notificationService.send() dispatched with four copy-pasted `if` blocks and no provider abstraction, so a fifth channel meant editing eight files by hand. A plugin could produce a notification via ctx.notify.send(), but never deliver one. A plugin now registers a channel with `hooks.notificationChannel` + `hook:notification-channel` on a plain `type: 'integration'` — not a new manifest type, so the TREK-Plugins registry schema and both its CI gates are untouched. Core refactor - New channel registry (services/notifications/): email/webhook/ntfy become ExternalChannel providers wrapping the EXISTING send functions — no delivery logic is rewritten, only relocated. In-app deliberately stays out: it writes typed rows with scope/target/callbacks, not a rendered title+body, the same line shared/ already draws with i18n/externalNotifications. - The event text is now rendered once per recipient instead of once per channel. - The channel set is open: NotifChannel becomes a string, the matrix is registry-derived, and the UI columns are server-driven. The DB column was already bare TEXT and the Zod contract already a string record — only the TypeScript and the two UIs were ever closed. The hook runs USERLESS. Every other hook is user-initiated, so actingUserId falls out of the request; a notification is host-initiated for an ARBITRARY recipient, so ctx.settings.get() would return undefined. The host resolves the recipient's decrypted scope:'user' settings itself and passes them as an argument. That is what lets a channel plugin be handed someone's push token WITHOUT being handed the right to read their trips as them. Enabling the plugin is the opt-in: a plugin channel is not gated on the admin's `notification_channels` list. A built-in always exists in code and needs an explicit switch; a plugin channel only exists because an admin enabled that plugin. (Nothing could write a `plugin:` id into that CSV anyway, and the admin toggle rebuilt it from three booleans, silently dropping anything else — so requiring a second opt-in meant the channel could never be turned on at all.) Also fixed, found while building this: - Plugin settings keys were unvalidated, so a field named `__proto__` or `constructor` resolved off Object.prototype: a REQUIRED field with such a name reported as configured for every user who had configured nothing — enough, for a channel, to be dispatched to everyone with no credentials. Keys are now constrained at install and the config blob is parsed null-prototype, so it is impossible even for an already-installed plugin. - A `select` field's options were cast straight through, so the obvious `["1","5"]` form rendered every dropdown entry BLANK (the client reads value/label). Now coerced, and malformed options are rejected. Also adds: operator-supplied egress hosts (a plugin talking to a self-hosted service can't name the operator's host at publish time, so an admin adds it post-install and the runtime re-spawns the child with the widened allow-list — only for a plugin that DECLARED operatorEgress, and only an admin, never a user); settings-page actions (a "Test connection" button, user-initiated so ctx.settings.get() returns the clicking user's own value); and a Gotify-shaped notification-channel template in the SDK. Verified end to end against a real Gotify container, not just in tests.
150 lines
7.6 KiB
TypeScript
150 lines
7.6 KiB
TypeScript
/**
|
||
* Operator-supplied egress hosts.
|
||
*
|
||
* A plugin's egress allow-list is fixed in its manifest at publish time, but a plugin that
|
||
* talks to a SELF-HOSTED service (Gotify, ntfy, …) cannot know the operator's hostname —
|
||
* so a community plugin would serve nobody. An ADMIN adds the hosts post-install and the
|
||
* runtime unions them into the child's allow-list at spawn.
|
||
*
|
||
* The invariants that keep this from becoming an egress bypass:
|
||
* - only a plugin that DECLARED operatorEgress may have hosts (install-time consent);
|
||
* - hosts are validated like manifest egress (no bare `*`, no scheme, no whole-TLD);
|
||
* - it is always the ADMIN, never an end user, who widens it;
|
||
* - changing the set RE-SPAWNS the plugin, because the child's guard is install-once.
|
||
*/
|
||
import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } };
|
||
});
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} }));
|
||
|
||
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';
|
||
|
||
function install(id: string, operatorEgress: boolean, perms: string[] = ['http:outbound:gotify.net']) {
|
||
testDb.prepare(
|
||
`INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config, operator_egress)
|
||
VALUES (?, ?, 'inactive', 0, '1.0.0', ?, ?, '{}', '{}', ?)`,
|
||
).run(id, id, JSON.stringify(perms), JSON.stringify(perms), operatorEgress ? 1 : 0);
|
||
}
|
||
|
||
let rt: PluginRuntimeService;
|
||
|
||
beforeAll(() => { createTables(testDb); runMigrations(testDb); });
|
||
beforeEach(() => {
|
||
testDb.prepare('DELETE FROM plugins').run();
|
||
testDb.prepare('DELETE FROM plugin_egress_hosts').run();
|
||
testDb.prepare('DELETE FROM plugin_actions').run();
|
||
rt = new PluginRuntimeService();
|
||
});
|
||
|
||
describe('operator-supplied egress hosts', () => {
|
||
it('OEG-001 — an admin can add hosts to a plugin that declared operatorEgress', async () => {
|
||
install('gotify', true);
|
||
expect(rt.wantsOperatorEgress('gotify')).toBe(true);
|
||
expect(await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com'])).toEqual(['gotify.mydomain.com']);
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual(['gotify.mydomain.com']);
|
||
});
|
||
|
||
it('OEG-002 — a plugin that did NOT declare it can never have hosts added', async () => {
|
||
install('sneaky', false);
|
||
expect(rt.wantsOperatorEgress('sneaky')).toBe(false);
|
||
// This is the load-bearing check: without it an admin could silently widen egress for
|
||
// ANY plugin, and the install-time consent would stop bounding what's possible.
|
||
await expect(rt.setOperatorEgressHosts('sneaky', ['evil.example.com'])).rejects.toThrow(/did not declare operatorEgress/);
|
||
expect(rt.operatorEgressHosts('sneaky')).toEqual([]);
|
||
});
|
||
|
||
it('OEG-003 — hosts are validated exactly like manifest egress', async () => {
|
||
install('gotify', true);
|
||
for (const bad of ['*', '*.com', 'https://gotify.example.com', 'has space', 'a/b']) {
|
||
await expect(rt.setOperatorEgressHosts('gotify', [bad])).rejects.toThrow(/invalid host/);
|
||
}
|
||
// A legitimate wildcard with a real multi-label suffix is fine.
|
||
expect(await rt.setOperatorEgressHosts('gotify', ['*.mydomain.com'])).toEqual(['*.mydomain.com']);
|
||
});
|
||
|
||
it('OEG-004 — hosts are normalized and de-duplicated', async () => {
|
||
install('gotify', true);
|
||
expect(await rt.setOperatorEgressHosts('gotify', ['Gotify.MyDomain.com', 'gotify.mydomain.com.', ' ', ''])).toEqual([
|
||
'gotify.mydomain.com',
|
||
]);
|
||
});
|
||
|
||
it('OEG-005 — setting the list replaces it (a removed host is really gone)', async () => {
|
||
install('gotify', true);
|
||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||
await rt.setOperatorEgressHosts('gotify', ['b.example.com']);
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual(['b.example.com']);
|
||
});
|
||
|
||
it('OEG-006 — the manifest rejects operatorEgress without an outbound permission', () => {
|
||
const base = { id: 'chan', name: 'Chan', version: '1.0.0', apiVersion: 1, type: 'integration', nativeModules: false };
|
||
expect(() => parseManifest({ ...base, permissions: ['db:own'], operatorEgress: true })).toThrow(/requires an http:outbound/);
|
||
expect(() => parseManifest({ ...base, permissions: [], operatorEgress: 'yes' })).toThrow(ManifestError);
|
||
// …and accepts the real thing.
|
||
const m = parseManifest({ ...base, permissions: ['http:outbound:gotify.net'], egress: ['gotify.net'], operatorEgress: true });
|
||
expect(m.operatorEgress).toBe(true);
|
||
});
|
||
|
||
it('OEG-007 — uninstalling drops the admin’s host consent with the plugin', async () => {
|
||
install('gotify', true);
|
||
await rt.setOperatorEgressHosts('gotify', ['gotify.mydomain.com']);
|
||
await rt.uninstall('gotify', false);
|
||
// A LATER plugin reusing this id must not silently inherit hosts approved for another.
|
||
expect(rt.operatorEgressHosts('gotify')).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('settings-page actions (runtime)', () => {
|
||
function declareAction(id: string, key: string) {
|
||
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)')
|
||
.run(id, key, key);
|
||
}
|
||
|
||
it('ACT-001 — actionsOf returns the declared descriptors', () => {
|
||
install('p', false);
|
||
declareAction('p', 'testConnection');
|
||
expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]);
|
||
});
|
||
|
||
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
|
||
install('p', false);
|
||
declareAction('p', 'testConnection');
|
||
// The key is caller-supplied (it comes off the URL), so the host must check it
|
||
// against the manifest rather than forwarding whatever it is handed to the child.
|
||
await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/);
|
||
await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/);
|
||
});
|
||
|
||
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
|
||
install('p', false);
|
||
await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/);
|
||
});
|
||
});
|
||
|
||
describe('the admin list surfaces operator egress (so the chip can be shown)', () => {
|
||
it('OEG-008 — reports operatorEgress + the host count', async () => {
|
||
const { PluginsService } = await import('../../../src/nest/plugins/plugins.service');
|
||
process.env.TREK_PLUGINS_ENABLED = 'true';
|
||
install('gotify', true);
|
||
install('plain', false);
|
||
|
||
const before = new PluginsService().list().plugins;
|
||
expect(before.find(p => p.id === 'gotify')).toMatchObject({ operatorEgress: true, egressHostCount: 0 });
|
||
// A plugin that never asked for it must never invite the admin to add hosts.
|
||
expect(before.find(p => p.id === 'plain')).toMatchObject({ operatorEgress: false, egressHostCount: 0 });
|
||
|
||
await rt.setOperatorEgressHosts('gotify', ['a.example.com', 'b.example.com']);
|
||
const after = new PluginsService().list().plugins;
|
||
expect(after.find(p => p.id === 'gotify')!.egressHostCount).toBe(2);
|
||
delete process.env.TREK_PLUGINS_ENABLED;
|
||
});
|
||
});
|