mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user