From de49cde363845b5c7fd7f6bb471412a6598079b7 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Fri, 3 Jul 2026 16:50:23 +0200 Subject: [PATCH] feat(plugins): activation, HTTP route proxy + instance settings (M2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the isolated runtime into TREK. Admins can now activate a plugin from the panel and its HTTP routes work end to end, still behind the kill switch. - PluginRuntimeService owns the supervisor: activate spawns the child with its granted permissions + decrypted instance config, deactivate kills it, status and errors are persisted to the plugins / plugin_error_log tables, and active plugins are booted on startup (OnModuleInit). - Bidirectional RPC: the child now handles host→child invokes (routes/jobs) and reports its declared routes on load; the supervisor gained invoke()/routesOf(). - /api/plugins/:id/* proxy controller — a single static route that matches the plugin's declared routes, enforces per-route auth (auth:false routes are public for OAuth callbacks/webhooks), forwards only a whitelisted request view (never the session cookie), and strips unsafe response headers. - Admin endpoints: POST :id/activate, POST :id/deactivate, GET/PUT :id/config — instance settings with secret fields encrypted (apiKeyCrypto) and masked. - Kill switch moved to its own module so it never collides with test config mocks. Photo/calendar hook consumers are deferred to a later slice. --- server/src/config.ts | 11 -- server/src/nest/plugins/kill-switch.ts | 10 ++ .../nest/plugins/plugin-runtime.service.ts | 108 +++++++++++++ .../nest/plugins/plugins-proxy.controller.ts | 71 +++++++++ server/src/nest/plugins/plugins.controller.ts | 45 +++++- server/src/nest/plugins/plugins.module.ts | 12 +- server/src/nest/plugins/plugins.service.ts | 65 +++++++- .../nest/plugins/runtime/plugin-host-entry.ts | 42 ++++- .../plugins/supervisor/plugin-supervisor.ts | 72 ++++++++- .../plugins/plugin-runtime.test.ts | 149 ++++++++++++++++++ .../tests/unit/plugins/plugins-proxy.test.ts | 118 ++++++++++++++ .../unit/plugins/plugins-service.test.ts | 81 +++++++++- server/tests/unit/plugins/rpc-host.test.ts | 20 +++ 13 files changed, 775 insertions(+), 29 deletions(-) create mode 100644 server/src/nest/plugins/kill-switch.ts create mode 100644 server/src/nest/plugins/plugin-runtime.service.ts create mode 100644 server/src/nest/plugins/plugins-proxy.controller.ts create mode 100644 server/tests/integration/plugins/plugin-runtime.test.ts create mode 100644 server/tests/unit/plugins/plugins-proxy.test.ts diff --git a/server/src/config.ts b/server/src/config.ts index 54d43f0f8..8c7ab68f1 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -174,14 +174,3 @@ export const SESSION_DURATION_REMEMBER = export const SESSION_DURATION_REMEMBER_MS = parsedRememberMs ?? parseDurationMs(DEFAULT_SESSION_DURATION_REMEMBER)!; /** "Remember me" session length in seconds — passed to `jwt.sign({ expiresIn })`. */ export const SESSION_DURATION_REMEMBER_SECONDS = Math.floor(SESSION_DURATION_REMEMBER_MS / 1000); - -/** - * Plugin-system kill switch (#plugins). Off by default — a brand-new, high-risk - * surface an instance owner must deliberately opt into. When false, the plugin - * module serves its admin listing (so the tab isn't a dead end) but never spawns - * a plugin process and refuses install/activate. Read at call time so tests and - * runtime env changes take effect without re-import. - */ -export function pluginsEnabled(): boolean { - return (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase() === 'true'; -} diff --git a/server/src/nest/plugins/kill-switch.ts b/server/src/nest/plugins/kill-switch.ts new file mode 100644 index 000000000..995ca255e --- /dev/null +++ b/server/src/nest/plugins/kill-switch.ts @@ -0,0 +1,10 @@ +/** + * Plugin-system kill switch (#plugins). Off by default — a brand-new, high-risk + * surface an instance owner must deliberately opt into. Lives in its own module + * (not config.ts) so the many tests that mock config with a partial export set + * don't have to know about it: the plugin runtime reads the env directly here. + * Read at call time so tests and runtime env changes take effect immediately. + */ +export function pluginsEnabled(): boolean { + return (process.env.TREK_PLUGINS_ENABLED || '').trim().toLowerCase() === 'true'; +} diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts new file mode 100644 index 000000000..5cd19ebec --- /dev/null +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -0,0 +1,108 @@ +import { Injectable, type OnModuleInit, type OnModuleDestroy } from '@nestjs/common'; +import { db } from '../../db/database'; +import { pluginsEnabled } from './kill-switch'; +import { decrypt_api_key } from '../../services/apiKeyCrypto'; +import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; +import { createRealRpcHost, closePluginDataDb } from './host/create-rpc-host'; +import { isKnownPermission } from './protocol/envelope'; + +/** + * Owns the isolated-plugin runtime lifecycle inside NestJS (#plugins, M2). + * Bridges the DB registry (`plugins` rows) to the process supervisor: activate + * spawns the child with its granted permissions + decrypted instance config, + * deactivate kills it, and status/errors are persisted back to the DB. Boots all + * `active` plugins on startup when the runtime is enabled. + */ + +interface PluginRow { + id: string; + status: string; + permissions: string; + granted_permissions: string; + config: string; +} + +@Injectable() +export class PluginRuntimeService implements OnModuleInit, OnModuleDestroy { + private readonly supervisor = new PluginSupervisor(createRealRpcHost, { + onStatus: (id, status, error) => { + db.prepare('UPDATE plugins SET status = ?, last_error = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run( + status, + error ?? null, + id, + ); + }, + onLog: (id, level, msg) => { + if (level === 'error' || level === 'warn') { + db.prepare('INSERT INTO plugin_error_log (plugin_id, level, message) VALUES (?, ?, ?)').run(id, level, msg); + } + }, + }); + + onModuleInit(): void { + if (!pluginsEnabled()) return; + const active = db.prepare("SELECT id FROM plugins WHERE status = 'active'").all() as Array<{ id: string }>; + for (const { id } of active) { + this.activate(id).catch(() => { + /* status is persisted as error by the supervisor hook */ + }); + } + } + + async onModuleDestroy(): Promise { + await this.supervisor.shutdownAll(); + } + + /** Spawn a plugin from its DB row (granted permissions + decrypted config). */ + async activate(id: string): Promise { + const row = db.prepare('SELECT id, status, permissions, granted_permissions, config FROM plugins WHERE id = ?').get(id) as + | PluginRow + | undefined; + if (!row) throw new Error(`plugin ${id} not found`); + + const granted = new Set(parseArray(row.granted_permissions).filter(isKnownPermission)); + const config = decryptConfig(parseObject(row.config)); + await this.supervisor.activate(id, granted, config); + } + + async deactivate(id: string): Promise { + await this.supervisor.disable(id); + closePluginDataDb(id); + db.prepare("UPDATE plugins SET status = 'inactive', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(id); + } + + isActive(id: string): boolean { + return this.supervisor.isActive(id); + } + routesOf(id: string): PluginRouteInfo[] { + return this.supervisor.routesOf(id); + } + invoke(id: string, method: string, params: Record): Promise { + return this.supervisor.invoke(id, method, params); + } +} + +function parseArray(json: string): string[] { + try { + const v = JSON.parse(json || '[]'); + return Array.isArray(v) ? v.map(String) : []; + } catch { + return []; + } +} +function parseObject(json: string): Record { + try { + const v = JSON.parse(json || '{}'); + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; + } catch { + return {}; + } +} +/** Decrypt secret config values transparently (decrypt_api_key passes plaintext through). */ +function decryptConfig(config: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(config)) { + out[k] = typeof v === 'string' ? decrypt_api_key(v) : v; + } + return out; +} diff --git a/server/src/nest/plugins/plugins-proxy.controller.ts b/server/src/nest/plugins/plugins-proxy.controller.ts new file mode 100644 index 000000000..010a93843 --- /dev/null +++ b/server/src/nest/plugins/plugins-proxy.controller.ts @@ -0,0 +1,71 @@ +import { All, Controller, Param, Req, Res } from '@nestjs/common'; +import type { Request, Response } from 'express'; +import { extractToken, verifyJwtAndLoadUser } from '../../middleware/auth'; +import { pluginsEnabled } from './kill-switch'; +import { PluginRuntimeService } from './plugin-runtime.service'; + +/** + * Proxies a plugin's own HTTP routes at /api/plugins/:id/* (#plugins, M2). + * + * This is a SINGLE static Nest route — it never registers per-plugin routes at + * runtime. It matches the request against the plugin's declared routes, enforces + * per-route auth (routes with `auth:false` — OAuth callbacks/webhooks — skip the + * session check), and forwards a minimal, whitelisted request view to the + * isolated child over RPC. The plugin never sees raw headers or the session + * cookie, so it cannot replay the user's credentials. + */ +const SAFE_RESPONSE_HEADERS = new Set(['content-type', 'cache-control', 'content-disposition', 'location']); + +@Controller('api/plugins/:pluginId') +export class PluginsProxyController { + constructor(private readonly runtime: PluginRuntimeService) {} + + @All('*path') + async proxy(@Param('pluginId') pluginId: string, @Req() req: Request, @Res() res: Response): Promise { + if (!pluginsEnabled() || !this.runtime.isActive(pluginId)) { + res.status(404).json({ error: 'Plugin not found' }); + return; + } + + const rest = (req.params as Record).path ?? (req.params as Record)[0] ?? ''; + const sub = '/' + (Array.isArray(rest) ? rest.join('/') : String(rest)).replace(/^\/+/, ''); + const route = this.runtime.routesOf(pluginId).find((r) => r.method === req.method && r.path === sub); + if (!route) { + res.status(404).json({ error: 'Not found' }); + return; + } + + // Per-route auth: default-on; `auth:false` routes are public (OAuth cb/webhook). + let user: { id: number; username: string; is_admin?: boolean } | null = null; + if (route.auth) { + const token = extractToken(req); + const loaded = token ? verifyJwtAndLoadUser(token) : null; + if (!loaded) { + res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' }); + return; + } + user = loaded; + } + + try { + const reply = (await this.runtime.invoke(pluginId, 'invoke.route', { + routeId: route.i, + req: { + method: req.method, + path: sub, + query: req.query, + body: req.body ?? null, + user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null, + }, + })) as { status?: number; headers?: Record; body?: unknown }; + + res.status(reply?.status ?? 200); + for (const [k, v] of Object.entries(reply?.headers ?? {})) { + if (SAFE_RESPONSE_HEADERS.has(k.toLowerCase())) res.setHeader(k, v); + } + res.send(reply?.body ?? ''); + } catch (e) { + res.status(502).json({ error: 'Plugin error', detail: e instanceof Error ? e.message : 'unknown' }); + } + } +} diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index fdbc4ef8e..718e3837b 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -1,24 +1,57 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common'; import { PluginsService } from './plugins.service'; +import { PluginRuntimeService } from './plugin-runtime.service'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../auth/admin.guard'; +import { pluginsEnabled } from './kill-switch'; /** * /api/admin/plugins — admin-only plugin control surface (#plugins). * - * M0 scaffold: read-only listing + the runtime-enabled flag. Install, activate, - * disable, uninstall and the registry browser are added in later milestones. + * M0: read-only listing + the runtime-enabled flag. + * M2: activate / deactivate (spawns/kills the isolated child) + instance config. * Admin-gated like the rest of /api/admin. The proxy namespace /api/plugins/:id - * is reserved for the (future) runtime and is intentionally NOT part of this - * admin controller. + * is a separate controller. */ @Controller('api/admin/plugins') @UseGuards(JwtAuthGuard, AdminGuard) export class PluginsController { - constructor(private readonly plugins: PluginsService) {} + constructor( + private readonly plugins: PluginsService, + private readonly runtime: PluginRuntimeService, + ) {} @Get() list() { return this.plugins.list(); } + + @Get(':id/config') + getConfig(@Param('id') id: string) { + return { config: this.plugins.getInstanceConfig(id) }; + } + + @Put(':id/config') + updateConfig(@Param('id') id: string, @Body() body: Record) { + return { config: this.plugins.updateInstanceConfig(id, body || {}) }; + } + + @Post(':id/activate') + @HttpCode(200) + async activate(@Param('id') id: string) { + if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503); + try { + await this.runtime.activate(id); + } catch (e) { + throw new HttpException({ error: e instanceof Error ? e.message : 'activation failed' }, 400); + } + return { status: this.runtime.isActive(id) ? 'active' : 'error' }; + } + + @Post(':id/deactivate') + @HttpCode(200) + async deactivate(@Param('id') id: string) { + await this.runtime.deactivate(id); + return { status: 'inactive' }; + } } diff --git a/server/src/nest/plugins/plugins.module.ts b/server/src/nest/plugins/plugins.module.ts index 462a21f05..e24c0450b 100644 --- a/server/src/nest/plugins/plugins.module.ts +++ b/server/src/nest/plugins/plugins.module.ts @@ -1,14 +1,16 @@ import { Module } from '@nestjs/common'; import { PluginsController } from './plugins.controller'; +import { PluginsProxyController } from './plugins-proxy.controller'; import { PluginsService } from './plugins.service'; +import { PluginRuntimeService } from './plugin-runtime.service'; /** - * Plugin system (#plugins). M0 registers only the read side; the isolated - * runtime supervisor, install pipeline and registry service are added in later - * milestones under this same module. + * Plugin system (#plugins). M0 read side + M2 isolated runtime: the runtime + * service owns the process supervisor and boots active plugins on startup; the + * proxy controller forwards /api/plugins/:id/* to the isolated child. */ @Module({ - controllers: [PluginsController], - providers: [PluginsService], + controllers: [PluginsController, PluginsProxyController], + providers: [PluginsService, PluginRuntimeService], }) export class PluginsModule {} diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 1e88b9c3d..20ea074b0 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -1,6 +1,9 @@ import { Injectable } from '@nestjs/common'; import { db } from '../../db/database'; -import { pluginsEnabled } from '../../config'; +import { pluginsEnabled } from './kill-switch'; +import { maybe_encrypt_api_key } from '../../services/apiKeyCrypto'; + +const SECRET_MASK = '••••••••'; /** * Read side of the plugin system (#plugins), M0 scaffold. Lists installed @@ -33,4 +36,64 @@ export class PluginsService { .all() as PluginListItem[]; return { enabled: pluginsEnabled(), plugins }; } + + /** + * Merge instance-scope settings into the plugin's config, encrypting fields + * declared secret (unless the value is the unchanged mask sentinel). Returns + * the config with secrets masked for the client. + */ + updateInstanceConfig(id: string, patch: Record): Record { + const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; + if (!row) throw new Error(`plugin ${id} not found`); + + const secretKeys = new Set( + ( + db + .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1") + .all(id) as Array<{ field_key: string }> + ).map((r) => r.field_key), + ); + + const config = safeParse(row.config); + for (const [k, v] of Object.entries(patch)) { + if (secretKeys.has(k)) { + if (v === SECRET_MASK) continue; // unchanged secret — keep stored ciphertext + config[k] = maybe_encrypt_api_key(v); + } else { + config[k] = v; + } + } + db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id); + return maskSecrets(config, secretKeys); + } + + /** Read the instance config with secret fields masked. */ + getInstanceConfig(id: string): Record { + const row = db.prepare('SELECT config FROM plugins WHERE id = ?').get(id) as { config: string } | undefined; + if (!row) throw new Error(`plugin ${id} not found`); + const secretKeys = new Set( + ( + db + .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'instance' AND secret = 1") + .all(id) as Array<{ field_key: string }> + ).map((r) => r.field_key), + ); + return maskSecrets(safeParse(row.config), secretKeys); + } +} + +function safeParse(json: string): Record { + try { + const v = JSON.parse(json || '{}'); + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : {}; + } catch { + return {}; + } +} +function maskSecrets(config: Record, secretKeys: Set): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(config)) { + out[k] = secretKeys.has(k) && v ? SECRET_MASK : v; + } + return out; } diff --git a/server/src/nest/plugins/runtime/plugin-host-entry.ts b/server/src/nest/plugins/runtime/plugin-host-entry.ts index ff931b778..3f60e715c 100644 --- a/server/src/nest/plugins/runtime/plugin-host-entry.ts +++ b/server/src/nest/plugins/runtime/plugin-host-entry.ts @@ -51,7 +51,11 @@ async function boot(config: Record): Promise { def = mod && mod.default ? (mod.default as PluginDefinition) : (mod as PluginDefinition); ctx = createPluginContext(pluginId, config, transport); if (typeof def.onLoad === 'function') await def.onLoad(ctx); - send({ k: 'evt', topic: 'loaded', data: {} }); + // Report the declared routes (with their index = routeId) and job ids so the + // host can proxy HTTP and schedule jobs without re-parsing the manifest. + const routes = (def.routes ?? []).map((r, i) => ({ i, method: r.method, path: r.path, auth: r.auth !== false })); + const jobs = (def.jobs ?? []).map((j) => j.id); + send({ k: 'evt', topic: 'loaded', data: { routes, jobs } }); // An immediate first heartbeat confirms liveness without waiting a full interval. send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } }); } catch (e) { @@ -59,6 +63,37 @@ async function boot(config: Record): Promise { } } +/** Handle a host→child request: run a declared route or job with the plugin ctx. */ +async function handleInvoke(req: { id: string; method: string; params: Record }): Promise { + const respond = (ok: boolean, payload: unknown) => + send( + ok + ? { k: 'res', id: req.id, ok: true, result: payload } + : { k: 'res', id: req.id, ok: false, error: { code: 'PLUGIN_ERROR', message: String(payload) } }, + ); + try { + if (!def || !ctx) throw new Error('plugin not loaded'); + if (req.method === 'invoke.route') { + const routeId = req.params.routeId as number; + const route = def.routes?.[routeId]; + if (!route) throw new Error(`no route ${routeId}`); + const pluginReq = req.params.req as Parameters>[0]; + const result = await route.handler(pluginReq, ctx); + respond(true, result); + } else if (req.method === 'invoke.job') { + const jobId = req.params.jobId as string; + const job = def.jobs?.find((j) => j.id === jobId); + if (!job) throw new Error(`no job ${jobId}`); + await job.handler(ctx); + respond(true, { ok: true }); + } else { + respond(false, `unknown invoke ${req.method}`); + } + } catch (e) { + respond(false, errMsg(e)); + } +} + async function shutdown(): Promise { try { if (def && typeof def.onUnload === 'function' && ctx) await def.onUnload(ctx); @@ -72,6 +107,11 @@ async function shutdown(): Promise { process.on('message', (raw: unknown) => { const msg = raw as Envelope; if (!msg || typeof msg !== 'object') return; + if (msg.k === 'req') { + // A host→child invoke (route / job). + void handleInvoke({ id: msg.id, method: msg.method, params: (msg.params ?? {}) as Record }); + return; + } if (msg.k === 'res') { const p = pending.get(msg.id); if (!p) return; diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts index 9aa760725..4470e4b47 100644 --- a/server/src/nest/plugins/supervisor/plugin-supervisor.ts +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -1,8 +1,16 @@ +import { randomUUID } from 'node:crypto'; import { fork, type ChildProcess } from 'node:child_process'; import { resolveChildEntry, pluginCodeDir } from '../paths'; -import type { Envelope, RpcRequest } from '../protocol/envelope'; +import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope'; import type { PluginRpcHost } from '../host/rpc-host'; +export interface PluginRouteInfo { + i: number; + method: string; + path: string; + auth: boolean; +} + /** * Owns the lifecycle of every running plugin child (#plugins, M1): spawn on * activate, route RPC between the child and its capability host, watch @@ -21,6 +29,12 @@ export interface SupervisorHooks { onEvent?(id: string, topic: string, data: unknown): void; } +interface Pending { + resolve: (v: unknown) => void; + reject: (e: Error) => void; + timer: ReturnType; +} + interface Supervised { id: string; granted: ReadonlySet; @@ -30,6 +44,9 @@ interface Supervised { status: PluginStatus; crashes: number[]; // crash timestamps (ms) lastBeat: number; + routes: PluginRouteInfo[]; + jobs: string[]; + pending: Map; // host→child invokes awaiting a response activation?: { resolve: () => void; reject: (e: Error) => void }; } @@ -74,6 +91,9 @@ export class PluginSupervisor { status: 'starting', crashes: [], lastBeat: Date.now(), + routes: [], + jobs: [], + pending: new Map(), }; this.running.set(id, sup); this.ensureSweep(); @@ -99,6 +119,28 @@ export class PluginSupervisor { statusOf(id: string): PluginStatus | null { return this.running.get(id)?.status ?? null; } + /** The plugin's declared HTTP routes (populated once it reports `loaded`). */ + routesOf(id: string): PluginRouteInfo[] { + return this.running.get(id)?.routes ?? []; + } + + /** Send a host→child request (invoke a route or job) and await its response. */ + invoke(id: string, method: string, params: Record, timeoutMs = 30_000): Promise { + const sup = this.running.get(id); + if (!sup || sup.status !== 'active' || !sup.child) { + return Promise.reject(new Error(`plugin ${id} is not active`)); + } + return new Promise((resolve, reject) => { + const reqId = randomUUID(); + const timer = setTimeout(() => { + sup.pending.delete(reqId); + reject(new Error('plugin invoke timed out')); + }, timeoutMs); + timer.unref?.(); + sup.pending.set(reqId, { resolve, reject, timer }); + sup.child!.send({ k: 'req', id: reqId, method, params } satisfies Envelope); + }); + } async shutdownAll(): Promise { if (this.sweep) clearInterval(this.sweep); @@ -145,6 +187,17 @@ export class PluginSupervisor { return; } + if (msg.k === 'res') { + // A response to a host→child invoke (route/job). + const p = sup.pending.get(msg.id); + if (!p) return; + sup.pending.delete(msg.id); + clearTimeout(p.timer); + if (msg.ok) p.resolve(msg.result); + else p.reject(new Error((msg as RpcError).error.message)); + return; + } + if (msg.k === 'evt') { switch (msg.topic) { case 'hello': @@ -153,12 +206,16 @@ export class PluginSupervisor { case 'heartbeat': sup.lastBeat = Date.now(); break; - case 'loaded': + case 'loaded': { sup.lastBeat = Date.now(); + const d = msg.data as { routes?: PluginRouteInfo[]; jobs?: string[] }; + sup.routes = d.routes ?? []; + sup.jobs = d.jobs ?? []; this.setStatus(sup, 'active'); sup.activation?.resolve(); sup.activation = undefined; break; + } case 'load-error': { const message = (msg.data as { message?: string })?.message || 'plugin load failed'; this.setStatus(sup, 'error', message); @@ -179,9 +236,18 @@ export class PluginSupervisor { } } + private rejectPending(sup: Supervised, reason: string): void { + for (const p of sup.pending.values()) { + clearTimeout(p.timer); + p.reject(new Error(reason)); + } + sup.pending.clear(); + } + private onExit(sup: Supervised, code: number | null, signal: string | null): void { - // Reject any pending ctx calls implicitly by killing the child; the child is gone. sup.child = null; + // In-flight host→child invokes can never complete now. + this.rejectPending(sup, 'plugin exited'); // A clean stop we asked for isn't a crash. if (sup.status === 'stopped' || sup.status === 'error') return; if (!this.running.has(sup.id)) return; diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts new file mode 100644 index 000000000..34fcb06a2 --- /dev/null +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -0,0 +1,149 @@ +/** + * End-to-end M2: the runtime service activates a plugin from its DB row and its + * HTTP route works through the host→child invoke path, using its own isolated + * db. Proves the full activate → route → deactivate loop. + */ +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, status TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', + config TEXT DEFAULT '{}', 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, scope TEXT, secret INTEGER);`); + 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 runtime: PluginRuntimeService; + +beforeAll(() => { + codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-rt-code-')); + dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-rt-data-')); + process.env.TREK_PLUGINS_DIR = codeRoot; + process.env.TREK_PLUGINS_DATA_DIR = dataRoot; + process.env.TREK_PLUGINS_ENABLED = 'true'; + + const dir = path.join(codeRoot, 'counter', 'server'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'index.js'), + `module.exports = { + async onLoad(ctx) { await ctx.db.migrate('001', 'CREATE TABLE hits (n INTEGER)'); }, + routes: [ + { method: 'GET', path: '/count', auth: true, async handler(req, ctx) { + await ctx.db.exec('INSERT INTO hits (n) VALUES (1)'); + const rows = await ctx.db.query('SELECT COUNT(*) AS c FROM hits'); + return { status: 200, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ count: rows[0].c, user: req.user }) }; + }}, + { method: 'GET', path: '/boom', auth: false, async handler() { throw new Error('route fail'); } }, + ] + };`, + ); + + testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run(); + runtime = new PluginRuntimeService(); +}); + +afterAll(async () => { + await runtime?.deactivate('counter').catch(() => {}); + delete process.env.TREK_PLUGINS_DIR; + delete process.env.TREK_PLUGINS_DATA_DIR; + delete process.env.TREK_PLUGINS_ENABLED; + fs.rmSync(codeRoot, { recursive: true, force: true }); + fs.rmSync(dataRoot, { recursive: true, force: true }); +}); + +describe('PluginRuntimeService (M2 end-to-end)', () => { + it('activates a plugin and serves its route through the isolated child', async () => { + await runtime.activate('counter'); + expect(runtime.isActive('counter')).toBe(true); + expect(runtime.routesOf('counter')).toEqual([ + { i: 0, method: 'GET', path: '/count', auth: true }, + { i: 1, method: 'GET', path: '/boom', auth: false }, + ]); + + const r1 = (await runtime.invoke('counter', 'invoke.route', { + routeId: 0, + req: { method: 'GET', path: '/count', query: {}, body: null, user: { id: 5, username: 'ada', isAdmin: false } }, + })) as { status: number; body: string }; + expect(r1.status).toBe(200); + const parsed = JSON.parse(r1.body); + expect(parsed.count).toBe(1); + expect(parsed.user).toEqual({ id: 5, username: 'ada', isAdmin: false }); + + // its own db persists across invokes + const r2 = (await runtime.invoke('counter', 'invoke.route', { + routeId: 0, + req: { method: 'GET', path: '/count', query: {}, body: null, user: { id: 5, username: 'ada', isAdmin: false } }, + })) as { body: string }; + expect(JSON.parse(r2.body).count).toBe(2); + + // DB status was persisted active by the supervisor hook + const row = testDb.prepare("SELECT status FROM plugins WHERE id = 'counter'").get() as { status: string }; + expect(row.status).toBe('active'); + }); + + it('invoke on a plugin that is not running rejects', async () => { + await expect(runtime.invoke('never-activated', 'invoke.route', { routeId: 0, req: {} })).rejects.toThrow(/not active/); + }); + + it('a route that throws surfaces as a rejected invoke', async () => { + await expect( + runtime.invoke('counter', 'invoke.route', { routeId: 1, req: { method: 'GET', path: '/boom', query: {}, body: null, user: null } }), + ).rejects.toThrow(/route fail/); + }); + + it('activate throws for an unknown plugin id', async () => { + await expect(runtime.activate('ghost')).rejects.toThrow(/not found/); + }); + + it('onModuleInit is a no-op when the runtime is disabled', () => { + process.env.TREK_PLUGINS_ENABLED = 'false'; + expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow(); + process.env.TREK_PLUGINS_ENABLED = 'true'; + }); + + it('deactivate stops the plugin and marks it inactive', async () => { + await runtime.deactivate('counter'); + expect(runtime.isActive('counter')).toBe(false); + const row = testDb.prepare("SELECT status FROM plugins WHERE id = 'counter'").get() as { status: string }; + expect(row.status).toBe('inactive'); + }); + + it('tolerates malformed granted_permissions / config JSON on activate', async () => { + fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true }); + fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); + testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('messy','inactive','not-json','not-json')").run(); + const rt = new PluginRuntimeService(); + await rt.activate('messy'); // must not throw despite the garbage JSON + expect(rt.isActive('messy')).toBe(true); + await rt.deactivate('messy'); + }); + + it('onModuleInit boots plugins that are active in the DB', async () => { + fs.mkdirSync(path.join(codeRoot, 'booter', 'server'), { recursive: true }); + fs.writeFileSync(path.join(codeRoot, 'booter', 'server', 'index.js'), 'module.exports = { async onLoad() {} };'); + testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('booter','active','[]','{}')").run(); + + const rt = new PluginRuntimeService(); + rt.onModuleInit(); // fire-and-forget spawn + for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50)); + expect(rt.isActive('booter')).toBe(true); + await rt.deactivate('booter'); + }); + + it('onModuleDestroy tears down cleanly', async () => { + await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined(); + }); +}); diff --git a/server/tests/unit/plugins/plugins-proxy.test.ts b/server/tests/unit/plugins/plugins-proxy.test.ts new file mode 100644 index 000000000..e1d43d5e4 --- /dev/null +++ b/server/tests/unit/plugins/plugins-proxy.test.ts @@ -0,0 +1,118 @@ +/** + * The plugin HTTP proxy (#plugins, M2): route matching, per-route auth (auth:false + * routes are public), the whitelisted request view forwarded to the child, and + * error/404 handling — all without a real fork (the runtime is faked). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { pluginsEnabledMock, extractTokenMock, verifyMock } = vi.hoisted(() => ({ + pluginsEnabledMock: vi.fn(() => true), + extractTokenMock: vi.fn(() => 'tok'), + verifyMock: vi.fn(() => ({ id: 5, username: 'ada', is_admin: false })), +})); +vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled: pluginsEnabledMock })); +vi.mock('../../../src/middleware/auth', () => ({ extractToken: extractTokenMock, verifyJwtAndLoadUser: verifyMock })); + +import { PluginsProxyController } from '../../../src/nest/plugins/plugins-proxy.controller'; +import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; + +function fakeRes() { + const res = { + statusCode: 0, + headers: {} as Record, + body: undefined as unknown, + status(c: number) { res.statusCode = c; return res; }, + setHeader(k: string, v: string) { res.headers[k] = v; }, + json(b: unknown) { res.body = b; return res; }, + send(b: unknown) { res.body = b; return res; }, + }; + return res; +} +function fakeReq(method: string, sub: string, extra: Record = {}) { + return { method, params: { 0: sub.replace(/^\//, '') }, query: {}, body: null, ...extra } as never; +} + +function makeRuntime(over: Partial = {}): PluginRuntimeService { + return { + isActive: vi.fn(() => true), + routesOf: vi.fn(() => [{ i: 0, method: 'GET', path: '/status', auth: true }]), + invoke: vi.fn(async () => ({ status: 200, headers: { 'content-type': 'application/json' }, body: '{"ok":true}' })), + ...over, + } as unknown as PluginRuntimeService; +} + +beforeEach(() => { + pluginsEnabledMock.mockClear().mockReturnValue(true); + verifyMock.mockClear().mockReturnValue({ id: 5, username: 'ada', is_admin: false }); + extractTokenMock.mockClear().mockReturnValue('tok'); +}); + +describe('PluginsProxyController', () => { + it('404 when the runtime is disabled', async () => { + pluginsEnabledMock.mockReturnValue(false); + const res = fakeRes(); + await new PluginsProxyController(makeRuntime()).proxy('p', fakeReq('GET', '/status'), res as never); + expect(res.statusCode).toBe(404); + }); + + it('404 when the plugin is not active', async () => { + const res = fakeRes(); + await new PluginsProxyController(makeRuntime({ isActive: vi.fn(() => false) } as never)).proxy('p', fakeReq('GET', '/status'), res as never); + expect(res.statusCode).toBe(404); + }); + + it('404 when no declared route matches', async () => { + const res = fakeRes(); + await new PluginsProxyController(makeRuntime()).proxy('p', fakeReq('GET', '/nope'), res as never); + expect(res.statusCode).toBe(404); + }); + + it('401 on an auth route without a valid session', async () => { + verifyMock.mockReturnValue(null as never); + const res = fakeRes(); + await new PluginsProxyController(makeRuntime()).proxy('p', fakeReq('GET', '/status'), res as never); + expect(res.statusCode).toBe(401); + }); + + it('forwards an authenticated request and returns the child response', async () => { + const runtime = makeRuntime(); + const res = fakeRes(); + await new PluginsProxyController(runtime).proxy('p', fakeReq('GET', '/status'), res as never); + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toBe('application/json'); + expect(res.body).toBe('{"ok":true}'); + // the child receives only a whitelisted user view, never the token/cookie + expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({ + routeId: 0, + req: expect.objectContaining({ user: { id: 5, username: 'ada', isAdmin: false } }), + })); + }); + + it('a public (auth:false) route skips the session check', async () => { + const runtime = makeRuntime({ routesOf: vi.fn(() => [{ i: 1, method: 'POST', path: '/webhook', auth: false }]) } as never); + const res = fakeRes(); + await new PluginsProxyController(runtime).proxy('p', fakeReq('POST', '/webhook'), res as never); + expect(res.statusCode).toBe(200); + expect(extractTokenMock).not.toHaveBeenCalled(); + expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({ + req: expect.objectContaining({ user: null }), + })); + }); + + it('strips unsafe response headers from the child', async () => { + const runtime = makeRuntime({ + invoke: vi.fn(async () => ({ status: 200, headers: { 'content-type': 'text/plain', 'set-cookie': 'evil=1' }, body: 'ok' })), + } as never); + const res = fakeRes(); + await new PluginsProxyController(runtime).proxy('p', fakeReq('GET', '/status'), res as never); + expect(res.headers['content-type']).toBe('text/plain'); + expect(res.headers['set-cookie']).toBeUndefined(); // a plugin cannot set cookies + }); + + it('502 when the plugin invoke throws', async () => { + const runtime = makeRuntime({ invoke: vi.fn(async () => { throw new Error('down'); }) } as never); + const res = fakeRes(); + await new PluginsProxyController(runtime).proxy('p', fakeReq('GET', '/status'), res as never); + expect(res.statusCode).toBe(502); + }); +}); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 2182a67b7..ea139ea14 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -9,7 +9,9 @@ const { testDb } = vi.hoisted(() => { const db = new Database(':memory:'); db.exec(`CREATE TABLE plugins ( id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - status TEXT, reviewed_at TEXT, source_repo TEXT, sort_order INTEGER DEFAULT 0)`); + status TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT, + sort_order INTEGER DEFAULT 0); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);`); return { testDb: db }; }); vi.mock('../../../src/db/database', () => ({ db: testDb })); @@ -19,6 +21,7 @@ import { PluginsController } from '../../../src/nest/plugins/plugins.controller' beforeEach(() => { testDb.exec('DELETE FROM plugins'); + testDb.exec('DELETE FROM plugin_settings_fields'); delete process.env.TREK_PLUGINS_ENABLED; }); afterEach(() => { @@ -46,8 +49,82 @@ describe('PluginsService.list', () => { it('controller delegates to the service', () => { const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService; - const res = new PluginsController(svc).list(); + const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService; + const res = new PluginsController(svc, runtime).list(); expect(svc.list).toHaveBeenCalled(); expect(res).toEqual({ enabled: false, plugins: [] }); }); }); + +describe('PluginsController M2 endpoints', () => { + const svc = { + getInstanceConfig: vi.fn(() => ({ a: 1 })), + updateInstanceConfig: vi.fn(() => ({ a: 2 })), + } as unknown as PluginsService; + + beforeEach(() => { + (svc.getInstanceConfig as ReturnType).mockClear(); + (svc.updateInstanceConfig as ReturnType).mockClear(); + process.env.TREK_PLUGINS_ENABLED = 'true'; + }); + + it('get/update config delegate to the service', () => { + const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never; + const c = new PluginsController(svc, rt); + expect(c.getConfig('x')).toEqual({ config: { a: 1 } }); + expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } }); + }); + + it('activate spawns via the runtime when enabled', async () => { + const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never; + const out = await new PluginsController(svc, rt).activate('x'); + expect(out).toEqual({ status: 'active' }); + }); + + it('activate is 503 when the runtime is disabled', async () => { + process.env.TREK_PLUGINS_ENABLED = 'false'; + const rt = { activate: vi.fn(), isActive: vi.fn() } as never; + await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 503 }); + }); + + it('activate surfaces an activation error as 400', async () => { + const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never; + await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 400 }); + }); + + it('deactivate stops the plugin', async () => { + const rt = { deactivate: vi.fn(async () => {}) } as never; + expect(await new PluginsController(svc, rt).deactivate('x')).toEqual({ status: 'inactive' }); + }); +}); + +describe('PluginsService instance config', () => { + it('encrypts secret fields on write and masks them on read; keeps plaintext for non-secrets', () => { + testDb.prepare("INSERT INTO plugins (id, name, status, config) VALUES ('x','X','inactive','{}')").run(); + testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('x','api_key','instance',1)").run(); + + const svc = new PluginsService(); + const masked = svc.updateInstanceConfig('x', { api_key: 'super-secret', server: 'https://h' }); + // client gets the masked view + expect(masked.api_key).toBe('••••••••'); + expect(masked.server).toBe('https://h'); + + // stored value is encrypted, not plaintext + const stored = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config); + expect(stored.api_key).not.toBe('super-secret'); + expect(String(stored.api_key)).toMatch(/^enc:/); + expect(stored.server).toBe('https://h'); + + // an unchanged mask does not overwrite the stored secret + svc.updateInstanceConfig('x', { api_key: '••••••••' }); + const still = JSON.parse((testDb.prepare("SELECT config FROM plugins WHERE id='x'").get() as { config: string }).config); + expect(still.api_key).toBe(stored.api_key); + + expect(svc.getInstanceConfig('x').api_key).toBe('••••••••'); + }); + + it('throws for an unknown plugin', () => { + expect(() => new PluginsService().updateInstanceConfig('nope', {})).toThrow(/not found/); + expect(() => new PluginsService().getInstanceConfig('nope')).toThrow(/not found/); + }); +}); diff --git a/server/tests/unit/plugins/rpc-host.test.ts b/server/tests/unit/plugins/rpc-host.test.ts index 4d93cf79d..22d789ab6 100644 --- a/server/tests/unit/plugins/rpc-host.test.ts +++ b/server/tests/unit/plugins/rpc-host.test.ts @@ -151,4 +151,24 @@ describe('PluginRpcHost — capability enforcement', () => { expect((res as RpcError).error.code).toBe('HOST_ERROR'); expect((res as RpcError).error.message).toBe('disk gone'); }); + + it('a non-Error thrown by a handler still maps to HOST_ERROR', async () => { + (deps.data.query as ReturnType).mockImplementation(() => { + throw 'raw string'; + }); + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const res = await host.dispatch(req('db.query', { sql: 'SELECT 1' })); + expect((res as RpcError).error.code).toBe('HOST_ERROR'); + expect((res as RpcError).error.message).toBe('internal error'); + }); + + it('coerces numeric string params and tolerates a missing params object', async () => { + const host = new PluginRpcHost('p', new Set(['db:read:trips', 'db:own']), deps); + // tripId/asUserId as strings -> coerced to numbers + const res = await host.dispatch(req('trips.getById', { tripId: '1', asUserId: '42' })); + expect(ok(res)).toBe(true); + // a request with no params object at all -> BAD_PARAMS (sql missing), not a crash + const noParams = await host.dispatch({ k: 'req', id: 'y', method: 'db.query', params: undefined }); + expect((noParams as RpcError).error.code).toBe('BAD_PARAMS'); + }); });