mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
14b348581a
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.
63 lines
2.7 KiB
TypeScript
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');
|
|
});
|
|
});
|