mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +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.
138 lines
7.1 KiB
TypeScript
138 lines
7.1 KiB
TypeScript
/**
|
|
* Dev-link (#plugins, developer experience): register a plugin from a LOCAL build
|
|
* dir and hot-reload it against real data. Proves the link symlinks the source and
|
|
* registers it INACTIVE as `local:link`, the gates (dev-only flag, absolute path,
|
|
* built artifact, native binaries, don't-clobber-a-real-plugin, reload only a link),
|
|
* and a full link -> activate -> reload loop through a real isolated child.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
const { testDb } = vi.hoisted(() => {
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(':memory:');
|
|
db.exec(`CREATE TABLE plugins (
|
|
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER,
|
|
min_trek_version TEXT, permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}',
|
|
operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '', status TEXT, enabled INTEGER DEFAULT 0, config TEXT DEFAULT '{}',
|
|
source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at TEXT);
|
|
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
|
|
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER);
|
|
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
|
|
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
|
|
CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);`);
|
|
return { testDb: db };
|
|
});
|
|
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
|
|
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
|
|
|
|
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
|
|
|
|
let codeRoot: string;
|
|
let dataRoot: string;
|
|
let srcRoot: string; // the "developer's" source lives OUTSIDE the plugins volume
|
|
let runtime: PluginRuntimeService;
|
|
|
|
const ROUTE = (v: string) =>
|
|
`module.exports = { routes: [{ method: 'GET', path: '/v', auth: false, async handler() { return { status: 200, body: JSON.stringify({ v: ${v} }) }; } }] };`;
|
|
|
|
function writeSource(id: string, opts: { index?: string; native?: boolean; noBuild?: boolean } = {}): string {
|
|
const dir = path.join(srcRoot, id);
|
|
fs.mkdirSync(path.join(dir, 'server'), { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'trek-plugin.json'), JSON.stringify({ id, name: id, version: '1.0.0', type: 'integration', permissions: [] }));
|
|
if (!opts.noBuild) fs.writeFileSync(path.join(dir, 'server', 'index.js'), opts.index ?? 'module.exports = {};');
|
|
if (opts.native) fs.writeFileSync(path.join(dir, 'server', 'addon.node'), '\0');
|
|
return dir;
|
|
}
|
|
|
|
beforeAll(() => {
|
|
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-link-code-'));
|
|
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-link-data-'));
|
|
srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-link-src-'));
|
|
process.env.TREK_PLUGINS_DIR = codeRoot;
|
|
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
|
|
process.env.TREK_PLUGINS_ENABLED = 'true';
|
|
process.env.TREK_PLUGINS_DEV_LINK = '1';
|
|
runtime = new PluginRuntimeService();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await runtime?.onModuleDestroy();
|
|
for (const r of [codeRoot, dataRoot, srcRoot]) fs.rmSync(r, { recursive: true, force: true });
|
|
delete process.env.TREK_PLUGINS_DIR;
|
|
delete process.env.TREK_PLUGINS_DATA_DIR;
|
|
delete process.env.TREK_PLUGINS_ENABLED;
|
|
delete process.env.TREK_PLUGINS_DEV_LINK;
|
|
});
|
|
|
|
describe('PluginRuntimeService dev-link', () => {
|
|
it('links a local dir: symlinks the source in and registers it inactive as local:link', async () => {
|
|
const dir = writeSource('linkplug');
|
|
const res = await runtime.link(dir);
|
|
expect(res).toMatchObject({ id: 'linkplug', version: '1.0.0', replaced: false });
|
|
|
|
const dest = path.join(codeRoot, 'linkplug');
|
|
// a link (POSIX symlink or Windows junction) that resolves to the source build
|
|
expect(fs.existsSync(path.join(dest, 'server', 'index.js'))).toBe(true);
|
|
expect(fs.realpathSync(dest)).toBe(fs.realpathSync(dir));
|
|
|
|
const row = testDb.prepare("SELECT source_repo, status, enabled FROM plugins WHERE id = 'linkplug'").get() as {
|
|
source_repo: string; status: string; enabled: number;
|
|
};
|
|
expect(row).toMatchObject({ source_repo: 'local:link', status: 'inactive', enabled: 0 });
|
|
});
|
|
|
|
it('re-linking an existing link updates it in place (replaced=true)', async () => {
|
|
const dir = writeSource('linkplug');
|
|
const res = await runtime.link(dir);
|
|
expect(res.replaced).toBe(true);
|
|
});
|
|
|
|
it('rejects a non-absolute path, a missing manifest, a missing build, and native binaries', async () => {
|
|
await expect(runtime.link('relative/dir')).rejects.toThrow(/absolute/);
|
|
await expect(runtime.link(path.join(srcRoot, 'ghost-dir'))).rejects.toThrow(/trek-plugin\.json/);
|
|
await expect(runtime.link(writeSource('nobuild', { noBuild: true }))).rejects.toThrow(/server\/index\.js|build/);
|
|
await expect(runtime.link(writeSource('nativeplug', { native: true }))).rejects.toThrow(/native/);
|
|
});
|
|
|
|
it('refuses to clobber a real (non-linked) installed plugin of the same id', async () => {
|
|
testDb.prepare("INSERT INTO plugins (id, name, type, version, status, source_repo) VALUES ('installed','X','integration','1.0.0','inactive','local:upload')").run();
|
|
await expect(runtime.link(writeSource('installed'))).rejects.toThrow(/already installed/);
|
|
});
|
|
|
|
it('is disabled unless TREK_PLUGINS_DEV_LINK=1', async () => {
|
|
delete process.env.TREK_PLUGINS_DEV_LINK;
|
|
try {
|
|
await expect(runtime.link(writeSource('gated'))).rejects.toThrow(/disabled/);
|
|
await expect(runtime.reload('linkplug')).rejects.toThrow(/disabled/);
|
|
} finally {
|
|
process.env.TREK_PLUGINS_DEV_LINK = '1';
|
|
}
|
|
});
|
|
|
|
it('reload rejects an unknown or non-linked plugin', async () => {
|
|
await expect(runtime.reload('ghost')).rejects.toThrow(/not found/);
|
|
await expect(runtime.reload('installed')).rejects.toThrow(/not dev-linked/);
|
|
});
|
|
|
|
it('links, activates through a real isolated child, and reload() re-forks it (the hot-reload primitive)', async () => {
|
|
const dir = writeSource('live', { index: ROUTE('1') });
|
|
await runtime.link(dir);
|
|
await runtime.activate('live');
|
|
expect(runtime.isActive('live')).toBe(true);
|
|
|
|
const before = (await runtime.invoke('live', 'invoke.route', { routeId: 0, req: {} })) as { body: string };
|
|
expect(JSON.parse(before.body).v).toBe(1);
|
|
|
|
await runtime.reload('live'); // deactivate -> activate, same grants, no re-consent
|
|
expect(runtime.isActive('live')).toBe(true);
|
|
|
|
const after = (await runtime.invoke('live', 'invoke.route', { routeId: 0, req: {} })) as { body: string };
|
|
expect(JSON.parse(after.body).v).toBe(1);
|
|
|
|
await runtime.deactivate('live').catch(() => {});
|
|
});
|
|
});
|