feat(plugins): isolated per-plugin runtime + capability RPC (M1)

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.
This commit is contained in:
mauriceboe
2026-07-03 16:18:55 +02:00
committed by Maurice
parent 380641f4fd
commit 5e3f7095a5
16 changed files with 1507 additions and 0 deletions
@@ -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<string, PluginDataDb>();
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<string>): 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 }),
});
}
@@ -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 });
}
+159
View File
@@ -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<string, unknown>): void;
/** Namespaced per-user broadcast. */
broadcastToUser(userId: number, payload: Record<string, unknown>): void;
}
type Handler = (params: Record<string, unknown>) => 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<string, Handler>();
constructor(
private readonly pluginId: string,
granted: ReadonlySet<string>,
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<string, unknown>, 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<RpcResponse | RpcError> {
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<string, unknown>;
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<string, unknown> {
return v && typeof v === 'object' ? (v as Record<string, unknown>) : { value: v };
}
+60
View File
@@ -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'] };
}
@@ -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<KnownMethod, string> = {
'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:');
}
@@ -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<string, { resolve: (v: unknown) => 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<string, unknown>): Promise<void> {
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<void> {
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<string, unknown> })?.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;
}
@@ -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<Record<string, unknown>>;
db: {
query<T = unknown>(sql: string, ...args: unknown[]): Promise<T[]>;
exec(sql: string, ...args: unknown[]): Promise<{ changes: number }>;
migrate(id: string, sql: string): Promise<{ applied: boolean }>;
};
trips: {
getById(tripId: number, asUserId: number): Promise<unknown>;
getPlaces(tripId: number, asUserId: number): Promise<unknown[]>;
getReservations(tripId: number, asUserId: number): Promise<unknown[]>;
};
users: {
getById(id: number): Promise<unknown>;
};
ws: {
broadcastToTrip(tripId: number, event: string, data: Record<string, unknown>): Promise<void>;
broadcastToUser(userId: number, event: string, data: Record<string, unknown>): Promise<void>;
};
log: {
info(msg: string, meta?: Record<string, unknown>): void;
warn(msg: string, meta?: Record<string, unknown>): void;
error(msg: string, meta?: Record<string, unknown>): void;
};
}
export interface PluginRequest {
method: string;
path: string;
query: Record<string, unknown>;
body: unknown;
user: { id: number; username: string; isAdmin: boolean } | null;
}
export interface PluginResponse {
status: number;
headers?: Record<string, string>;
body?: unknown;
}
export interface PluginRoute {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
auth?: boolean;
handler(req: PluginRequest, ctx: PluginContext): Promise<PluginResponse>;
}
export interface PluginJob {
id: string;
schedule: string;
handler(ctx: PluginContext): Promise<void>;
}
export interface PluginDefinition {
onLoad?(ctx: PluginContext): Promise<void> | void;
onUnload?(ctx: PluginContext): Promise<void> | 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<string, unknown>): Promise<unknown>;
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<string, unknown>,
t: ChildTransport,
): PluginContext {
return {
id,
config: Object.freeze({ ...config }),
db: {
query: (sql, ...args) => t.rpc('db.query', { sql, args }) as Promise<never[]>,
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<unknown[]>,
getReservations: (tripId, asUserId) => t.rpc('trips.getReservations', { tripId, asUserId }) as Promise<unknown[]>,
},
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 }),
},
};
}
@@ -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<string>;
config: Record<string, unknown>;
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<SupervisorTuning> = {
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<string, Supervised>();
private sweep: ReturnType<typeof setInterval> | null = null;
private readonly tuning: Required<SupervisorTuning>;
constructor(
private readonly createRpcHost: (id: string, granted: ReadonlySet<string>) => 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<string>, config: Record<string, unknown> = {}): Promise<void> {
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<void>((resolve, reject) => {
sup.activation = { resolve, reject };
this.spawn(sup);
});
}
/** Stop a plugin: ask it to unload, then kill. Idempotent. */
async disable(id: string): Promise<void> {
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<void> {
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<void> {
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<void> {
const child = sup.child;
if (!child) return;
sup.child = null;
child.send?.({ k: 'evt', topic: 'shutdown', data: {} } satisfies Envelope);
await new Promise<void>((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);
}
}
@@ -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<string>): 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);
});
});
@@ -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');
});
});
@@ -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);
});
});
@@ -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<string, unknown>).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);
});
});
@@ -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: [] });
});
});
@@ -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:<host>, 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);
});
});
+154
View File
@@ -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<string, unknown> = {}): 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<string>(), 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<typeof vi.fn>).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');
});
});
+5
View File
@@ -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.