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,62 @@
/**
* The production wiring that connects a plugin's capability host to the real
* privileged modules (#plugins, M1). Verifies the per-plugin data db is cached,
* a granted db:own call works through the wired host, and trip broadcasts are
* force-namespaced to plugin:{id}:{event}.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const { broadcast, broadcastToUser } = vi.hoisted(() => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ all: () => [], get: () => null }) },
canAccessTrip: (tripId: number, userId: number) => (tripId === 1 && userId === 5 ? { id: 1 } : undefined),
}));
vi.mock('../../../src/websocket', () => ({ broadcast, broadcastToUser }));
import { createRealRpcHost, getPluginDataDb, closePluginDataDb } from '../../../src/nest/plugins/host/create-rpc-host';
let tmp: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-crh-'));
process.env.TREK_PLUGINS_DATA_DIR = tmp;
});
afterAll(() => {
closePluginDataDb('wired');
delete process.env.TREK_PLUGINS_DATA_DIR;
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('create-rpc-host wiring', () => {
it('caches one data db per plugin id', () => {
const a = getPluginDataDb('wired');
const b = getPluginDataDb('wired');
expect(a).toBe(b);
});
it('a granted db:own call runs against the plugin db, and a trip broadcast is namespaced', async () => {
const host = createRealRpcHost('wired', new Set(['db:own', 'ws:broadcast:trip']));
const migrated = await host.dispatch({ k: 'req', id: '1', method: 'db.migrate', params: { id: '001', sql: 'CREATE TABLE t (v TEXT)' } });
expect(migrated.ok).toBe(true);
await host.dispatch({ k: 'req', id: '2', method: 'ws.broadcastToTrip', params: { tripId: 1, event: 'ping', data: { a: 1 } } });
expect(broadcast).toHaveBeenCalledWith(1, 'plugin:wired:ping', { a: 1 });
const bcastUser = createRealRpcHost('wired', new Set(['ws:broadcast:user']));
await bcastUser.dispatch({ k: 'req', id: '3', method: 'ws.broadcastToUser', params: { userId: 7, event: 'hi', data: {} } });
expect(broadcastToUser).toHaveBeenCalledWith(7, { type: 'plugin:wired', event: 'hi' });
});
it('closePluginDataDb closes and drops the cached handle', () => {
getPluginDataDb('transient');
closePluginDataDb('transient');
// a fresh get after close returns a NEW instance (cache was cleared)
const a = getPluginDataDb('transient');
closePluginDataDb('transient');
const b = getPluginDataDb('transient');
expect(a).not.toBe(b);
closePluginDataDb('transient');
});
});
@@ -0,0 +1,56 @@
/**
* The per-plugin sqlite file (#plugins, M1, db:own). Proves migrations are
* idempotent, reads/writes work against the plugin's OWN file, and the guard
* blocks statements that would let a plugin escape its file (ATTACH/PRAGMA).
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { PluginDataDb, removePluginData } from '../../../src/nest/plugins/host/plugin-data.service';
let tmp: string;
beforeAll(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-data-'));
process.env.TREK_PLUGINS_DATA_DIR = tmp;
});
afterAll(() => {
delete process.env.TREK_PLUGINS_DATA_DIR;
fs.rmSync(tmp, { recursive: true, force: true });
});
describe('PluginDataDb', () => {
it('migrates once (idempotent by id), then reads and writes its own data', () => {
const db = new PluginDataDb('notes');
expect(db.migrate('001', 'CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT)').applied).toBe(true);
expect(db.migrate('001', 'CREATE TABLE notes (x)').applied).toBe(false); // same id -> skipped
db.exec('INSERT INTO notes (body) VALUES (?)', ['hello']);
// exec without bound args runs the multi-statement path
db.exec("INSERT INTO notes (body) VALUES ('second')");
const rows = db.query('SELECT body FROM notes ORDER BY id') as Array<{ body: string }>;
expect(rows).toEqual([{ body: 'hello' }, { body: 'second' }]);
db.close();
// The data lives in its own file, not trek.db
expect(fs.existsSync(path.join(tmp, 'notes', 'plugin.db'))).toBe(true);
});
it('rejects statements that would escape the plugin file, and bad sql', () => {
const db = new PluginDataDb('guard');
expect(() => db.exec("ATTACH DATABASE 'trek.db' AS core")).toThrow(/not allowed/);
expect(() => db.query('PRAGMA table_info(x)')).toThrow(/not allowed/);
expect(() => db.exec(123 as unknown as string)).toThrow(/must be a string/);
expect(() => db.query('x'.repeat(100_001))).toThrow(/too long/);
db.close();
});
it('removePluginData deletes the whole data dir', () => {
const db = new PluginDataDb('temp');
db.migrate('001', 'CREATE TABLE t (id INTEGER)');
db.close();
expect(fs.existsSync(path.join(tmp, 'temp'))).toBe(true);
removePluginData('temp');
expect(fs.existsSync(path.join(tmp, 'temp'))).toBe(false);
});
});
@@ -0,0 +1,78 @@
/**
* The child-side SDK (#plugins, M1). Pure plumbing: every ctx method turns into
* an RPC call, config is frozen, and log is a fire-and-forget event. Runs
* in-process against a fake transport (no fork needed).
*/
import { describe, it, expect, vi } from 'vitest';
import { createPluginContext, definePlugin, type ChildTransport } from '../../../src/nest/plugins/runtime/plugin-sdk';
function fakeTransport() {
const rpc = vi.fn(async () => ({ ok: true }));
const emit = vi.fn();
const transport = { rpc, emit } as unknown as ChildTransport;
return { transport, rpc, emit };
}
describe('createPluginContext', () => {
it('maps each ctx method onto the right RPC call', async () => {
const { transport, rpc } = fakeTransport();
const ctx = createPluginContext('p', {}, transport);
await ctx.db.query('SELECT 1', 'a');
expect(rpc).toHaveBeenCalledWith('db.query', { sql: 'SELECT 1', args: ['a'] });
await ctx.db.migrate('001', 'CREATE TABLE t (x)');
expect(rpc).toHaveBeenCalledWith('db.migrate', { id: '001', sql: 'CREATE TABLE t (x)' });
await ctx.trips.getById(1, 42);
expect(rpc).toHaveBeenCalledWith('trips.getById', { tripId: 1, asUserId: 42 });
await ctx.ws.broadcastToTrip(1, 'ping', { a: 1 });
expect(rpc).toHaveBeenCalledWith('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } });
await ctx.users.getById(3);
expect(rpc).toHaveBeenCalledWith('users.getById', { id: 3 });
await ctx.db.exec('DELETE FROM t');
expect(rpc).toHaveBeenCalledWith('db.exec', { sql: 'DELETE FROM t', args: [] });
await ctx.trips.getPlaces(1, 42);
expect(rpc).toHaveBeenCalledWith('trips.getPlaces', { tripId: 1, asUserId: 42 });
await ctx.trips.getReservations(1, 42);
expect(rpc).toHaveBeenCalledWith('trips.getReservations', { tripId: 1, asUserId: 42 });
await ctx.ws.broadcastToUser(9, 'poke', { x: 2 });
expect(rpc).toHaveBeenCalledWith('ws.broadcastToUser', { userId: 9, event: 'poke', data: { x: 2 } });
});
it('log warn and error also emit events', () => {
const { transport, emit } = fakeTransport();
const ctx = createPluginContext('p', {}, transport);
ctx.log.warn('careful');
ctx.log.error('bad', { code: 1 });
expect(emit).toHaveBeenCalledWith('log', { level: 'warn', msg: 'careful', meta: undefined });
expect(emit).toHaveBeenCalledWith('log', { level: 'error', msg: 'bad', meta: { code: 1 } });
});
it('freezes config so a plugin cannot mutate its instance settings object', () => {
const ctx = createPluginContext('p', { api_key: 'secret' }, fakeTransport().transport);
expect(ctx.config.api_key).toBe('secret');
expect(() => {
(ctx.config as Record<string, unknown>).api_key = 'tampered';
}).toThrow();
});
it('log is a fire-and-forget event, not an RPC round-trip', () => {
const { transport, rpc, emit } = fakeTransport();
const ctx = createPluginContext('p', {}, transport);
ctx.log.info('hello', { n: 1 });
expect(emit).toHaveBeenCalledWith('log', { level: 'info', msg: 'hello', meta: { n: 1 } });
expect(rpc).not.toHaveBeenCalled();
});
it('definePlugin returns the definition unchanged', () => {
const def = { onLoad: async () => {} };
expect(definePlugin(def)).toBe(def);
});
});
@@ -0,0 +1,53 @@
/**
* The read-side plugin service + controller (#plugins, M0). Lists installed
* plugins and reports whether the runtime is enabled (TREK_PLUGINS_ENABLED).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
status TEXT, reviewed_at TEXT, source_repo TEXT, sort_order INTEGER DEFAULT 0)`);
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb }));
import { PluginsService } from '../../../src/nest/plugins/plugins.service';
import { PluginsController } from '../../../src/nest/plugins/plugins.controller';
beforeEach(() => {
testDb.exec('DELETE FROM plugins');
delete process.env.TREK_PLUGINS_ENABLED;
});
afterEach(() => {
delete process.env.TREK_PLUGINS_ENABLED;
});
describe('PluginsService.list', () => {
it('returns the installed plugins and the runtime-enabled flag', () => {
testDb
.prepare('INSERT INTO plugins (id, name, description, type, status, version) VALUES (?,?,?,?,?,?)')
.run('flight', 'Flight', 'desc', 'widget', 'inactive', '1.0.0');
process.env.TREK_PLUGINS_ENABLED = 'true';
const out = new PluginsService().list();
expect(out.enabled).toBe(true);
expect(out.plugins).toHaveLength(1);
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
});
it('reports disabled when the kill switch is off (default)', () => {
const out = new PluginsService().list();
expect(out.enabled).toBe(false);
expect(out.plugins).toEqual([]);
});
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
const res = new PluginsController(svc).list();
expect(svc.list).toHaveBeenCalled();
expect(res).toEqual({ enabled: false, plugins: [] });
});
});
@@ -0,0 +1,51 @@
/**
* Small pure helpers of the plugin module (#plugins): permission recognition and
* the code/data path resolution (both the env-override and default branches).
*/
import { describe, it, expect, afterEach } from 'vitest';
import { isKnownPermission, METHOD_PERMISSION, KNOWN_METHODS } from '../../../src/nest/plugins/protocol/envelope';
import path from 'node:path';
import { pluginsCodeRoot, pluginsDataRoot, pluginCodeDir, pluginDbFile, resolveChildEntry } from '../../../src/nest/plugins/paths';
afterEach(() => {
delete process.env.TREK_PLUGINS_DIR;
delete process.env.TREK_PLUGINS_DATA_DIR;
});
describe('envelope helpers', () => {
it('recognises known permissions, http:outbound:<host>, and rejects unknown', () => {
expect(isKnownPermission('db:own')).toBe(true);
expect(isKnownPermission('http:outbound')).toBe(true);
expect(isKnownPermission('http:outbound:api.example.com')).toBe(true);
expect(isKnownPermission('fs:read')).toBe(false);
expect(isKnownPermission('')).toBe(false);
});
it('every known method maps to a permission', () => {
for (const m of KNOWN_METHODS) {
expect(METHOD_PERMISSION[m]).toBeTruthy();
}
});
});
describe('paths', () => {
it('uses the env override when set', () => {
process.env.TREK_PLUGINS_DIR = '/custom/code';
process.env.TREK_PLUGINS_DATA_DIR = '/custom/data';
expect(pluginsCodeRoot()).toBe('/custom/code');
expect(pluginsDataRoot()).toBe('/custom/data');
expect(pluginCodeDir('x')).toBe(path.join('/custom/code', 'x'));
expect(path.basename(pluginDbFile('x'))).toBe('plugin.db');
});
it('falls back to the data-dir default when unset', () => {
expect(pluginsCodeRoot()).toContain('plugins');
expect(pluginsDataRoot()).toContain('plugins-data');
});
it('resolves a child entry with fork args', () => {
const r = resolveChildEntry();
expect(r.entry).toMatch(/plugin-host-entry\.(js|ts)$/);
expect(Array.isArray(r.execArgv)).toBe(true);
});
});
+154
View File
@@ -0,0 +1,154 @@
/**
* The capability router is the plugin permission boundary (#plugins, M1). These
* tests prove that an ungranted method is never reachable, a granted method
* works, a granted trip read is still membership-checked against the acting
* user, and bad params / unknown methods are rejected — without ever spawning a
* child (the router runs in the host).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PluginRpcHost, type HostDeps } from '../../../src/nest/plugins/host/rpc-host';
import type { RpcRequest, RpcResponse, RpcError } from '../../../src/nest/plugins/protocol/envelope';
function makeDeps(): HostDeps {
return {
data: {
query: vi.fn(() => [{ n: 1 }]),
exec: vi.fn(() => ({ changes: 1 })),
migrate: vi.fn(() => ({ applied: true })),
close: vi.fn(),
} as unknown as HostDeps['data'],
db: {
prepare: vi.fn(() => ({
all: () => [{ id: 7, name: 'Place' }],
get: () => ({ id: 3, username: 'ada', display_name: 'Ada', avatar: null }),
})),
},
// trip 1 is accessible to user 42; everything else is not
canAccessTrip: vi.fn((tripId: number, userId: number) => (tripId === 1 && userId === 42 ? { id: 1 } : undefined)),
broadcastToTrip: vi.fn(),
broadcastToUser: vi.fn(),
};
}
const req = (method: string, params: Record<string, unknown> = {}): RpcRequest => ({ k: 'req', id: 'x', method, params });
const ok = (r: RpcResponse | RpcError): r is RpcResponse => r.ok === true;
describe('PluginRpcHost — capability enforcement', () => {
let deps: HostDeps;
beforeEach(() => { deps = makeDeps(); });
it('registers only granted methods; an ungranted method is PERMISSION_DENIED', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
const denied = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }));
expect(denied.ok).toBe(false);
expect((denied as RpcError).error.code).toBe('PERMISSION_DENIED');
expect(deps.canAccessTrip).not.toHaveBeenCalled();
});
it('a granted db:own method runs against the plugin db', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] }));
expect(ok(res)).toBe(true);
expect((res as RpcResponse).result).toEqual([{ n: 1 }]);
expect(deps.data.query).toHaveBeenCalledWith('SELECT 1', []);
});
it('an unknown method is UNKNOWN_METHOD, not PERMISSION_DENIED', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
const res = await host.dispatch(req('fs.readFile', { path: '/etc/passwd' }));
expect((res as RpcError).error.code).toBe('UNKNOWN_METHOD');
});
it('db:read:trips reads a trip the acting user can access', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 42 }));
expect(ok(res)).toBe(true);
expect((res as RpcResponse).result).toEqual({ id: 1 });
});
it('db:read:trips is still RESOURCE_FORBIDDEN when the user is not a member', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
const res = await host.dispatch(req('trips.getById', { tripId: 1, asUserId: 99 }));
expect((res as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
});
it('trips.getPlaces is membership-checked before the core read', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
const forbidden = await host.dispatch(req('trips.getPlaces', { tripId: 2, asUserId: 42 }));
expect((forbidden as RpcError).error.code).toBe('RESOURCE_FORBIDDEN');
expect(deps.db.prepare).not.toHaveBeenCalled();
const allowed = await host.dispatch(req('trips.getPlaces', { tripId: 1, asUserId: 42 }));
expect(ok(allowed)).toBe(true);
expect((allowed as RpcResponse).result).toEqual([{ id: 7, name: 'Place' }]);
});
it('db:read:users returns only the public projection', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:users']), deps);
const res = await host.dispatch(req('users.getById', { id: 3 }));
expect(ok(res)).toBe(true);
expect((res as RpcResponse).result).toEqual({ id: 3, username: 'ada', display_name: 'Ada', avatar: null });
// the SELECT column list is host-controlled — no password/token columns
expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('id, username, display_name, avatar'));
});
it('ws:broadcast:trip forwards to the (namespaced) broadcaster', async () => {
const host = new PluginRpcHost('p', new Set(['ws:broadcast:trip']), deps);
const res = await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: { a: 1 } }));
expect(ok(res)).toBe(true);
expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { a: 1 });
});
it('bad params are BAD_PARAMS', async () => {
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
const res = await host.dispatch(req('db.query', { args: [] }));
expect((res as RpcError).error.code).toBe('BAD_PARAMS');
});
it('with no permissions, every real method is denied', async () => {
const host = new PluginRpcHost('p', new Set<string>(), deps);
for (const m of ['db.query', 'trips.getById', 'users.getById', 'ws.broadcastToTrip']) {
const res = await host.dispatch(req(m, { tripId: 1, asUserId: 42, id: 3, event: 'x', sql: 'SELECT 1' }));
expect((res as RpcError).error.code).toBe('PERMISSION_DENIED');
}
});
it('ws:broadcast:user forwards to the per-user broadcaster', async () => {
const host = new PluginRpcHost('p', new Set(['ws:broadcast:user']), deps);
const res = await host.dispatch(req('ws.broadcastToUser', { userId: 9, event: 'poke', data: { x: 2 } }));
expect(ok(res)).toBe(true);
expect(deps.broadcastToUser).toHaveBeenCalledWith(9, { event: 'poke', x: 2 });
});
it('non-array db args are BAD_PARAMS; a primitive ws payload is wrapped', async () => {
const host = new PluginRpcHost('p', new Set(['db:own', 'ws:broadcast:trip']), deps);
const bad = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: 'nope' }));
expect((bad as RpcError).error.code).toBe('BAD_PARAMS');
await host.dispatch(req('ws.broadcastToTrip', { tripId: 1, event: 'ping', data: 'primitive' }));
expect(deps.broadcastToTrip).toHaveBeenCalledWith(1, 'ping', { value: 'primitive' });
});
it('dispose() closes the plugin data db', () => {
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
host.dispose();
expect(deps.data.close).toHaveBeenCalled();
});
it('trips.getReservations is membership-checked and reads reservations', async () => {
const host = new PluginRpcHost('p', new Set(['db:read:trips']), deps);
const res = await host.dispatch(req('trips.getReservations', { tripId: 1, asUserId: 42 }));
expect(ok(res)).toBe(true);
expect(deps.db.prepare).toHaveBeenCalledWith(expect.stringContaining('FROM reservations'));
});
it('an error thrown by a handler becomes HOST_ERROR', async () => {
(deps.data.query as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('disk gone');
});
const host = new PluginRpcHost('p', new Set(['db:own']), deps);
const res = await host.dispatch(req('db.query', { sql: 'SELECT 1', args: [] }));
expect((res as RpcError).error.code).toBe('HOST_ERROR');
expect((res as RpcError).error.message).toBe('disk gone');
});
});