From 14b348581aeabb9f8a3eed61e163af8a8a45b392 Mon Sep 17 00:00:00 2001 From: mauriceboe Date: Fri, 3 Jul 2026 16:18:55 +0200 Subject: [PATCH] feat(plugins): isolated per-plugin runtime + capability RPC (M1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every plugin now runs in its own forked child process with a scrubbed env (no JWT_SECRET, no db path, nothing inherited). It talks to TREK only over a JSON-RPC channel, and the host's capability router registers ONLY the methods a plugin's granted permissions unlock — so an ungranted call is unreachable, not merely refused. The plugin's own data lives in a separate sqlite file it can never open directly; core reads (trips/users) go through membership-checked, column-projected host methods; ws broadcasts are force-namespaced. - protocol/envelope: the wire types + method→permission map (pure, shared by host and the isolated child) - host/rpc-host: the capability router = the enforcement point (dispatch, BAD_PARAMS / PERMISSION_DENIED / RESOURCE_FORBIDDEN / UNKNOWN_METHOD) - host/plugin-data: the per-plugin sqlite file (db:own), guarded against ATTACH/PRAGMA escape, idempotent migrations - host/create-rpc-host: wires the router to the real db/websocket (host-only) - runtime/plugin-sdk + plugin-host-entry: the child bootstrap + definePlugin ctx; turns each ctx call into an RPC, never imports a privileged module - supervisor: spawn on activate, heartbeat/reap, crash backoff + auto-disable, graceful shutdown — a plugin crash/OOM/hang can never reach the Nest loop - paths: code/data layout, dist-vs-tsx child entry resolution Nothing is wired into activation yet (that's the next slice); exercised by unit tests for the router/sdk/data and an integration test that forks a real child. --- .../src/nest/plugins/host/create-rpc-host.ts | 37 +++ .../nest/plugins/host/plugin-data.service.ts | 82 ++++++ server/src/nest/plugins/host/rpc-host.ts | 159 ++++++++++++ server/src/nest/plugins/paths.ts | 60 +++++ server/src/nest/plugins/protocol/envelope.ts | 89 +++++++ .../nest/plugins/runtime/plugin-host-entry.ts | 118 +++++++++ server/src/nest/plugins/runtime/plugin-sdk.ts | 116 +++++++++ .../plugins/supervisor/plugin-supervisor.ts | 245 ++++++++++++++++++ .../integration/plugins/supervisor.test.ts | 142 ++++++++++ .../unit/plugins/create-rpc-host.test.ts | 62 +++++ server/tests/unit/plugins/plugin-data.test.ts | 56 ++++ server/tests/unit/plugins/plugin-sdk.test.ts | 78 ++++++ .../unit/plugins/plugins-service.test.ts | 53 ++++ .../tests/unit/plugins/protocol-paths.test.ts | 51 ++++ server/tests/unit/plugins/rpc-host.test.ts | 154 +++++++++++ server/vitest.config.ts | 5 + 16 files changed, 1507 insertions(+) create mode 100644 server/src/nest/plugins/host/create-rpc-host.ts create mode 100644 server/src/nest/plugins/host/plugin-data.service.ts create mode 100644 server/src/nest/plugins/host/rpc-host.ts create mode 100644 server/src/nest/plugins/paths.ts create mode 100644 server/src/nest/plugins/protocol/envelope.ts create mode 100644 server/src/nest/plugins/runtime/plugin-host-entry.ts create mode 100644 server/src/nest/plugins/runtime/plugin-sdk.ts create mode 100644 server/src/nest/plugins/supervisor/plugin-supervisor.ts create mode 100644 server/tests/integration/plugins/supervisor.test.ts create mode 100644 server/tests/unit/plugins/create-rpc-host.test.ts create mode 100644 server/tests/unit/plugins/plugin-data.test.ts create mode 100644 server/tests/unit/plugins/plugin-sdk.test.ts create mode 100644 server/tests/unit/plugins/plugins-service.test.ts create mode 100644 server/tests/unit/plugins/protocol-paths.test.ts create mode 100644 server/tests/unit/plugins/rpc-host.test.ts diff --git a/server/src/nest/plugins/host/create-rpc-host.ts b/server/src/nest/plugins/host/create-rpc-host.ts new file mode 100644 index 000000000..07a6a8bdf --- /dev/null +++ b/server/src/nest/plugins/host/create-rpc-host.ts @@ -0,0 +1,37 @@ +import { db, canAccessTrip } from '../../../db/database'; +import { broadcast, broadcastToUser } from '../../../websocket'; +import { PluginDataDb } from './plugin-data.service'; +import { PluginRpcHost } from './rpc-host'; + +/** + * Wires a plugin's capability host to the REAL privileged modules (#plugins, + * M1). This is the ONLY plugin file that imports db/websocket — it runs in the + * host (parent), never in the child. Broadcasts are force-namespaced to + * `plugin:{id}:{event}` so a plugin can't forge a core event. + */ + +const dataDbs = new Map(); + +export function getPluginDataDb(id: string): PluginDataDb { + let d = dataDbs.get(id); + if (!d) { + d = new PluginDataDb(id); + dataDbs.set(id, d); + } + return d; +} + +export function closePluginDataDb(id: string): void { + dataDbs.get(id)?.close(); + dataDbs.delete(id); +} + +export function createRealRpcHost(id: string, granted: ReadonlySet): PluginRpcHost { + return new PluginRpcHost(id, granted, { + data: getPluginDataDb(id), + db, + canAccessTrip: (tripId, userId) => canAccessTrip(tripId, userId), + broadcastToTrip: (tripId, event, payload) => broadcast(tripId, `plugin:${id}:${event}`, payload), + broadcastToUser: (userId, payload) => broadcastToUser(userId, { type: `plugin:${id}`, ...payload }), + }); +} diff --git a/server/src/nest/plugins/host/plugin-data.service.ts b/server/src/nest/plugins/host/plugin-data.service.ts new file mode 100644 index 000000000..878ea1a10 --- /dev/null +++ b/server/src/nest/plugins/host/plugin-data.service.ts @@ -0,0 +1,82 @@ +import fs from 'node:fs'; +import Database from 'better-sqlite3'; +import type { Database as Db } from 'better-sqlite3'; +import { pluginDataDir, pluginDbFile } from '../paths'; + +/** + * A plugin's own sqlite database (#plugins, db:own). The HOST owns the handle; + * the plugin child never gets a path or a connection — it can only reach this + * through RPC (db.exec / db.query / db.migrate). Because it is a SEPARATE FILE, + * containment is a filesystem fact: the plugin physically cannot read trek.db, + * and we don't have to police table-name prefixes in its SQL. + * + * A thin guard still rejects statements that would let a plugin escape its file + * (ATTACH another db, VACUUM INTO elsewhere, PRAGMA fiddling) or DoS via + * oversize SQL. + */ + +const MAX_SQL_LENGTH = 100_000; +const FORBIDDEN = /\b(ATTACH|DETACH|VACUUM|PRAGMA)\b/i; + +export class PluginDataDb { + private db: Db; + + constructor(pluginId: string) { + fs.mkdirSync(pluginDataDir(pluginId), { recursive: true }); + this.db = new Database(pluginDbFile(pluginId)); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('foreign_keys = ON'); + // Track applied migrations so db.migrate is idempotent per (plugin, id). + this.db.exec( + `CREATE TABLE IF NOT EXISTS _plugin_migrations (id TEXT PRIMARY KEY, applied_at INTEGER)`, + ); + } + + private guard(sql: string): void { + if (typeof sql !== 'string') throw new Error('sql must be a string'); + if (sql.length > MAX_SQL_LENGTH) throw new Error('sql too long'); + if (FORBIDDEN.test(sql)) throw new Error('statement type not allowed for plugin databases'); + } + + /** Read query — returns all rows. Single statement only (better-sqlite3 prepare). */ + query(sql: string, args: unknown[] = []): unknown[] { + this.guard(sql); + return this.db.prepare(sql).all(...(args as never[])); + } + + /** Write statement(s). exec() allows multiple statements (e.g. a small setup script). */ + exec(sql: string, args: unknown[] = []): { changes: number } { + this.guard(sql); + if (args.length > 0) { + const info = this.db.prepare(sql).run(...(args as never[])); + return { changes: info.changes }; + } + this.db.exec(sql); + return { changes: 0 }; + } + + /** Run a migration once, keyed by id. Re-running with the same id is a no-op. */ + migrate(id: string, sql: string): { applied: boolean } { + this.guard(sql); + const seen = this.db.prepare('SELECT 1 FROM _plugin_migrations WHERE id = ?').get(id); + if (seen) return { applied: false }; + this.db.transaction(() => { + this.db.exec(sql); + this.db.prepare('INSERT INTO _plugin_migrations (id, applied_at) VALUES (?, ?)').run(id, Date.now()); + })(); + return { applied: true }; + } + + close(): void { + try { + this.db.close(); + } catch { + /* already closed */ + } + } +} + +/** Delete a plugin's data directory (uninstall "delete data"). */ +export function removePluginData(pluginId: string): void { + fs.rmSync(pluginDataDir(pluginId), { recursive: true, force: true }); +} diff --git a/server/src/nest/plugins/host/rpc-host.ts b/server/src/nest/plugins/host/rpc-host.ts new file mode 100644 index 000000000..451e94a88 --- /dev/null +++ b/server/src/nest/plugins/host/rpc-host.ts @@ -0,0 +1,159 @@ +import { + KNOWN_METHODS, + type KnownMethod, + type RpcError, + type RpcRequest, + type RpcResponse, +} from '../protocol/envelope'; +import type { PluginDataDb } from './plugin-data.service'; + +/** + * The per-plugin capability router (#plugins, M1) — the ENFORCEMENT POINT. + * + * Built from the plugin's GRANTED permission set. Only the methods a permission + * unlocks are registered; an ungranted method is simply never in the map, so the + * plugin cannot "call it anyway" — there is no shared object, only messages, and + * the host is the sole holder of the trek.db handle and the broadcast fns. + * + * Runs in the HOST (parent) process. + */ + +/** Thrown by a handler when the acting user may not touch the requested resource. */ +export class ForbiddenResource extends Error {} + +interface CoreDb { + prepare(sql: string): { + all(...args: unknown[]): unknown[]; + get(...args: unknown[]): unknown; + }; +} + +export interface HostDeps { + /** The plugin's own sqlite (db:own). */ + data: PluginDataDb; + /** Read-only handle to the core trek.db, used ONLY through the typed readers here. */ + db: CoreDb; + /** Returns the trip row if the user may access it, else undefined. */ + canAccessTrip(tripId: number, userId: number): unknown; + /** Namespaced trip broadcast (host forces the plugin:{id}:{event} event type). */ + broadcastToTrip(tripId: number, eventType: string, payload: Record): void; + /** Namespaced per-user broadcast. */ + broadcastToUser(userId: number, payload: Record): void; +} + +type Handler = (params: Record) => unknown; + +const num = (v: unknown, name: string): number => { + const n = typeof v === 'string' ? Number(v) : v; + if (typeof n !== 'number' || !Number.isFinite(n)) throw new BadParams(`${name} must be a number`); + return n; +}; +const str = (v: unknown, name: string): string => { + if (typeof v !== 'string') throw new BadParams(`${name} must be a string`); + return v; +}; +class BadParams extends Error {} + +export class PluginRpcHost { + private methods = new Map(); + + constructor( + private readonly pluginId: string, + granted: ReadonlySet, + private readonly deps: HostDeps, + ) { + const has = (p: string) => granted.has(p); + + if (has('db:own')) { + this.methods.set('db.query', (p) => deps.data.query(str(p.sql, 'sql'), asArgs(p.args))); + this.methods.set('db.exec', (p) => deps.data.exec(str(p.sql, 'sql'), asArgs(p.args))); + this.methods.set('db.migrate', (p) => deps.data.migrate(str(p.id, 'id'), str(p.sql, 'sql'))); + } + + if (has('db:read:trips')) { + this.methods.set('trips.getById', (p) => this.tripRead(p, () => deps.canAccessTrip(num(p.tripId, 'tripId'), num(p.asUserId, 'asUserId')))); + this.methods.set('trips.getPlaces', (p) => + this.tripRead(p, () => deps.db.prepare('SELECT * FROM places WHERE trip_id = ? ORDER BY day_id, position').all(num(p.tripId, 'tripId'))), + ); + this.methods.set('trips.getReservations', (p) => + this.tripRead(p, () => deps.db.prepare('SELECT * FROM reservations WHERE trip_id = ? ORDER BY reservation_time').all(num(p.tripId, 'tripId'))), + ); + } + + if (has('db:read:users')) { + this.methods.set('users.getById', (p) => + deps.db + .prepare('SELECT id, username, display_name, avatar FROM users WHERE id = ?') + .get(num(p.id, 'id')), + ); + } + + if (has('ws:broadcast:trip')) { + this.methods.set('ws.broadcastToTrip', (p) => { + deps.broadcastToTrip(num(p.tripId, 'tripId'), str(p.event, 'event'), asPayload(p.data)); + return { ok: true }; + }); + } + if (has('ws:broadcast:user')) { + this.methods.set('ws.broadcastToUser', (p) => { + deps.broadcastToUser(num(p.userId, 'userId'), { event: str(p.event, 'event'), ...asPayload(p.data) }); + return { ok: true }; + }); + } + } + + /** Membership-check every trip read against the acting user; forbid otherwise. */ + private tripRead(p: Record, read: () => unknown): unknown { + const tripId = num(p.tripId, 'tripId'); + const asUserId = num(p.asUserId, 'asUserId'); + if (!this.deps.canAccessTrip(tripId, asUserId)) { + throw new ForbiddenResource(`no access to trip ${tripId}`); + } + return read(); + } + + async dispatch(req: RpcRequest): Promise { + const handler = this.methods.get(req.method); + if (!handler) { + const known = (KNOWN_METHODS as readonly string[]).includes(req.method as KnownMethod); + return this.err( + req.id, + known ? 'PERMISSION_DENIED' : 'UNKNOWN_METHOD', + known + ? `${req.method} requires a permission "${this.pluginId}" was not granted` + : `unknown method ${req.method}`, + ); + } + const params = (req.params ?? {}) as Record; + try { + const result = await handler(params); + return { k: 'res', id: req.id, ok: true, result }; + } catch (e) { + if (e instanceof BadParams) return this.err(req.id, 'BAD_PARAMS', e.message); + if (e instanceof ForbiddenResource) return this.err(req.id, 'RESOURCE_FORBIDDEN', e.message); + return this.err(req.id, 'HOST_ERROR', e instanceof Error ? e.message : 'internal error'); + } + } + + private err(id: string, code: RpcError['error']['code'], message: string): RpcError { + return { k: 'res', id, ok: false, error: { code, message } }; + } + + /** Release host-held resources (the plugin's own db handle) on terminal stop. */ + dispose(): void { + try { + this.deps.data.close(); + } catch { + /* already closed */ + } + } +} + +function asArgs(v: unknown): unknown[] { + if (v == null) return []; + if (Array.isArray(v)) return v; + throw new BadParams('args must be an array'); +} +function asPayload(v: unknown): Record { + return v && typeof v === 'object' ? (v as Record) : { value: v }; +} diff --git a/server/src/nest/plugins/paths.ts b/server/src/nest/plugins/paths.ts new file mode 100644 index 000000000..7e0de6dfd --- /dev/null +++ b/server/src/nest/plugins/paths.ts @@ -0,0 +1,60 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Filesystem layout for the plugin system (#plugins). Code and data are two + * separate trees so the child can be given a read-only view of its code and a + * read-write view of only its own data (M1 sets the env; the container runtime + * in v2 enforces the mounts). + * + * Defaults sit under the persisted data dir (server/data), overridable by env + * so a Docker deployment can point them at dedicated volumes. + */ + +const DATA_ROOT = path.resolve(__dirname, '../../../data'); + +// Read lazily so a deployment (or a test) can point these at dedicated volumes +// via env without import-order surprises. +export function pluginsCodeRoot(): string { + return process.env.TREK_PLUGINS_DIR || path.join(DATA_ROOT, 'plugins'); +} +export function pluginsDataRoot(): string { + return process.env.TREK_PLUGINS_DATA_DIR || path.join(DATA_ROOT, 'plugins-data'); +} + +/** A plugin's installed code directory (contains trek-plugin.json + server/index.js). */ +export function pluginCodeDir(id: string): string { + return path.join(pluginsCodeRoot(), id); +} + +/** A plugin's writable data directory (its own sqlite file + any blobs). */ +export function pluginDataDir(id: string): string { + return path.join(pluginsDataRoot(), id); +} + +/** The plugin's own sqlite file — opened by the HOST, reached by the plugin only via RPC. */ +export function pluginDbFile(id: string): string { + return path.join(pluginDataDir(id), 'plugin.db'); +} + +/** + * The child bootstrap entry + the execArgv to fork it with. + * + * Prod/dev run the tsc output, so the sibling `runtime/plugin-host-entry.js` + * exists and forks as plain node. Under vitest the code runs from `src` as TS, + * so no `.js` sibling exists — fall back to the `.ts` source loaded via tsx (a + * prod dependency). This keeps the fork path identical in tests and production. + */ +export function resolveChildEntry(): { entry: string; execArgv: string[]; forkCwd?: string } { + const js = path.join(__dirname, 'runtime', 'plugin-host-entry.js'); + const ts = path.join(__dirname, 'runtime', 'plugin-host-entry.ts'); + if (!fs.existsSync(js) && fs.existsSync(ts)) { + // tsx (dev/test only) is resolved relative to the child's cwd, so the child + // must run from a dir where `tsx` is on the node_modules chain (the server + // root) — NOT the plugin dir. The plugin itself is still loaded by absolute + // path via createRequire, so this doesn't weaken the prod isolation, where + // the .js branch below keeps cwd at the plugin dir. + return { entry: ts, execArgv: ['--import', 'tsx'], forkCwd: process.cwd() }; + } + return { entry: js, execArgv: ['--max-old-space-size=192'] }; +} diff --git a/server/src/nest/plugins/protocol/envelope.ts b/server/src/nest/plugins/protocol/envelope.ts new file mode 100644 index 000000000..9ab504937 --- /dev/null +++ b/server/src/nest/plugins/protocol/envelope.ts @@ -0,0 +1,89 @@ +/** + * The host <-> plugin JSON-RPC wire protocol (#plugins, M1). + * + * PURE TYPES ONLY — this file must never import anything with runtime side + * effects. It is loaded by BOTH the privileged host (parent process) and the + * isolated plugin child, and the child must not transitively pull in db, config + * or the websocket server. Keep it dependency-free. + */ + +export type PluginErrCode = + | 'PERMISSION_DENIED' // a real method the plugin was not granted + | 'UNKNOWN_METHOD' // not a method the host exposes at all + | 'BAD_PARAMS' // params failed validation + | 'RESOURCE_FORBIDDEN' // granted, but the acting user can't touch this resource + | 'TIMEOUT' + | 'PLUGIN_ERROR' + | 'HOST_ERROR'; + +export interface RpcRequest { + k: 'req'; + id: string; + method: string; + params: unknown; +} +export interface RpcResponse { + k: 'res'; + id: string; + ok: true; + result: unknown; +} +export interface RpcError { + k: 'res'; + id: string; + ok: false; + error: { code: PluginErrCode; message: string }; +} +export interface RpcEvent { + k: 'evt'; + topic: string; + data: unknown; +} +export type Envelope = RpcRequest | RpcResponse | RpcError | RpcEvent; + +/** + * Every method the host CAN expose. The capability router registers only the + * subset a plugin was granted; anything here but ungranted resolves to + * PERMISSION_DENIED, anything not here at all resolves to UNKNOWN_METHOD. + */ +export const KNOWN_METHODS = [ + 'db.exec', + 'db.query', + 'db.migrate', + 'trips.getById', + 'trips.getPlaces', + 'trips.getReservations', + 'users.getById', + 'ws.broadcastToTrip', + 'ws.broadcastToUser', +] as const; +export type KnownMethod = (typeof KNOWN_METHODS)[number]; + +/** Which permission unlocks which method(s). The single source for the router. */ +export const METHOD_PERMISSION: Record = { + 'db.exec': 'db:own', + 'db.query': 'db:own', + 'db.migrate': 'db:own', + 'trips.getById': 'db:read:trips', + 'trips.getPlaces': 'db:read:trips', + 'trips.getReservations': 'db:read:trips', + 'users.getById': 'db:read:users', + 'ws.broadcastToTrip': 'ws:broadcast:trip', + 'ws.broadcastToUser': 'ws:broadcast:user', +}; + +/** All permission strings the host understands (unknown ones are rejected at activation). */ +export const KNOWN_PERMISSIONS = [ + 'db:own', + 'db:read:trips', + 'db:read:users', + 'ws:broadcast:trip', + 'ws:broadcast:user', + 'hook:photo-provider', + 'hook:calendar-source', + 'http:outbound', +] as const; + +export function isKnownPermission(p: string): boolean { + return (KNOWN_PERMISSIONS as readonly string[]).includes(p) || p.startsWith('http:outbound:'); +} diff --git a/server/src/nest/plugins/runtime/plugin-host-entry.ts b/server/src/nest/plugins/runtime/plugin-host-entry.ts new file mode 100644 index 000000000..ff931b778 --- /dev/null +++ b/server/src/nest/plugins/runtime/plugin-host-entry.ts @@ -0,0 +1,118 @@ +/** + * The isolated plugin child bootstrap (#plugins, M1). + * + * Runs as a forked node process (`dist/nest/plugins/runtime/plugin-host-entry.js`) + * with a scrubbed env — NO JWT_SECRET, NO db path, NO inherited process.env. It + * loads the plugin's own code and turns every ctx call into an RPC message to + * the parent, which is the only side holding real capabilities. + * + * MUST NOT import any privileged server module (db, config, websocket). Its only + * imports are the pure protocol + SDK. + */ + +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { createPluginContext, type ChildTransport, type PluginContext, type PluginDefinition } from './plugin-sdk'; +import type { Envelope, RpcError } from '../protocol/envelope'; + +const pluginId = process.argv[2] || process.env.TREK_PLUGIN_ID || 'unknown'; +const pluginDir = process.argv[3] || ''; + +const pending = new Map void; reject: (e: Error) => void }>(); +let seq = 0; + +function send(msg: Envelope): void { + process.send?.(msg); +} + +const transport: ChildTransport = { + rpc(method, params) { + return new Promise((resolve, reject) => { + const id = `${++seq}`; + pending.set(id, { resolve, reject }); + send({ k: 'req', id, method, params }); + }); + }, + emit(topic, data) { + send({ k: 'evt', topic, data }); + }, +}; + +let def: PluginDefinition | null = null; +let ctx: PluginContext | null = null; + +async function boot(config: Record): Promise { + try { + // createRequire works whether this bootstrap runs as CJS (prod dist) or ESM + // (tsx in tests), so `require` being undefined in ESM never bites us. + const entry = path.join(pluginDir, 'server', 'index.js'); + const requirePlugin = createRequire(entry); + const mod = requirePlugin(entry); + 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: {} }); + // An immediate first heartbeat confirms liveness without waiting a full interval. + send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } }); + } catch (e) { + send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } }); + } +} + +async function shutdown(): Promise { + try { + if (def && typeof def.onUnload === 'function' && ctx) await def.onUnload(ctx); + } catch { + /* best effort */ + } + clearInterval(heartbeat); + process.exit(0); +} + +process.on('message', (raw: unknown) => { + const msg = raw as Envelope; + if (!msg || typeof msg !== 'object') return; + if (msg.k === 'res') { + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + if (msg.ok) { + p.resolve(msg.result); + } else { + const em = (msg as RpcError).error; + p.reject(new Error(`${em.code}: ${em.message}`)); + } + return; + } + if (msg.k === 'evt') { + if (msg.topic === 'init') void boot(((msg.data as { config?: Record })?.config) ?? {}); + else if (msg.topic === 'shutdown') void shutdown(); + } +}); + +// Ask the host for the init payload (instance config), then wait for it. +send({ k: 'evt', topic: 'hello', data: {} }); + +// Liveness — unref so it never keeps the process alive on its own. +const heartbeat = setInterval(() => { + send({ k: 'evt', topic: 'heartbeat', data: { rss: process.memoryUsage().rss } }); +}, 5000); +heartbeat.unref?.(); + +// A plugin that throws asynchronously must not take the host down — it only +// crashes THIS child, which the supervisor detects and restarts/disables. +process.on('uncaughtException', (e) => { + send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } }); + process.exit(1); +}); +process.on('unhandledRejection', (e) => { + send({ k: 'evt', topic: 'load-error', data: { message: errMsg(e), stack: errStack(e) } }); + process.exit(1); +}); + +function errMsg(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} +function errStack(e: unknown): string | undefined { + return e instanceof Error ? e.stack : undefined; +} diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts new file mode 100644 index 000000000..de658b815 --- /dev/null +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -0,0 +1,116 @@ +/** + * The plugin-author-facing SDK surface (#plugins, M1) — the minimal in-repo + * version. The published `@trek/plugin-sdk` (M6) will re-export these types; for + * now the runtime ships its own copy so the child has zero external deps. + * + * PURE — no server imports. This runs inside the isolated child. Every ctx + * method is plumbing that turns a call into an RPC message to the host; the + * child holds no db handle, no secrets, no network by default. + */ + +export interface PluginContext { + readonly id: string; + readonly config: Readonly>; + db: { + query(sql: string, ...args: unknown[]): Promise; + exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>; + migrate(id: string, sql: string): Promise<{ applied: boolean }>; + }; + trips: { + getById(tripId: number, asUserId: number): Promise; + getPlaces(tripId: number, asUserId: number): Promise; + getReservations(tripId: number, asUserId: number): Promise; + }; + users: { + getById(id: number): Promise; + }; + ws: { + broadcastToTrip(tripId: number, event: string, data: Record): Promise; + broadcastToUser(userId: number, event: string, data: Record): Promise; + }; + log: { + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; + }; +} + +export interface PluginRequest { + method: string; + path: string; + query: Record; + body: unknown; + user: { id: number; username: string; isAdmin: boolean } | null; +} +export interface PluginResponse { + status: number; + headers?: Record; + body?: unknown; +} +export interface PluginRoute { + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + path: string; + auth?: boolean; + handler(req: PluginRequest, ctx: PluginContext): Promise; +} +export interface PluginJob { + id: string; + schedule: string; + handler(ctx: PluginContext): Promise; +} +export interface PluginDefinition { + onLoad?(ctx: PluginContext): Promise | void; + onUnload?(ctx: PluginContext): Promise | void; + routes?: PluginRoute[]; + jobs?: PluginJob[]; + // hooks (photo/calendar) land in M2 +} + +/** Identity helper: gives authors types + a stable shape. A plain object works too. */ +export function definePlugin(def: PluginDefinition): PluginDefinition { + return def; +} + +/** Transport the child entry wires to process.send / message correlation. */ +export interface ChildTransport { + rpc(method: string, params: Record): Promise; + emit(topic: string, data: unknown): void; +} + +/** Build the ctx the plugin's handlers receive — every method is an RPC call. */ +export function createPluginContext( + id: string, + config: Record, + t: ChildTransport, +): PluginContext { + return { + id, + config: Object.freeze({ ...config }), + db: { + query: (sql, ...args) => t.rpc('db.query', { sql, args }) as Promise, + exec: (sql, ...args) => t.rpc('db.exec', { sql, args }) as Promise<{ changes: number }>, + migrate: (mid, sql) => t.rpc('db.migrate', { id: mid, sql }) as Promise<{ applied: boolean }>, + }, + trips: { + getById: (tripId, asUserId) => t.rpc('trips.getById', { tripId, asUserId }), + getPlaces: (tripId, asUserId) => t.rpc('trips.getPlaces', { tripId, asUserId }) as Promise, + getReservations: (tripId, asUserId) => t.rpc('trips.getReservations', { tripId, asUserId }) as Promise, + }, + users: { + getById: (uid) => t.rpc('users.getById', { id: uid }), + }, + ws: { + broadcastToTrip: async (tripId, event, data) => { + await t.rpc('ws.broadcastToTrip', { tripId, event, data }); + }, + broadcastToUser: async (userId, event, data) => { + await t.rpc('ws.broadcastToUser', { userId, event, data }); + }, + }, + log: { + info: (msg, meta) => t.emit('log', { level: 'info', msg, meta }), + warn: (msg, meta) => t.emit('log', { level: 'warn', msg, meta }), + error: (msg, meta) => t.emit('log', { level: 'error', msg, meta }), + }, + }; +} diff --git a/server/src/nest/plugins/supervisor/plugin-supervisor.ts b/server/src/nest/plugins/supervisor/plugin-supervisor.ts new file mode 100644 index 000000000..9aa760725 --- /dev/null +++ b/server/src/nest/plugins/supervisor/plugin-supervisor.ts @@ -0,0 +1,245 @@ +import { fork, type ChildProcess } from 'node:child_process'; +import { resolveChildEntry, pluginCodeDir } from '../paths'; +import type { Envelope, RpcRequest } from '../protocol/envelope'; +import type { PluginRpcHost } from '../host/rpc-host'; + +/** + * Owns the lifecycle of every running plugin child (#plugins, M1): spawn on + * activate, route RPC between the child and its capability host, watch + * heartbeats, and restart-with-backoff / auto-disable on crashes. A child dying + * — segfault, throw, OOM, infinite loop — only ever kills the child; the Nest + * event loop never hiccups. That is what finally makes "a plugin can't crash + * TREK" true. + */ + +export type PluginStatus = 'starting' | 'active' | 'error' | 'stopped'; + +export interface SupervisorHooks { + onStatus?(id: string, status: PluginStatus, error?: string): void; + onLog?(id: string, level: string, msg: string, meta?: unknown): void; + /** Any non-lifecycle event the child emits (e.g. a plugin's own signals). */ + onEvent?(id: string, topic: string, data: unknown): void; +} + +interface Supervised { + id: string; + granted: ReadonlySet; + config: Record; + rpcHost: PluginRpcHost; + child: ChildProcess | null; + status: PluginStatus; + crashes: number[]; // crash timestamps (ms) + lastBeat: number; + activation?: { resolve: () => void; reject: (e: Error) => void }; +} + +export interface SupervisorTuning { + heartbeatTimeoutMs?: number; + crashWindowMs?: number; + crashLimit?: number; + backoffCapMs?: number; + killGraceMs?: number; +} + +const DEFAULTS: Required = { + heartbeatTimeoutMs: 20_000, // 3–4 missed 5s beats + crashWindowMs: 5 * 60_000, + crashLimit: 5, + backoffCapMs: 30_000, + killGraceMs: 3000, +}; + +export class PluginSupervisor { + private running = new Map(); + private sweep: ReturnType | null = null; + private readonly tuning: Required; + + constructor( + private readonly createRpcHost: (id: string, granted: ReadonlySet) => PluginRpcHost, + private readonly hooks: SupervisorHooks = {}, + tuning: SupervisorTuning = {}, + ) { + this.tuning = { ...DEFAULTS, ...tuning }; + } + + /** Spawn a plugin and resolve once it reports `loaded` (or reject on load error). */ + activate(id: string, granted: ReadonlySet, config: Record = {}): Promise { + if (this.running.has(id)) return Promise.resolve(); + const sup: Supervised = { + id, + granted, + config, + rpcHost: this.createRpcHost(id, granted), + child: null, + status: 'starting', + crashes: [], + lastBeat: Date.now(), + }; + this.running.set(id, sup); + this.ensureSweep(); + return new Promise((resolve, reject) => { + sup.activation = { resolve, reject }; + this.spawn(sup); + }); + } + + /** Stop a plugin: ask it to unload, then kill. Idempotent. */ + async disable(id: string): Promise { + const sup = this.running.get(id); + if (!sup) return; + this.running.delete(id); + this.setStatus(sup, 'stopped'); + await this.kill(sup); + sup.rpcHost.dispose(); + } + + isActive(id: string): boolean { + return this.running.get(id)?.status === 'active'; + } + statusOf(id: string): PluginStatus | null { + return this.running.get(id)?.status ?? null; + } + + async shutdownAll(): Promise { + if (this.sweep) clearInterval(this.sweep); + this.sweep = null; + const all = [...this.running.values()]; + await Promise.all(all.map((s) => this.kill(s))); + for (const s of all) s.rpcHost.dispose(); + this.running.clear(); + } + + // ── internals ────────────────────────────────────────────────────────────── + + private spawn(sup: Supervised): void { + const { entry, execArgv, forkCwd } = resolveChildEntry(); + const child = fork(entry, [sup.id, pluginCodeDir(sup.id)], { + cwd: forkCwd ?? pluginCodeDir(sup.id), + execArgv, + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + // Whitelist env — nothing inherited. No JWT_SECRET, no DB creds, no PATH-leaked secrets. + env: { + NODE_ENV: process.env.NODE_ENV ?? 'production', + TZ: process.env.TZ ?? '', + PATH: process.env.PATH ?? '', + TREK_PLUGIN_ID: sup.id, + }, + }); + sup.child = child; + sup.lastBeat = Date.now(); + + child.on('message', (raw: unknown) => this.onMessage(sup, raw as Envelope)); + child.on('exit', (code, signal) => this.onExit(sup, code, signal)); + child.on('error', (e) => this.hooks.onLog?.(sup.id, 'error', `child error: ${e.message}`)); + child.stdout?.on('data', (b) => this.hooks.onLog?.(sup.id, 'info', String(b).trimEnd())); + child.stderr?.on('data', (b) => this.hooks.onLog?.(sup.id, 'error', String(b).trimEnd())); + } + + private async onMessage(sup: Supervised, msg: Envelope): Promise { + if (!msg || typeof msg !== 'object') return; + + if (msg.k === 'req') { + // A ctx.* call from the plugin — dispatch through its capability host. + const res = await sup.rpcHost.dispatch(msg as RpcRequest); + sup.child?.send(res); + return; + } + + if (msg.k === 'evt') { + switch (msg.topic) { + case 'hello': + sup.child?.send({ k: 'evt', topic: 'init', data: { config: sup.config } } satisfies Envelope); + break; + case 'heartbeat': + sup.lastBeat = Date.now(); + break; + case 'loaded': + sup.lastBeat = Date.now(); + 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); + sup.activation?.reject(new Error(message)); + sup.activation = undefined; + await this.kill(sup); + sup.rpcHost.dispose(); + break; + } + case 'log': { + const d = msg.data as { level?: string; msg?: string; meta?: unknown }; + this.hooks.onLog?.(sup.id, d.level || 'info', d.msg || '', d.meta); + break; + } + default: + this.hooks.onEvent?.(sup.id, msg.topic, msg.data); + } + } + } + + 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; + // 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; + + sup.crashes.push(Date.now()); + const recent = sup.crashes.filter((t) => t > Date.now() - this.tuning.crashWindowMs); + if (recent.length >= this.tuning.crashLimit) { + this.setStatus(sup, 'error', `auto-disabled after ${recent.length} crashes`); + sup.activation?.reject(new Error('plugin crashed repeatedly')); + sup.activation = undefined; + sup.rpcHost.dispose(); + return; + } + const delay = Math.min(this.tuning.backoffCapMs, 1000 * 2 ** (recent.length - 1)); + this.hooks.onLog?.(sup.id, 'warn', `crashed (code=${code} sig=${signal}); restarting in ${delay}ms`); + this.setStatus(sup, 'starting'); + const timer = setTimeout(() => { + if (this.running.has(sup.id)) this.spawn(sup); + }, delay); + timer.unref?.(); + } + + private ensureSweep(): void { + if (this.sweep) return; + this.sweep = setInterval(() => this.reapStale(), 5000); + this.sweep.unref?.(); + } + + /** Kill any active plugin that has stopped sending heartbeats (drives the crash path). */ + reapStale(now = Date.now()): void { + for (const sup of this.running.values()) { + if (sup.status === 'active' && now - sup.lastBeat > this.tuning.heartbeatTimeoutMs) { + this.hooks.onLog?.(sup.id, 'warn', 'missed heartbeats; killing'); + sup.child?.kill('SIGKILL'); + } + } + } + + private async kill(sup: Supervised): Promise { + const child = sup.child; + if (!child) return; + sup.child = null; + child.send?.({ k: 'evt', topic: 'shutdown', data: {} } satisfies Envelope); + await new Promise((resolve) => { + const t = setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, this.tuning.killGraceMs); + t.unref?.(); + child.once('exit', () => { + clearTimeout(t); + resolve(); + }); + }); + } + + private setStatus(sup: Supervised, status: PluginStatus, error?: string): void { + sup.status = status; + this.hooks.onStatus?.(sup.id, status, error); + } +} diff --git a/server/tests/integration/plugins/supervisor.test.ts b/server/tests/integration/plugins/supervisor.test.ts new file mode 100644 index 000000000..87839b8a3 --- /dev/null +++ b/server/tests/integration/plugins/supervisor.test.ts @@ -0,0 +1,142 @@ +/** + * End-to-end proof of the isolated plugin runtime (#plugins, M1). Forks a REAL + * child process, loads a fixture plugin, and verifies: + * - onLoad runs isolated and its ctx.db round-trips through RPC to the host, + * - an ungranted capability is refused across the fork boundary, + * - a plugin that throws in onLoad fails activation without touching the host, + * - disable() tears the child down. + * The child runs its own process — its crash/throw can never reach this test. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PluginSupervisor, type SupervisorHooks, type SupervisorTuning } from '../../../src/nest/plugins/supervisor/plugin-supervisor'; +import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host'; +import { PluginDataDb } from '../../../src/nest/plugins/host/plugin-data.service'; + +let codeRoot: string; +let dataRoot: string; +let sup: PluginSupervisor; + +const broadcasts: unknown[] = []; + +function writePlugin(id: string, source: string): void { + const dir = path.join(codeRoot, id, 'server'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'index.js'), source); +} + +function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning: SupervisorTuning = {}): PluginSupervisor { + const createRpcHost = (id: string, granted: ReadonlySet): PluginRpcHost => { + const deps: HostDeps = { + data: new PluginDataDb(id), + db: { prepare: () => ({ all: () => [], get: () => null }) }, + canAccessTrip: () => undefined, + broadcastToTrip: (tripId, event, payload) => broadcasts.push({ tripId, event, payload }), + broadcastToUser: () => {}, + }; + return new PluginRpcHost(id, granted, deps); + }; + const hooks: SupervisorHooks = { + onEvent: (_id, topic, data) => events.push({ topic, data }), + onLog: (_id, level, msg) => events.push({ topic: '__log', data: { level, msg } }), + }; + return new PluginSupervisor(createRpcHost, hooks, tuning); +} + +beforeAll(() => { + codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-code-')); + dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-pdata-')); + process.env.TREK_PLUGINS_DIR = codeRoot; + process.env.TREK_PLUGINS_DATA_DIR = dataRoot; +}); +afterAll(async () => { + delete process.env.TREK_PLUGINS_DIR; + delete process.env.TREK_PLUGINS_DATA_DIR; + fs.rmSync(codeRoot, { recursive: true, force: true }); + fs.rmSync(dataRoot, { recursive: true, force: true }); +}); +afterEach(async () => { + await sup?.shutdownAll(); +}); + +describe('PluginSupervisor — isolated runtime', () => { + it('loads a plugin in a child process: ctx.db round-trips and an ungranted capability is denied', async () => { + const events: Array<{ topic: string; data: unknown }> = []; + sup = makeSupervisor(events); + + writePlugin( + 'hello', + `module.exports = { + async onLoad(ctx) { + await ctx.db.migrate('001', 'CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT)'); + await ctx.db.exec('INSERT INTO kv (k, v) VALUES (?, ?)', ['greeting', ctx.config.greeting || 'none']); + const rows = await ctx.db.query('SELECT v FROM kv WHERE k = ?', 'greeting'); + let tripsDenied = false; + try { await ctx.trips.getById(1, 42); } catch (e) { tripsDenied = /PERMISSION_DENIED/.test(e.message); } + ctx.log.info('selftest complete'); + process.send({ k: 'evt', topic: 'diag', data: { value: rows[0] && rows[0].v, tripsDenied } }); + } + };`, + ); + + await sup.activate('hello', new Set(['db:own']), { greeting: 'hi there' }); + expect(sup.isActive('hello')).toBe(true); + + const diag = events.find((e) => e.topic === 'diag')?.data as { value: string; tripsDenied: boolean }; + expect(diag).toBeTruthy(); + expect(diag.value).toBe('hi there'); // instance config reached the child, db round-trip worked + expect(diag.tripsDenied).toBe(true); // ungranted db:read:trips was refused across the fork boundary + // the plugin's ctx.log surfaced through the supervisor's log hook + expect(events.some((e) => e.topic === '__log')).toBe(true); + }); + + it('a plugin that throws in onLoad fails activation and is marked error — the host survives', async () => { + const events: Array<{ topic: string; data: unknown }> = []; + sup = makeSupervisor(events); + writePlugin('boom', `module.exports = { async onLoad() { throw new Error('kaboom'); } };`); + + await expect(sup.activate('boom', new Set(['db:own']), {})).rejects.toThrow(/kaboom/); + expect(sup.isActive('boom')).toBe(false); + expect(sup.statusOf('boom')).toBe('error'); + }); + + it('disable() stops a running plugin', async () => { + sup = makeSupervisor([]); + writePlugin('stopme', `module.exports = { async onLoad() {} };`); + await sup.activate('stopme', new Set(), {}); + expect(sup.isActive('stopme')).toBe(true); + await sup.disable('stopme'); + expect(sup.isActive('stopme')).toBe(false); + }); + + it('auto-disables a plugin that keeps crashing (backoff + crash limit)', async () => { + sup = makeSupervisor([], { crashLimit: 3, backoffCapMs: 5, crashWindowMs: 60_000 }); + // Exits during onLoad every time -> never reaches "loaded" -> crash loop. + writePlugin('crasher', `module.exports = { async onLoad() { process.exit(1); } };`); + await expect(sup.activate('crasher', new Set(), {})).rejects.toThrow(/crashed repeatedly/); + expect(sup.statusOf('crasher')).toBe('error'); + }); + + it('reaps a plugin that stops sending heartbeats', async () => { + // heartbeatTimeoutMs -1 => any active plugin is "stale"; crashLimit 1 => reap = terminal error. + sup = makeSupervisor([], { heartbeatTimeoutMs: -1, crashLimit: 1, crashWindowMs: 60_000 }); + writePlugin('idle', `module.exports = { async onLoad() {} };`); + await sup.activate('idle', new Set(), {}); + expect(sup.isActive('idle')).toBe(true); + + sup.reapStale(); // manual tick — kills the stale child + await new Promise((r) => setTimeout(r, 400)); // let SIGKILL -> exit -> onCrash settle + expect(sup.statusOf('idle')).toBe('error'); + }); + + it('force-kills a plugin that ignores shutdown (kill grace)', async () => { + sup = makeSupervisor([], { killGraceMs: 60 }); + // onUnload blocks forever -> shutdown never completes -> SIGKILL after the grace. + writePlugin('sticky', `module.exports = { async onLoad() {}, async onUnload() { while (true) {} } };`); + await sup.activate('sticky', new Set(), {}); + await sup.disable('sticky'); // resolves via the kill-grace SIGKILL path + expect(sup.isActive('sticky')).toBe(false); + }); +}); diff --git a/server/tests/unit/plugins/create-rpc-host.test.ts b/server/tests/unit/plugins/create-rpc-host.test.ts new file mode 100644 index 000000000..0a62f4ab7 --- /dev/null +++ b/server/tests/unit/plugins/create-rpc-host.test.ts @@ -0,0 +1,62 @@ +/** + * The production wiring that connects a plugin's capability host to the real + * privileged modules (#plugins, M1). Verifies the per-plugin data db is cached, + * a granted db:own call works through the wired host, and trip broadcasts are + * force-namespaced to plugin:{id}:{event}. + */ +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 { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); +vi.mock('../../../src/db/database', () => ({ + db: { prepare: () => ({ all: () => [], get: () => null }) }, + canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined), +})); +vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser })); + +import { createRealRpcHost, getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/create-rpc-host'; + +let tmp: string; +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-crh-')); + process.env.TREK_PLUGINS_DATA_DIR = tmp; +}); +afterAll(() => { + closePluginDataDb('wired'); + delete process.env.TREK_PLUGINS_DATA_DIR; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('create-rpc-host wiring', () => { + it('caches one data db per plugin id', () => { + const a = getPluginDataDb('wired'); + const b = getPluginDataDb('wired'); + expect(a).toBe(b); + }); + + it('a granted db:own call runs against the plugin db, and a trip broadcast is namespaced', async () => { + const host = createRealRpcHost('wired', new Set(['db:own', 'ws:broadcast:trip'])); + const migrated = await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE t (v TEXT)' } }); + expect(migrated.ok).toBe(true); + + await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } }); + expect(broadcast).toHaveBeenCalledWith(1, 'plugin:wired:ping', { a: 1 }); + + const bcastUser = createRealRpcHost('wired', new Set(['ws:broadcast:user'])); + await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 7, event: 'hi', data: {} } }); + expect(broadcastToUser).toHaveBeenCalledWith(7, { type: 'plugin:wired', event: 'hi' }); + }); + + it('closePluginDataDb closes and drops the cached handle', () => { + getPluginDataDb('transient'); + closePluginDataDb('transient'); + // a fresh get after close returns a NEW instance (cache was cleared) + const a = getPluginDataDb('transient'); + closePluginDataDb('transient'); + const b = getPluginDataDb('transient'); + expect(a).not.toBe(b); + closePluginDataDb('transient'); + }); +}); diff --git a/server/tests/unit/plugins/plugin-data.test.ts b/server/tests/unit/plugins/plugin-data.test.ts new file mode 100644 index 000000000..21927a2a4 --- /dev/null +++ b/server/tests/unit/plugins/plugin-data.test.ts @@ -0,0 +1,56 @@ +/** + * The per-plugin sqlite file (#plugins, M1, db:own). Proves migrations are + * idempotent, reads/writes work against the plugin's OWN file, and the guard + * blocks statements that would let a plugin escape its file (ATTACH/PRAGMA). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { PluginDataDb, removePluginData } from '../../../src/nest/plugins/host/plugin-data.service'; + +let tmp: string; +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-data-')); + process.env.TREK_PLUGINS_DATA_DIR = tmp; +}); +afterAll(() => { + delete process.env.TREK_PLUGINS_DATA_DIR; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe('PluginDataDb', () => { + it('migrates once (idempotent by id), then reads and writes its own data', () => { + const db = new PluginDataDb('notes'); + expect(db.migrate('001', 'CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)').applied).toBe(true); + expect(db.migrate('001', 'CREATE TABLE notes (x)').applied).toBe(false); // same id -> skipped + + db.exec('INSERT INTO notes (body) VALUES (?)', ['hello']); + // exec without bound args runs the multi-statement path + db.exec("INSERT INTO notes (body) VALUES ('second')"); + const rows = db.query('SELECT body FROM notes ORDER BY id') as Array<{ body: string }>; + expect(rows).toEqual([{ body: 'hello' }, { body: 'second' }]); + db.close(); + + // The data lives in its own file, not trek.db + expect(fs.existsSync(path.join(tmp, 'notes', 'plugin.db'))).toBe(true); + }); + + it('rejects statements that would escape the plugin file, and bad sql', () => { + const db = new PluginDataDb('guard'); + expect(() => db.exec("ATTACH DATABASE 'trek.db' AS core")).toThrow(/not allowed/); + expect(() => db.query('PRAGMA table_info(x)')).toThrow(/not allowed/); + expect(() => db.exec(123 as unknown as string)).toThrow(/must be a string/); + expect(() => db.query('x'.repeat(100_001))).toThrow(/too long/); + db.close(); + }); + + it('removePluginData deletes the whole data dir', () => { + const db = new PluginDataDb('temp'); + db.migrate('001', 'CREATE TABLE t (id INTEGER)'); + db.close(); + expect(fs.existsSync(path.join(tmp, 'temp'))).toBe(true); + removePluginData('temp'); + expect(fs.existsSync(path.join(tmp, 'temp'))).toBe(false); + }); +}); diff --git a/server/tests/unit/plugins/plugin-sdk.test.ts b/server/tests/unit/plugins/plugin-sdk.test.ts new file mode 100644 index 000000000..95e0cd17a --- /dev/null +++ b/server/tests/unit/plugins/plugin-sdk.test.ts @@ -0,0 +1,78 @@ +/** + * The child-side SDK (#plugins, M1). Pure plumbing: every ctx method turns into + * an RPC call, config is frozen, and log is a fire-and-forget event. Runs + * in-process against a fake transport (no fork needed). + */ +import { describe, it, expect, vi } from 'vitest'; +import { createPluginContext, definePlugin, type ChildTransport } from '../../../src/nest/plugins/runtime/plugin-sdk'; + +function fakeTransport() { + const rpc = vi.fn(async () => ({ ok: true })); + const emit = vi.fn(); + const transport = { rpc, emit } as unknown as ChildTransport; + return { transport, rpc, emit }; +} + +describe('createPluginContext', () => { + it('maps each ctx method onto the right RPC call', async () => { + const { transport, rpc } = fakeTransport(); + const ctx = createPluginContext('p', {}, transport); + + await ctx.db.query('SELECT 1', 'a'); + expect(rpc).toHaveBeenCalledWith('db.query', { sql: 'SELECT 1', args: ['a'] }); + + await ctx.db.migrate('001', 'CREATE TABLE t (x)'); + expect(rpc).toHaveBeenCalledWith('db.migrate', { id: '001', sql: 'CREATE TABLE t (x)' }); + + await ctx.trips.getById(1, 42); + expect(rpc).toHaveBeenCalledWith('trips.getById', { tripId: 1, asUserId: 42 }); + + await ctx.ws.broadcastToTrip(1, 'ping', { a: 1 }); + expect(rpc).toHaveBeenCalledWith('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }); + + await ctx.users.getById(3); + expect(rpc).toHaveBeenCalledWith('users.getById', { id: 3 }); + + await ctx.db.exec('DELETE FROM t'); + expect(rpc).toHaveBeenCalledWith('db.exec', { sql: 'DELETE FROM t', args: [] }); + + await ctx.trips.getPlaces(1, 42); + expect(rpc).toHaveBeenCalledWith('trips.getPlaces', { tripId: 1, asUserId: 42 }); + + await ctx.trips.getReservations(1, 42); + expect(rpc).toHaveBeenCalledWith('trips.getReservations', { tripId: 1, asUserId: 42 }); + + await ctx.ws.broadcastToUser(9, 'poke', { x: 2 }); + expect(rpc).toHaveBeenCalledWith('ws.broadcastToUser', { userId: 9, event: 'poke', data: { x: 2 } }); + }); + + it('log warn and error also emit events', () => { + const { transport, emit } = fakeTransport(); + const ctx = createPluginContext('p', {}, transport); + ctx.log.warn('careful'); + ctx.log.error('bad', { code: 1 }); + expect(emit).toHaveBeenCalledWith('log', { level: 'warn', msg: 'careful', meta: undefined }); + expect(emit).toHaveBeenCalledWith('log', { level: 'error', msg: 'bad', meta: { code: 1 } }); + }); + + it('freezes config so a plugin cannot mutate its instance settings object', () => { + const ctx = createPluginContext('p', { api_key: 'secret' }, fakeTransport().transport); + expect(ctx.config.api_key).toBe('secret'); + expect(() => { + (ctx.config as Record).api_key = 'tampered'; + }).toThrow(); + }); + + it('log is a fire-and-forget event, not an RPC round-trip', () => { + const { transport, rpc, emit } = fakeTransport(); + const ctx = createPluginContext('p', {}, transport); + ctx.log.info('hello', { n: 1 }); + expect(emit).toHaveBeenCalledWith('log', { level: 'info', msg: 'hello', meta: { n: 1 } }); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('definePlugin returns the definition unchanged', () => { + const def = { onLoad: async () => {} }; + expect(definePlugin(def)).toBe(def); + }); +}); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts new file mode 100644 index 000000000..2182a67b7 --- /dev/null +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -0,0 +1,53 @@ +/** + * The read-side plugin service + controller (#plugins, M0). Lists installed + * plugins and reports whether the runtime is enabled (TREK_PLUGINS_ENABLED). + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +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, + status TEXT, reviewed_at TEXT, source_repo TEXT, sort_order INTEGER DEFAULT 0)`); + return { testDb: db }; +}); +vi.mock('../../../src/db/database', () => ({ db: testDb })); + +import { PluginsService } from '../../../src/nest/plugins/plugins.service'; +import { PluginsController } from '../../../src/nest/plugins/plugins.controller'; + +beforeEach(() => { + testDb.exec('DELETE FROM plugins'); + delete process.env.TREK_PLUGINS_ENABLED; +}); +afterEach(() => { + delete process.env.TREK_PLUGINS_ENABLED; +}); + +describe('PluginsService.list', () => { + it('returns the installed plugins and the runtime-enabled flag', () => { + testDb + .prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)') + .run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0'); + process.env.TREK_PLUGINS_ENABLED = 'true'; + + const out = new PluginsService().list(); + expect(out.enabled).toBe(true); + expect(out.plugins).toHaveLength(1); + expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' }); + }); + + it('reports disabled when the kill switch is off (default)', () => { + const out = new PluginsService().list(); + expect(out.enabled).toBe(false); + expect(out.plugins).toEqual([]); + }); + + it('controller delegates to the service', () => { + const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService; + const res = new PluginsController(svc).list(); + expect(svc.list).toHaveBeenCalled(); + expect(res).toEqual({ enabled: false, plugins: [] }); + }); +}); diff --git a/server/tests/unit/plugins/protocol-paths.test.ts b/server/tests/unit/plugins/protocol-paths.test.ts new file mode 100644 index 000000000..c46e3cb80 --- /dev/null +++ b/server/tests/unit/plugins/protocol-paths.test.ts @@ -0,0 +1,51 @@ +/** + * Small pure helpers of the plugin module (#plugins): permission recognition and + * the code/data path resolution (both the env-override and default branches). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS } from '../../../src/nest/plugins/protocol/envelope'; +import path from 'node:path'; +import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry } from '../../../src/nest/plugins/paths'; + +afterEach(() => { + delete process.env.TREK_PLUGINS_DIR; + delete process.env.TREK_PLUGINS_DATA_DIR; +}); + +describe('envelope helpers', () => { + it('recognises known permissions, http:outbound:, and rejects unknown', () => { + expect(isKnownPermission('db:own')).toBe(true); + expect(isKnownPermission('http:outbound')).toBe(true); + expect(isKnownPermission('http:outbound:api.example.com')).toBe(true); + expect(isKnownPermission('fs:read')).toBe(false); + expect(isKnownPermission('')).toBe(false); + }); + + it('every known method maps to a permission', () => { + for (const m of KNOWN_METHODS) { + expect(METHOD_PERMISSION[m]).toBeTruthy(); + } + }); +}); + +describe('paths', () => { + it('uses the env override when set', () => { + process.env.TREK_PLUGINS_DIR = '/custom/code'; + process.env.TREK_PLUGINS_DATA_DIR = '/custom/data'; + expect(pluginsCodeRoot()).toBe('/custom/code'); + expect(pluginsDataRoot()).toBe('/custom/data'); + expect(pluginCodeDir('x')).toBe(path.join('/custom/code', 'x')); + expect(path.basename(pluginDbFile('x'))).toBe('plugin.db'); + }); + + it('falls back to the data-dir default when unset', () => { + expect(pluginsCodeRoot()).toContain('plugins'); + expect(pluginsDataRoot()).toContain('plugins-data'); + }); + + it('resolves a child entry with fork args', () => { + const r = resolveChildEntry(); + expect(r.entry).toMatch(/plugin-host-entry\.(js|ts)$/); + expect(Array.isArray(r.execArgv)).toBe(true); + }); +}); diff --git a/server/tests/unit/plugins/rpc-host.test.ts b/server/tests/unit/plugins/rpc-host.test.ts new file mode 100644 index 000000000..4d93cf79d --- /dev/null +++ b/server/tests/unit/plugins/rpc-host.test.ts @@ -0,0 +1,154 @@ +/** + * The capability router is the plugin permission boundary (#plugins, M1). These + * tests prove that an ungranted method is never reachable, a granted method + * works, a granted trip read is still membership-checked against the acting + * user, and bad params / unknown methods are rejected — without ever spawning a + * child (the router runs in the host). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host'; +import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope'; + +function makeDeps(): HostDeps { + return { + data: { + query: vi.fn(() => [{ n: 1 }]), + exec: vi.fn(() => ({ changes: 1 })), + migrate: vi.fn(() => ({ applied: true })), + close: vi.fn(), + } as unknown as HostDeps['data'], + db: { + prepare: vi.fn(() => ({ + all: () => [{ id: 7, name: 'Place' }], + get: () => ({ id: 3, username: 'ada', display_name: 'Ada', avatar: null }), + })), + }, + // trip 1 is accessible to user 42; everything else is not + canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1 } : undefined)), + broadcastToTrip: vi.fn(), + broadcastToUser: vi.fn(), + }; +} + +const req = (method: string, params: Record = {}): RpcRequest => ({ k: 'req', id: 'x', method, params }); +const ok = (r: RpcResponse | RpcError): r is RpcResponse => r.ok === true; + +describe('PluginRpcHost — capability enforcement', () => { + let deps: HostDeps; + beforeEach(() => { deps = makeDeps(); }); + + it('registers only granted methods; an ungranted method is PERMISSION_DENIED', async () => { + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const denied = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 })); + expect(denied.ok).toBe(false); + expect((denied as RpcError).error.code).toBe('PERMISSION_DENIED'); + expect(deps.canAccessTrip).not.toHaveBeenCalled(); + }); + + it('a granted db:own method runs against the plugin db', async () => { + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] })); + expect(ok(res)).toBe(true); + expect((res as RpcResponse).result).toEqual([{ n: 1 }]); + expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []); + }); + + it('an unknown method is UNKNOWN_METHOD, not PERMISSION_DENIED', async () => { + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const res = await host.dispatch(req('fs.readFile', { path: '/etc/passwd' })); + expect((res as RpcError).error.code).toBe('UNKNOWN_METHOD'); + }); + + it('db:read:trips reads a trip the acting user can access', async () => { + const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); + const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 })); + expect(ok(res)).toBe(true); + expect((res as RpcResponse).result).toEqual({ id: 1 }); + }); + + it('db:read:trips is still RESOURCE_FORBIDDEN when the user is not a member', async () => { + const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); + const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 99 })); + expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); + }); + + it('trips.getPlaces is membership-checked before the core read', async () => { + const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); + const forbidden = await host.dispatch(req('trips.getPlaces', { tripId: 2, asUserId: 42 })); + expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN'); + expect(deps.db.prepare).not.toHaveBeenCalled(); + + const allowed = await host.dispatch(req('trips.getPlaces', { tripId: 1, asUserId: 42 })); + expect(ok(allowed)).toBe(true); + expect((allowed as RpcResponse).result).toEqual([{ id: 7, name: 'Place' }]); + }); + + it('db:read:users returns only the public projection', async () => { + const host = new PluginRpcHost('p', new Set(['db:read:users']), deps); + const res = await host.dispatch(req('users.getById', { id: 3 })); + expect(ok(res)).toBe(true); + expect((res as RpcResponse).result).toEqual({ id: 3, username: 'ada', display_name: 'Ada', avatar: null }); + // the SELECT column list is host-controlled — no password/token columns + expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('id, username, display_name, avatar')); + }); + + it('ws:broadcast:trip forwards to the (namespaced) broadcaster', async () => { + const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps); + const res = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } })); + expect(ok(res)).toBe(true); + expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { a: 1 }); + }); + + it('bad params are BAD_PARAMS', async () => { + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const res = await host.dispatch(req('db.query', { args: [] })); + expect((res as RpcError).error.code).toBe('BAD_PARAMS'); + }); + + it('with no permissions, every real method is denied', async () => { + const host = new PluginRpcHost('p', new Set(), deps); + for (const m of ['db.query', 'trips.getById', 'users.getById', 'ws.broadcastToTrip']) { + const res = await host.dispatch(req(m, { tripId: 1, asUserId: 42, id: 3, event: 'x', sql: 'SELECT 1' })); + expect((res as RpcError).error.code).toBe('PERMISSION_DENIED'); + } + }); + + it('ws:broadcast:user forwards to the per-user broadcaster', async () => { + const host = new PluginRpcHost('p', new Set(['ws:broadcast:user']), deps); + const res = await host.dispatch(req('ws.broadcastToUser', { userId: 9, event: 'poke', data: { x: 2 } })); + expect(ok(res)).toBe(true); + expect(deps.broadcastToUser).toHaveBeenCalledWith(9, { event: 'poke', x: 2 }); + }); + + it('non-array db args are BAD_PARAMS; a primitive ws payload is wrapped', async () => { + const host = new PluginRpcHost('p', new Set(['db:own', 'ws:broadcast:trip']), deps); + const bad = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: 'nope' })); + expect((bad as RpcError).error.code).toBe('BAD_PARAMS'); + + await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' })); + expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { value: 'primitive' }); + }); + + it('dispose() closes the plugin data db', () => { + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + host.dispose(); + expect(deps.data.close).toHaveBeenCalled(); + }); + + it('trips.getReservations is membership-checked and reads reservations', async () => { + const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps); + const res = await host.dispatch(req('trips.getReservations', { tripId: 1, asUserId: 42 })); + expect(ok(res)).toBe(true); + expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('FROM reservations')); + }); + + it('an error thrown by a handler becomes HOST_ERROR', async () => { + (deps.data.query as ReturnType).mockImplementation(() => { + throw new Error('disk gone'); + }); + const host = new PluginRpcHost('p', new Set(['db:own']), deps); + const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] })); + expect((res as RpcError).error.code).toBe('HOST_ERROR'); + expect((res as RpcError).error.message).toBe('disk gone'); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 1abeedcca..e08ecc832 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -32,6 +32,11 @@ export default defineConfig({ reporter: ['lcov', 'text'], reportsDirectory: './coverage', include: ['src/**/*.ts'], + // The plugin child bootstrap runs in a forked subprocess via tsx, so the + // parent's instrumentation can't measure it; it's exercised end-to-end by + // the supervisor integration test instead. Everything else in the plugin + // module runs in-process and is unit-tested. + exclude: ['src/nest/plugins/runtime/plugin-host-entry.ts'], // Coverage gate scoped to the new NestJS code only — the legacy codebase // is intentionally ungated. Raised to the DoD's >=80% bar once the first // module (weather) landed; ratchet further as more modules are migrated.