mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +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.
73 lines
4.2 KiB
TypeScript
73 lines
4.2 KiB
TypeScript
/**
|
|
* Audit fix (#1429 provider hooks): a plugin may only be dispatched a provider hook
|
|
* if it BOTH implements it (reported by the child at load) AND holds the matching
|
|
* hook:* grant the admin consented to. providersOf() is the enforcement point; the
|
|
* child reports Object.keys(def.hooks) with no knowledge of grants, so without this
|
|
* host-side intersection the hook:* consent would be dead code.
|
|
*
|
|
* providersOf only reads status/hooks/granted, so we inject bare Supervised entries
|
|
* into the private running map rather than spawning real children.
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import { PluginSupervisor } from '../../../src/nest/plugins/supervisor/plugin-supervisor';
|
|
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
|
|
|
function makeSupervisor(): PluginSupervisor {
|
|
// createRpcHost is never called on the providersOf path (no spawn).
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return new PluginSupervisor((() => ({})) as any, {}, {});
|
|
}
|
|
function put(s: PluginSupervisor, id: string, status: string, hooks: string[], granted: string[]): void {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(s as any).running.set(id, { id, status, hooks, granted: new Set(granted) });
|
|
}
|
|
|
|
describe('providersOf enforces the hook:* grant', () => {
|
|
it('returns a plugin only when it implements the hook AND holds the matching grant', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'granted', 'active', ['placeDetailProvider'], ['hook:place-detail-provider']);
|
|
put(s, 'ungranted', 'active', ['placeDetailProvider'], ['db:write:places']); // implements it, but hook not granted
|
|
put(s, 'notactive', 'starting', ['placeDetailProvider'], ['hook:place-detail-provider']); // granted, but not active
|
|
put(s, 'warner', 'active', ['warningProvider'], ['hook:trip-warning-provider']);
|
|
expect(s.providersOf('placeDetailProvider')).toEqual(['granted']);
|
|
expect(s.providersOf('warningProvider')).toEqual(['warner']);
|
|
});
|
|
|
|
it('a hook name with no permission mapping resolves to nobody', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'x', 'active', ['mysteryProvider'], ['hook:mystery', 'db:own']);
|
|
expect(s.providersOf('mysteryProvider')).toEqual([]);
|
|
});
|
|
|
|
it('maps the pdf-section / atlas-layer / journal-entry hooks to their grants', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'pdf', 'active', ['pdfSectionProvider'], ['hook:pdf-section-provider']);
|
|
put(s, 'atlas', 'active', ['atlasLayerProvider'], ['hook:atlas-layer-provider']);
|
|
put(s, 'journal', 'active', ['journalEntryProvider'], ['hook:journal-entry-provider']);
|
|
put(s, 'crossed', 'active', ['pdfSectionProvider'], ['hook:atlas-layer-provider']); // wrong grant
|
|
expect(s.providersOf('pdfSectionProvider')).toEqual(['pdf']);
|
|
expect(s.providersOf('atlasLayerProvider')).toEqual(['atlas']);
|
|
expect(s.providersOf('journalEntryProvider')).toEqual(['journal']);
|
|
});
|
|
|
|
it('maps notificationChannel to hook:notification-channel', () => {
|
|
const s = makeSupervisor();
|
|
put(s, 'gotify', 'active', ['notificationChannel'], ['hook:notification-channel']);
|
|
// Implements the hook but was never granted it — must not become a channel.
|
|
put(s, 'sneaky', 'active', ['notificationChannel'], ['notify:send', 'http:outbound']);
|
|
// Granted, but disabled — a channel must not survive being turned off.
|
|
put(s, 'off', 'stopped', ['notificationChannel'], ['hook:notification-channel']);
|
|
expect(s.providersOf('notificationChannel')).toEqual(['gotify']);
|
|
});
|
|
});
|
|
|
|
describe('runtime.invokeHook defense-in-depth', () => {
|
|
it('refuses a plugin id that is not a granted provider of the hook, even if passed directly', async () => {
|
|
const rt = new PluginRuntimeService();
|
|
// one legitimate granted provider exists, so providersOf('placeDetailProvider') = ['ok']
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(rt as any).supervisor.running.set('ok', { id: 'ok', status: 'active', hooks: ['placeDetailProvider'], events: [], granted: new Set(['hook:place-detail-provider']) });
|
|
await expect(rt.invokeHook('other', 'placeDetailProvider', 'getDetails', [1])).rejects.toThrow(/not a granted provider/);
|
|
});
|
|
});
|