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
parent 252eae368d
commit 14b348581a
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);
}
}