Files
TREK/server/tests/unit/plugins/create-rpc-host.test.ts
T
mauriceboe 14b348581a 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.
2026-07-03 16:18:55 +02:00

63 lines
2.7 KiB
TypeScript

/**
* 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');
});
});