refactor(plugins): install-time gates, the capability-audit hash chain and the registry through repositories

This commit is contained in:
jubnl
2026-09-24 03:24:23 +02:00
parent f29216c5af
commit d522882590
30 changed files with 834 additions and 313 deletions
+50 -40
View File
@@ -2,15 +2,28 @@
* Plugin discovery (#plugins, M4, install-from-disk): scans the volume, upserts
* rows as inactive, refreshes settings fields, keeps an existing plugin's status,
* and skips invalid or native-carrying plugins (logging the reason).
*
* Plan 3j Task 3 — converted onto `PluginsRepository`/`PluginActionsRepository`/
* `PluginSettingsFieldsRepository`/`PluginErrorLogRepository` (a `DiscoveryRepos`
* bundle), via a real MikroORM over the full migrated schema (`createSnapshotTestDb`
* + `createTestOrm`) rather than a hand-rolled `:memory:` table set.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Database from 'better-sqlite3';
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { discoverPlugins } from '../../../src/nest/plugins/install/discovery';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import { PluginActions } from '../../../src/db/entities/PluginActions.entity';
import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity';
import { PluginErrorLog } from '../../../src/db/entities/PluginErrorLog.entity';
import { discoverPlugins, type DiscoveryRepos } from '../../../src/nest/plugins/install/discovery';
let db: Database.Database;
const testDb = createSnapshotTestDb();
let t: TestOrm;
let repos: DiscoveryRepos;
let codeRoot: string;
function writePlugin(id: string, manifest: Record<string, unknown>, extra?: () => void) {
@@ -21,24 +34,21 @@ function writePlugin(id: string, manifest: Record<string, unknown>, extra?: () =
extra?.()
}
beforeAll(async () => {
t = await createTestOrm(testDb);
repos = { plugins: t.repo(Plugins), actions: t.repo(PluginActions), settingsFields: t.repo(PluginSettingsFields), errorLog: t.repo(PluginErrorLog) };
});
beforeEach(() => {
resetTestDb(testDb);
t.clear();
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'disc-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
db = new Database(':memory:');
db.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT,
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER);
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, scope TEXT NOT NULL DEFAULT 'user', sort_order INTEGER,
PRIMARY KEY (plugin_id, action_key));
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
});
afterEach(() => {
delete process.env.TREK_PLUGINS_DIR;
db.close();
fs.rmSync(codeRoot, { recursive: true, force: true });
});
afterAll(async () => { await t.close(); testDb.close(); });
describe('discoverPlugins', () => {
it('registers a new plugin inactive with its settings fields', async () => {
@@ -52,15 +62,15 @@ describe('discoverPlugins', () => {
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
],
});
const res = await discoverPlugins(db);
const res = await discoverPlugins(repos);
expect(res.discovered).toEqual(['flight-tracker']);
const row = db.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string };
const row = testDb.prepare("SELECT status, type, permissions FROM plugins WHERE id='flight-tracker'").get() as { status: string; type: string; permissions: string };
expect(row.status).toBe('inactive');
expect(row.type).toBe('widget');
expect(JSON.parse(row.permissions)).toEqual(['db:own']);
const field = db.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number };
const field = testDb.prepare("SELECT field_key, secret FROM plugin_settings_fields WHERE plugin_id='flight-tracker'").get() as { field_key: string; secret: number };
expect(field).toMatchObject({ field_key: 'api_key', secret: 1 });
});
@@ -69,8 +79,8 @@ describe('discoverPlugins', () => {
name: 'Acts', type: 'integration', permissions: [],
actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }],
});
await discoverPlugins(db);
const rows = db.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all();
await discoverPlugins(repos);
const rows = testDb.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all();
expect(rows).toEqual([
{ action_key: 'ping', scope: 'user', danger: 0 },
{ action_key: 'purge', scope: 'instance', danger: 1 },
@@ -78,10 +88,10 @@ describe('discoverPlugins', () => {
});
it('keeps an existing plugin status + granted permissions on re-discovery', async () => {
db.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run();
testDb.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run();
writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' });
await discoverPlugins(db);
const row = db.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string };
await discoverPlugins(repos);
const row = testDb.prepare("SELECT status, version, granted_permissions FROM plugins WHERE id='keep'").get() as { status: string; version: string; granted_permissions: string };
expect(row.status).toBe('active'); // not downgraded
expect(row.version).toBe('2.0.0'); // metadata refreshed
expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved
@@ -90,23 +100,23 @@ describe('discoverPlugins', () => {
it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', async () => {
writePlugin('bom-plug', { type: 'integration' });
const mp = path.join(codeRoot, 'bom-plug', 'trek-plugin.json');
fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8'));
expect((await discoverPlugins(db)).discovered).toEqual(['bom-plug']);
fs.writeFileSync(mp, '' + fs.readFileSync(mp, 'utf8'));
expect((await discoverPlugins(repos)).discovered).toEqual(['bom-plug']);
});
it('skips an invalid manifest and logs the reason', async () => {
writePlugin('bad', { type: 'not-a-type' });
const res = await discoverPlugins(db);
const res = await discoverPlugins(repos);
expect(res.skipped).toEqual(['bad']);
expect(db.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 });
expect((db.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery');
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='bad'").get()).toMatchObject({ c: 0 });
expect((testDb.prepare("SELECT message FROM plugin_error_log WHERE plugin_id='bad'").get() as { message: string }).message).toContain('discovery');
});
it('skips a plugin that ships native binaries', async () => {
writePlugin('native', { type: 'integration' }, () => {
fs.writeFileSync(path.join(codeRoot, 'native', 'server', 'addon.node'), '\0');
});
expect((await discoverPlugins(db)).skipped).toEqual(['native']);
expect((await discoverPlugins(repos)).skipped).toEqual(['native']);
});
it('follows a symlinked dev-link plugin only when dev-link mode is on', async () => {
@@ -121,13 +131,13 @@ describe('discoverPlugins', () => {
// Off (default): a stale dev-link symlink is not discovered or registered.
delete process.env.TREK_PLUGINS_DEV_LINK;
expect((await discoverPlugins(db)).discovered).toEqual([]);
expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined();
expect((await discoverPlugins(repos)).discovered).toEqual([]);
expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toBeUndefined();
// On: the dev-link is followed and registered inactive.
process.env.TREK_PLUGINS_DEV_LINK = '1';
expect((await discoverPlugins(db)).discovered).toEqual(['linked']);
expect(db.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' });
expect((await discoverPlugins(repos)).discovered).toEqual(['linked']);
expect(testDb.prepare("SELECT status FROM plugins WHERE id='linked'").get()).toMatchObject({ status: 'inactive' });
} finally {
if (prev === undefined) delete process.env.TREK_PLUGINS_DEV_LINK; else process.env.TREK_PLUGINS_DEV_LINK = prev;
fs.rmSync(srcRoot, { recursive: true, force: true });
@@ -136,14 +146,14 @@ describe('discoverPlugins', () => {
it('is a no-op when the plugins dir is absent', async () => {
process.env.TREK_PLUGINS_DIR = path.join(codeRoot, 'does-not-exist');
expect(await discoverPlugins(db)).toEqual({ discovered: [], skipped: [] });
expect(await discoverPlugins(repos)).toEqual({ discovered: [], skipped: [] });
});
describe('the TREK range', () => {
it('persists the range and its lower bound', async () => {
writePlugin('ranged', { trek: '>=3.2.0 <4.0.0' });
await discoverPlugins(db);
expect(db.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
await discoverPlugins(repos);
expect(testDb.prepare("SELECT trek_range, min_trek_version FROM plugins WHERE id='ranged'").get())
.toMatchObject({ trek_range: '>=3.2.0 <4.0.0', min_trek_version: '3.2.0' });
});
@@ -153,16 +163,16 @@ describe('discoverPlugins', () => {
// enabled=1 row to be spawned by the next boot — invisible AND running. The row is
// registered with a null range and the activation gate refuses it (TREK_VERSION_UNKNOWN).
writePlugin('rangeless', {});
expect((await discoverPlugins(db)).discovered).toEqual(['rangeless']);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
expect((await discoverPlugins(repos)).discovered).toEqual(['rangeless']);
expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='rangeless'").get()).toMatchObject({ trek_range: null });
});
it('refreshes the range on re-discovery, so a plugin that narrowed its support is caught', async () => {
writePlugin('shrink', { trek: '>=3.0.0' });
await discoverPlugins(db);
await discoverPlugins(repos);
writePlugin('shrink', { trek: '>=3.0.0 <3.1.0' });
await discoverPlugins(db);
expect(db.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
await discoverPlugins(repos);
expect(testDb.prepare("SELECT trek_range FROM plugins WHERE id='shrink'").get()).toMatchObject({ trek_range: '>=3.0.0 <3.1.0' });
});
});
});
+36 -2
View File
@@ -73,6 +73,7 @@ import type { NotificationsService } from '../../../src/nest/notifications/notif
import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver';
import type { PluginOAuthService } from '../../../src/nest/plugins/oauth/plugin-oauth.service';
import type { RpcError, RpcResponse } from '../../../src/nest/plugins/protocol/envelope';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
// Typed from the real method rather than from the always-true body below, so a case that
// swaps in an implementation reading the action key (HOSTRPC-015) still type-checks.
@@ -96,6 +97,39 @@ const userSettings = {
readOne: vi.fn((_pid: string, uid: number, key: string) => (uid === 5 && key === 'apiKey' ? 'k-5' : undefined)),
} as unknown as PluginUserSettingsService;
// Plan 3j Task 3: `budgetFor`/`appendAudit` now take `PluginCapabilityAuditRepository`,
// not a raw connection. `plugin_capability_audit`'s hand-rolled table above (unlike
// `trips`/`users`) matches the real entity's full column set, so a real MikroORM
// repository would work here too — but every other cross-cutting dependency in this
// file (`canAccessTrip`, `getRole`) is a synchronous stub against this same `mockDb`
// for the same reason (no top-level await in this file), so this stays consistent:
// real raw SQL against `mockDb`, wrapped in the repository's own method shapes.
// HOSTRPC-031 seeds real rows through `mockDb.prepare(...)` and reads them back
// through `budgetSeed`, so this must be backed by the real table, not a canned stub.
const pluginAuditRepo = {
async budgetSeed(pluginId: string, since: string) {
return (mockDb as unknown as { prepare(s: string): { all(...a: unknown[]): unknown[] } })
.prepare(
"SELECT method, COUNT(*) AS n FROM plugin_capability_audit WHERE plugin_id = ? AND code = 'ok' AND ts >= ? AND method IN ('ai.complete','ai.extract','notify.send') GROUP BY method",
)
.all(pluginId, since) as Array<{ method: string; n: number }>;
},
async lastHash(pluginId: string) {
const row = (mockDb as unknown as { prepare(s: string): { get(...a: unknown[]): unknown } })
.prepare('SELECT hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id DESC LIMIT 1')
.get(pluginId) as { hash: string } | undefined;
return row?.hash ?? null;
},
async insertRow(entry: { plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }) {
(mockDb as unknown as { prepare(s: string): { run(...a: unknown[]): unknown } })
.prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)')
.run(entry.plugin_id, entry.acting_user_id, entry.method, entry.resource, entry.code, entry.ts, entry.prev_hash, entry.hash);
},
async pruneKeepingNewest() {
// Not exercised in this suite (PRUNE_EVERY = 500 appends/plugin) — a no-op stub.
},
} as unknown as PluginCapabilityAuditRepository;
const dbs = new DatabaseService(mockDb);
// Plan 3c Task 0b: `canAccessTrip` is `TripsRepository.findAccessible` now,
// which needs a real EntityManager over the full migrated schema — this
@@ -123,9 +157,9 @@ const guards = new PluginGuards(dbs, permissions, addons, usersRepo);
const registry = createTestPluginRegistry([
new DbRpc(userSettings),
new MetaRpc(dbs, guards),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards),
new HostSurfaceRpc(dbs, new RealtimeService(), notifications, llmConfig, oauth, guards, pluginAuditRepo),
]);
const factory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
const factory = new PluginRpcHostFactory(pluginAuditRepo, registry as unknown as PluginRpcRegistryService);
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: async () => {} };
const makeHost = (id: string, ...perms: string[]) => factory.create(id, new Set(perms), stubRouter);
/**
+128 -43
View File
@@ -1,18 +1,59 @@
/**
* Host-side capability audit log (#plugins, L1 hardening): resource projection,
* which methods are auditable, and the per-plugin hash chain.
*
* Plan 3j Task 3 — converted onto `PluginCapabilityAuditRepository`. R-hash-chain:
* `legacyAppendAudit` below is a byte-for-byte replica of the PRE-CONVERSION
* `appendAudit` body (a raw `better-sqlite3` INSERT, the exact hash construction
* this task's `plugin-audit.ts` now reproduces through the repository) — it exists
* SOLELY so these tests can produce a "legacy-written" chain to replay against the
* converted code, without keeping a second, unused copy of the production function
* around.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { appendAudit, readAudit, readAuditForUser, auditResource, isAuditable, pruneAudit } from '../../../src/nest/plugins/host/plugin-audit';
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import crypto from 'node:crypto';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
import { appendAudit, readAudit, readAuditForUser, auditResource, isAuditable, pruneAudit, verifyChain } from '../../../src/nest/plugins/host/plugin-audit';
function makeDb() {
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugin_capability_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, acting_user_id INTEGER,
method TEXT NOT NULL, resource TEXT, code TEXT NOT NULL, ts TEXT NOT NULL,
prev_hash TEXT, hash TEXT NOT NULL);`);
return db;
const testDb = createSnapshotTestDb();
let t: TestOrm;
let audit: PluginCapabilityAuditRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
audit = t.repo(PluginCapabilityAudit);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
interface LegacyEntry {
pluginId: string;
actingUserId?: number;
method: string;
resource?: string | null;
code: string;
}
/** The pre-conversion `appendAudit` body, verbatim, against a raw connection. */
function legacyAppendAudit(prevHash: string, e: LegacyEntry): { ts: string; hash: string } {
const ts = new Date().toISOString();
const row = JSON.stringify([e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts]);
const hash = crypto.createHash('sha256').update(prevHash + row).digest('hex');
testDb
.prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)')
.run(e.pluginId, e.actingUserId ?? null, e.method, e.resource ?? null, e.code, ts, prevHash || null, hash);
return { ts, hash };
}
/** Every row for a plugin, oldest first — the shape `verifyChain` needs. */
function chainRows(pluginId: string) {
return testDb
.prepare('SELECT plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = ? ORDER BY id ASC')
.all(pluginId) as Array<{ plugin_id: string; acting_user_id: number | null; method: string; resource: string | null; code: string; ts: string; prev_hash: string | null; hash: string }>;
}
describe('auditResource + isAuditable', () => {
@@ -40,14 +81,11 @@ describe('auditResource + isAuditable', () => {
});
});
describe('appendAudit hash chain', () => {
let db: ReturnType<typeof makeDb>;
beforeEach(() => { db = makeDb(); });
describe('appendAudit hash chain (converted, through PluginCapabilityAuditRepository)', () => {
it('chains each entry off the previous hash (per plugin)', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
const rows = db.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>;
await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:2', code: 'ok' });
const rows = testDb.prepare('SELECT prev_hash, hash FROM plugin_capability_audit ORDER BY id').all() as Array<{ prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(2);
expect(rows[0].prev_hash).toBeNull();
expect(rows[1].prev_hash).toBe(rows[0].hash); // chain links
@@ -55,52 +93,99 @@ describe('appendAudit hash chain', () => {
});
it('keeps separate chains per plugin', async () => {
await appendAudit(db, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const a = db.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null };
expect(a.prev_hash).toBeNull(); // b's first entry doesn't chain off a's
await appendAudit(audit, { pluginId: 'a', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(audit, { pluginId: 'b', method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const b = testDb.prepare("SELECT prev_hash FROM plugin_capability_audit WHERE plugin_id='b'").get() as { prev_hash: string | null };
expect(b.prev_hash).toBeNull(); // b's first entry doesn't chain off a's
});
it('records denials too (code is the error code)', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = (await readAudit(db, 'p'))[0] as { code: string; method: string };
await appendAudit(audit, { pluginId: 'p', actingUserId: 99, method: 'trips.getById', resource: 'trip:1', code: 'RESOURCE_FORBIDDEN' });
const row = (await readAudit(audit, 'p'))[0];
expect(row.code).toBe('RESOURCE_FORBIDDEN');
});
it('readAuditForUser returns one user\'s actions across ALL plugins, newest first, with the plugin name', async () => {
db.exec("CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT)");
db.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run();
await appendAudit(db, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' });
await appendAudit(db, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user
const mine = (await readAuditForUser(db, 42)) as Array<{ plugin_id: string; plugin_name: string; method: string }>;
expect(mine).toHaveLength(2); // only user 42's rows, across both plugins
it("readAuditForUser returns one user's actions across ALL plugins, newest first, with the plugin name", async () => {
testDb.prepare("INSERT INTO plugins (id, name) VALUES ('koffi','Koffi'), ('flight','Flight Tracker')").run();
await appendAudit(audit, { pluginId: 'koffi', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
await appendAudit(audit, { pluginId: 'flight', actingUserId: 42, method: 'reservations.create', resource: 'trip:1', code: 'ok' });
await appendAudit(audit, { pluginId: 'koffi', actingUserId: 99, method: 'trips.getById', resource: 'trip:2', code: 'ok' }); // another user
const mine = await readAuditForUser(audit, 42);
expect(mine).toHaveLength(2); // only user 42's rows, across both plugins
expect(mine[0]).toMatchObject({ plugin_id: 'flight', plugin_name: 'Flight Tracker', method: 'reservations.create' }); // newest first
expect(mine[1]).toMatchObject({ plugin_id: 'koffi', plugin_name: 'Koffi' });
expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && (r as { resource?: string }).resource === 'trip:2')).toBe(false); // never another user's
expect(mine.some((r) => r.plugin_id === 'koffi' && r.method === 'trips.getById' && r.resource === 'trip:2')).toBe(false); // never another user's
});
it('readAudit returns newest first with the projected fields only', async () => {
await appendAudit(db, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = (await readAudit(db, 'p')) as Array<Record<string, unknown>>;
await appendAudit(audit, { pluginId: 'p', actingUserId: 42, method: 'trips.getById', resource: 'trip:1', code: 'ok' });
const rows = await readAudit(audit, 'p');
expect(rows[0]).toHaveProperty('method', 'trips.getById');
expect(rows[0]).not.toHaveProperty('prev_hash'); // internal chain fields not exposed
});
it('pruneAudit keeps only the newest N rows per plugin, leaving the retained window chain-consistent', async () => {
for (let i = 0; i < 50; i++) await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
await appendAudit(db, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' });
await pruneAudit(db, 'p', 10);
const rows = db.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>;
for (let i = 0; i < 50; i++) await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
await appendAudit(audit, { pluginId: 'other', actingUserId: 1, method: 'trips.getById', resource: 'trip:x', code: 'ok' });
await pruneAudit(audit, 'p', 10);
const rows = testDb.prepare("SELECT resource, prev_hash, hash FROM plugin_capability_audit WHERE plugin_id = 'p' ORDER BY id ASC").all() as Array<{ resource: string; prev_hash: string | null; hash: string }>;
expect(rows).toHaveLength(10);
expect(rows[rows.length - 1].resource).toBe('trip:49'); // newest kept
// each retained row is still self-consistent: hash === sha256(prev_hash + row-content)
// (proven by re-appending on top — the chain continues from the surviving tip)
await appendAudit(db, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' });
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
await appendAudit(audit, { pluginId: 'p', actingUserId: 1, method: 'trips.getById', resource: 'trip:new', code: 'ok' });
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
// pruning one plugin never touches another's rows
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 });
await expect(pruneAudit(db, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op
expect(db.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='other'").get()).toMatchObject({ c: 1 });
await expect(pruneAudit(audit, 'p', 0)).resolves.toBeUndefined(); // 0 = disabled, no-op
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_capability_audit WHERE plugin_id='p'").get()).toMatchObject({ c: 11 });
});
});
describe('R-hash-chain: replay, extension, mutation', () => {
it('AUDITCHAIN-001 REPLAY+EXTENSION: verifies a legacy-produced chain, then extends it through the converted appendAudit and the WHOLE chain stays consistent', async () => {
// 1. Write three rows the way the PRE-CONVERSION code did: raw SQL, no repository.
let prev = '';
for (let i = 0; i < 3; i++) {
const { hash } = legacyAppendAudit(prev, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: `trip:${i}`, code: 'ok' });
prev = hash;
}
// 2. REPLAY: the converted verification logic (verifyChain) accepts the legacy-written rows.
expect(verifyChain(chainRows('legacy-p'))).toBe(true);
// 3. EXTENSION: append two more rows through the CONVERTED appendAudit, on top of the
// legacy tip — the repository reads the legacy row's hash as its own prev_hash.
await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:3', code: 'ok' });
await appendAudit(audit, { pluginId: 'legacy-p', actingUserId: 7, method: 'trips.getById', resource: 'trip:4', code: 'ok' });
const rows = chainRows('legacy-p');
expect(rows).toHaveLength(5);
expect(rows[3].prev_hash).toBe(rows[2].hash); // the converted append linked onto the legacy tip
// 4. The WHOLE chain (3 legacy rows + 2 converted rows) verifies end to end.
expect(verifyChain(rows)).toBe(true);
});
it('AUDITCHAIN-002 MUTATION: a row whose stored hash omits one field from the hash input fails verification', async () => {
// A row constructed EXACTLY like appendAudit's real one, except `resource` is
// dropped from the JSON array fed to sha256 — the drift R-hash-chain warns
// never errors anywhere on write, only on a verification pass.
const pluginId = 'mutant';
const actingUserId = 1;
const method = 'trips.getById';
const resource = 'trip:1';
const code = 'ok';
const ts = new Date().toISOString();
const mutatedRow = JSON.stringify([pluginId, actingUserId, method, code, ts]); // resource OMITTED
const mutatedHash = crypto.createHash('sha256').update('' + mutatedRow).digest('hex');
testDb
.prepare('INSERT INTO plugin_capability_audit (plugin_id, acting_user_id, method, resource, code, ts, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?)')
.run(pluginId, actingUserId, method, resource, code, ts, null, mutatedHash);
expect(verifyChain(chainRows(pluginId))).toBe(false);
// Control: the same row, hashed with the REAL (resource-included) construction, verifies.
testDb.prepare('DELETE FROM plugin_capability_audit WHERE plugin_id = ?').run(pluginId);
legacyAppendAudit('', { pluginId, actingUserId, method, resource, code });
expect(verifyChain(chainRows(pluginId))).toBe(true);
});
});
@@ -0,0 +1,69 @@
/**
* HS1 (`plugin-host-state.ts#budgetFor`'s seed read) — Plan 3j Task 3. Converted
* from a raw `better-sqlite3` connection to `PluginCapabilityAuditRepository`;
* this covers the seed's grouping (ai.complete + ai.extract vs notify.send),
* the UTC-day floor, and the `code = 'ok'` filter (a denied call never counts
* against the budget).
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from 'vitest';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
import type { PluginCapabilityAuditRepository } from '../../../src/db/repositories/PluginCapabilityAudit.repository';
import { appendAudit } from '../../../src/nest/plugins/host/plugin-audit';
import { budgetFor, pluginBudgetUsage } from '../../../src/nest/plugins/host/plugin-host-state';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let audit: PluginCapabilityAuditRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
audit = t.repo(PluginCapabilityAudit);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterEach(() => { vi.restoreAllMocks(); });
afterAll(async () => { await t.close(); testDb.close(); });
// `budgetFor`'s `budgets` Map is deliberately module-level (see plugin-host-state.ts's
// class docstring) — it is NOT reset by `resetTestDb`/`t.clear()`, so each test below
// uses its own plugin id to avoid one test's seed being served (cached) to the next.
describe('budgetFor / pluginBudgetUsage seed read', () => {
it('HS-001: seeds ai (ai.complete + ai.extract) and notify counts separately from today\'s already-audited rows', async () => {
await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' });
await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'ai.extract', resource: null, code: 'ok' });
await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'notify.send', resource: null, code: 'ok' });
// A denied call must not count against the budget.
await appendAudit(audit, { pluginId: 'hs1', actingUserId: 1, method: 'ai.complete', resource: null, code: 'RESOURCE_FORBIDDEN' });
// A different plugin's calls must not bleed into this one's budget.
await appendAudit(audit, { pluginId: 'hs1-other', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' });
const usage = await pluginBudgetUsage('hs1', audit);
expect(usage.ai).toBe(2);
expect(usage.notify).toBe(1);
});
it('HS-002: seeded only once per plugin per process lifetime — a later append is NOT re-counted on a second read', async () => {
await appendAudit(audit, { pluginId: 'hs2', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' });
const first = await budgetFor('hs2', audit);
expect(first.used(Date.now()).ai).toBe(1);
// A direct .take() call (what a real dispatch does) advances the in-memory
// counter; the seed read itself is not re-run.
await appendAudit(audit, { pluginId: 'hs2', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' });
const second = await budgetFor('hs2', audit);
expect(second).toBe(first); // same cached DailyBudget instance — no re-seed
expect(second.used(Date.now()).ai).toBe(1); // still 1: the second append was never taken through the budget
});
it("HS-003: ignores yesterday's rows — only today's UTC window seeds the count", async () => {
await appendAudit(audit, { pluginId: 'hs3', actingUserId: 1, method: 'ai.complete', resource: null, code: 'ok' });
// Backdate the row to yesterday, bypassing the repository (simulating a
// real prior day's audit history already on disk).
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
testDb.prepare("UPDATE plugin_capability_audit SET ts = ? WHERE plugin_id = 'hs3'").run(yesterday);
const usage = await pluginBudgetUsage('hs3', audit);
expect(usage.ai).toBe(0);
});
});
@@ -106,7 +106,9 @@ async function installFixturePlugin(opts: { settings: Array<Record<string, unkno
JSON.stringify({ id: 'fixture-id', name: 'Fixture', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0', settings: opts.settings }),
);
fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}');
await discoverPlugins(testDb);
// Plan 3j Task 3: `discoverPlugins` takes a `DiscoveryRepos` bundle now, not a raw
// connection — the same repositories this file already resolves via `sharedTestOrm`.
await discoverPlugins({ plugins: pluginsRepo, actions: pluginActionsRepo, settingsFields: pluginSettingsFieldsRepo, errorLog: pluginErrorLogRepo });
}
beforeAll(async () => {
@@ -0,0 +1,51 @@
/**
* SV4 (R-survivors) — `declaredProfiles`, Plan 3j Task 3's conversion of the one
* literal `SELECT capabilities FROM plugins WHERE id = ?` backing all three
* external callers (`roadtrip-router.service.ts`'s RRT1/RRT2,
* `plugin-routes.controller.ts`), now via `PluginsRepository#findCapabilities`
* (PR52/PR53 — the same method `plugin-runtime.service.ts`'s
* `capabilityList`/`mcpToolCapabilities` use for the same column).
*/
import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest';
import { createSnapshotTestDb } from '../../helpers/db-mock';
import { resetTestDb } from '../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
import { Plugins } from '../../../src/db/entities/Plugins.entity';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
import { declaredProfiles } from '../../../src/nest/plugins/contributions/plugin-route-normalize';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let plugins: PluginsRepository;
beforeAll(async () => {
t = await createTestOrm(testDb);
plugins = t.repo(Plugins);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
function seedPlugin(id: string, capabilities: unknown): void {
testDb.prepare('INSERT INTO plugins (id, name, capabilities) VALUES (?, ?, ?)').run(id, id, JSON.stringify(capabilities));
}
describe('declaredProfiles', () => {
it('RN-001: returns the declared routeProfiles ids, filtered to the safe id pattern', async () => {
seedPlugin('roadtrip-plugin', { routeProfiles: [{ id: 'eco' }, { id: 'fast-ev' }, { id: 'BAD ID' }, { id: '' }] });
expect(await declaredProfiles(plugins, 'roadtrip-plugin')).toEqual(['eco', 'fast-ev']);
});
it('RN-002: no routeProfiles capability → empty list', async () => {
seedPlugin('no-routes', { widget: { slot: 'sidebar' } });
expect(await declaredProfiles(plugins, 'no-routes')).toEqual([]);
});
it('RN-003: a missing plugin row → empty list, never throws', async () => {
expect(await declaredProfiles(plugins, 'does-not-exist')).toEqual([]);
});
it('RN-004: malformed capabilities JSON → empty list, never throws', async () => {
testDb.prepare('INSERT INTO plugins (id, name, capabilities) VALUES (?, ?, ?)').run('bad-json', 'bad-json', 'not json');
expect(await declaredProfiles(plugins, 'bad-json')).toEqual([]);
});
});
@@ -21,6 +21,7 @@ vi.mock('../../../src/nest/plugins/kill-switch', () => ({ pluginsEnabled }));
import { PluginRoutesController } from '../../../src/nest/plugins/contributions/plugin-routes.controller';
import type { PluginHooks } from '../../../src/nest/plugins/plugin-hooks.service';
import type { PluginsRepository } from '../../../src/db/repositories/Plugins.repository';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const req = (id?: number) => ({ user: id === undefined ? undefined : { id } }) as any;
@@ -29,7 +30,13 @@ function controller(invoke: () => unknown, providers = ['ev-plug']) {
providersOf: vi.fn(() => providers),
route: vi.fn(async () => invoke()),
} as unknown as PluginHooks;
return { c: new PluginRoutesController(runtime, { canAccessTrip, connection: { prepare: () => ({ get: () => (capabilitiesRow.value === undefined ? undefined : { capabilities: capabilitiesRow.value }) }) } } as unknown as DatabaseService), runtime };
// Plan 3j Task 3: `declaredProfiles` now takes `PluginsRepository`, not the raw
// connection — `findCapabilities` reads the same `capabilitiesRow` fixture the
// old `connection.prepare(...).get()` stub above read.
const plugins = {
findCapabilities: vi.fn(async () => (capabilitiesRow.value === undefined ? null : capabilitiesRow.value)),
} as unknown as PluginsRepository;
return { c: new PluginRoutesController(runtime, { canAccessTrip } as unknown as DatabaseService, plugins), runtime };
}
const wp = (n = 3) => Array.from({ length: n }, (_, i) => ({ lat: 48 + i * 0.1, lng: 2 + i * 0.1 }));
const goodRoute = (n = 3, over: Record<string, unknown> = {}) => ({