Release branch for 4.0.0.
This commit is contained in:
jubnl
2026-08-27 13:22:39 +02:00
committed by GitHub
parent e60427f813
commit 77dddd5e8a
3355 changed files with 403744 additions and 67215 deletions
@@ -0,0 +1,142 @@
/**
* Boot-order regression (#plugins): the RPC registry must be scanned before the
* runtime auto-activates the plugins an admin had enabled.
*
* The bug this pins: PluginRuntimeService booted enabled plugins from
* onModuleInit, and Nest fires same-module onModuleInit hooks in providers-array
* declaration order — where the runtime service is listed BEFORE
* PluginRpcRegistryService. Each boot activation builds its PluginRpcHost
* synchronously (bindInto snapshots the registry at construction), so every host
* was bound from a STILL-EMPTY registry: the plugin's first RPC on activation
* (typically ctx.db.migrate in onLoad) came back PERMISSION_DENIED and the plugin
* landed in error state on every cold boot, until an admin deactivated and
* reactivated it by hand. The fix moves the boot reconcile to
* onApplicationBootstrap, which Nest guarantees runs after EVERY module's
* onModuleInit — so this suite assembles a real Nest module mirroring the
* production declaration order (runtime provider first, registry scan later) and
* asserts the plugin still comes up clean.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { Test } from '@nestjs/testing';
import type { TestingModule } from '@nestjs/testing';
const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, api_version INTEGER DEFAULT 1, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT,
trek_range TEXT DEFAULT '>=3.0.0',
source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);
CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT);
CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
CREATE TABLE plugin_meta_migrations (plugin_id TEXT, migration_id TEXT, PRIMARY KEY (plugin_id, migration_id));
CREATE TABLE plugin_capability_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, acting_user_id INTEGER, method TEXT, resource TEXT, code TEXT, ts TEXT, prev_hash TEXT, hash TEXT);
CREATE TABLE plugin_scheduled_tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, name TEXT NOT NULL, due_at INTEGER NOT NULL, payload TEXT NOT NULL DEFAULT 'null', every_ms INTEGER, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, name));
CREATE TABLE plugin_user_erasure_queue (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT NOT NULL, user_id INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now')), UNIQUE(plugin_id, user_id));
CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER, action TEXT NOT NULL, resource TEXT, details TEXT, ip TEXT);`);
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AuditService } from '../../../src/nest/audit/audit.service';
import { AddonsService } from '../../../src/nest/addons/addons.service';
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
import { PluginRpcHostFactory } from '../../../src/nest/plugins/host/plugin-rpc-host.factory';
import { PluginRpcRegistry } from '../../../src/nest/plugins/host/rpc-kit/registry';
import type { PluginRpcRegistryService } from '../../../src/nest/plugins/host/rpc-kit/registry.service';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
let codeRoot: string;
let dataRoot: string;
let mod: TestingModule;
beforeAll(() => {
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-code-'));
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-data-'));
process.env.TREK_PLUGINS_DIR = codeRoot;
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
process.env.TREK_PLUGINS_ENABLED = 'true';
// The exact shape the bug bit: a plugin that runs its own-db migration as the
// first thing its onLoad does — what every db-backed plugin's activation looks like.
const dir = path.join(codeRoot, 'migrator', 'server');
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'index.js'),
`module.exports = { async onLoad(ctx) { await ctx.db.migrate('001', 'CREATE TABLE t (n INTEGER)'); } };`,
);
// The row a container recreate leaves behind: enabled, already consented to
// db:own. Seeded 'inactive' (not the 'active' a real shutdown leaves) so the
// poll below only terminates on a status the THIS-boot supervisor wrote.
testDb
.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config) VALUES ('migrator','inactive',1,'[\"db:own\"]','[\"db:own\"]','{}')")
.run();
});
afterAll(async () => {
await mod?.close();
delete process.env.TREK_PLUGINS_DIR;
delete process.env.TREK_PLUGINS_DATA_DIR;
delete process.env.TREK_PLUGINS_ENABLED;
fs.rmSync(codeRoot, { recursive: true, force: true });
fs.rmSync(dataRoot, { recursive: true, force: true });
});
describe('plugin boot vs registry scan ordering', () => {
it('BOOT-REG-001 a plugin enabled before a restart activates cleanly even though the registry scan runs in a LATER provider\'s onModuleInit', async () => {
const dbs = new DatabaseService(dbConn);
const userSettings = new PluginUserSettingsService(dbs);
// Empty at construction — exactly what PluginRpcRegistryService is before its
// own onModuleInit scan has run.
const registry = new PluginRpcRegistry();
const hostFactory = new PluginRpcHostFactory(dbs, registry as unknown as PluginRpcRegistryService);
mod = await Test.createTestingModule({
providers: [
// Mirrors plugins-runtime.module.ts: the runtime service is DECLARED BEFORE
// the provider that populates the registry, so Nest fires its onModuleInit
// (if it had one) first. The boot reconcile must therefore not live there.
{
provide: PluginRuntimeService,
useFactory: () => new PluginRuntimeService(dbs, new AuditService(dbs), new AddonsService(dbs), userSettings, undefined, hostFactory),
},
{
provide: 'REGISTRY_SCAN',
useFactory: () => ({
onModuleInit() {
registry.register(new DbRpc(userSettings));
},
}),
},
],
}).compile();
await mod.init();
// Boot activation is fire-and-forget; wait for the supervisor to settle the row.
const runtime = mod.get(PluginRuntimeService);
let row = { status: 'starting', last_error: null as string | null };
for (let i = 0; i < 100; i++) {
row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='migrator'").get() as typeof row;
if (row.status === 'active' || row.status === 'error') break;
await new Promise((r) => setTimeout(r, 50));
}
expect(row.last_error).toBeNull();
expect(row.status).toBe('active');
expect(runtime.isActive('migrator')).toBe(true);
});
});
@@ -27,9 +27,12 @@ const { testDb } = vi.hoisted(() => {
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service';
import { createPluginRuntime } from '../../helpers/plugin-host';
let codeRoot: string;
let dataRoot: string;
@@ -58,7 +61,7 @@ beforeAll(() => {
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
process.env.TREK_PLUGINS_ENABLED = 'true';
process.env.TREK_PLUGINS_DEV_LINK = '1';
runtime = new PluginRuntimeService();
runtime = createPluginRuntime(new DatabaseService(dbConn));
});
afterAll(async () => {
@@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE plugins (
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
id TEXT PRIMARY KEY, status TEXT, enabled INTEGER DEFAULT 0, version TEXT, api_version INTEGER DEFAULT 1, permissions TEXT DEFAULT '[]', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT DEFAULT '',
config TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', last_error TEXT, updated_at TEXT,
-- Activation refuses a plugin that declares no TREK range, so the fixtures default to a
-- satisfied one: these tests are about permissions/dependencies, and every row would
@@ -43,9 +43,12 @@ const { testDb } = vi.hoisted(() => {
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() }));
import { PluginRuntimeService, PluginDependencyError } from '../../../src/nest/plugins/plugin-runtime.service';
import { createPluginRuntime } from '../../helpers/plugin-host';
import { DependencyCycleError } from '../../../src/nest/plugins/dependencies';
let codeRoot: string;
@@ -77,7 +80,7 @@ beforeAll(() => {
);
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
runtime = new PluginRuntimeService();
runtime = createPluginRuntime(new DatabaseService(dbConn));
});
afterAll(async () => {
@@ -145,9 +148,9 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
// first activate is covered by the 'counter' happy-path test above.)
});
it('onModuleInit is a no-op when the runtime is disabled', () => {
it('onApplicationBootstrap is a no-op when the runtime is disabled', () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(() => new PluginRuntimeService().onModuleInit()).not.toThrow();
expect(() => createPluginRuntime(new DatabaseService(dbConn)).onApplicationBootstrap()).not.toThrow();
process.env.TREK_PLUGINS_ENABLED = 'true';
});
@@ -170,7 +173,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
fs.mkdirSync(path.join(codeRoot, 'messy', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'messy', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('messy','inactive','not-json','not-json')").run();
const rt = new PluginRuntimeService();
const rt = createPluginRuntime(new DatabaseService(dbConn));
await rt.activate('messy'); // must not throw despite the garbage JSON
expect(rt.isActive('messy')).toBe(true);
await rt.deactivate('messy');
@@ -243,33 +246,33 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_user_erasure_queue WHERE plugin_id='counter' AND user_id=9").get()).toMatchObject({ c: 0 });
});
it('onModuleInit boots every ENABLED plugin — even one left in error state', async () => {
it('onApplicationBootstrap boots every ENABLED plugin — even one left in error state', async () => {
fs.mkdirSync(path.join(codeRoot, 'booter', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'booter', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
// status='error' from a previous crash, but enabled=1 → must still boot
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('booter','error',1,'[]','{}')").run();
const rt = new PluginRuntimeService();
rt.onModuleInit(); // fire-and-forget spawn
const rt = createPluginRuntime(new DatabaseService(dbConn));
rt.onApplicationBootstrap(); // fire-and-forget spawn
for (let i = 0; i < 40 && !rt.isActive('booter'); i++) await new Promise((r) => setTimeout(r, 50));
expect(rt.isActive('booter')).toBe(true);
await rt.deactivate('booter');
});
it('onModuleInit does NOT boot a disabled plugin', async () => {
it('onApplicationBootstrap does NOT boot a disabled plugin', async () => {
fs.mkdirSync(path.join(codeRoot, 'sleeper', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'sleeper', 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb.prepare("INSERT INTO plugins (id, status, enabled, granted_permissions, config) VALUES ('sleeper','inactive',0,'[]','{}')").run();
const rt = new PluginRuntimeService();
rt.onModuleInit();
const rt = createPluginRuntime(new DatabaseService(dbConn));
rt.onApplicationBootstrap();
await new Promise((r) => setTimeout(r, 300));
expect(rt.isActive('sleeper')).toBe(false);
});
it('outboundHostsOf extracts declared http:outbound hosts', () => {
testDb.prepare("INSERT INTO plugins (id, status, granted_permissions, config) VALUES ('net','inactive','[\"db:own\",\"http:outbound:api.x.com\",\"http:outbound:*.y.com\"]','{}')").run();
const rt = new PluginRuntimeService();
const rt = createPluginRuntime(new DatabaseService(dbConn));
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
expect(rt.outboundHostsOf('missing')).toEqual([]);
});
@@ -291,7 +294,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
testDb.prepare("INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at) VALUES ('gone', 'poll', 0)").run();
testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('gone', 7)").run();
await new PluginRuntimeService().uninstall('gone', true);
await createPluginRuntime(new DatabaseService(dbConn)).uninstall('gone', true);
expect(fs.existsSync(path.join(codeRoot, 'gone'))).toBe(false);
expect(testDb.prepare("SELECT COUNT(*) c FROM plugins WHERE id='gone'").get()).toMatchObject({ c: 0 });
@@ -310,7 +313,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
// a deleted user's erasure is still pending for this plugin
testDb.prepare("INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES ('keepdata', 42)").run();
await new PluginRuntimeService().uninstall('keepdata', false); // keep the plugin's data
await createPluginRuntime(new DatabaseService(dbConn)).uninstall('keepdata', false); // keep the plugin's data
// the plugin row is gone, but the erasure obligation is retained — a reinstall of the
// same id will drain it and finally honour the deletion, instead of silently keeping
@@ -325,12 +328,12 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
"module.exports = { async onLoad() { await fetch('https://blocked.example/x'); } };");
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('nettry','inactive','[]','{}')").run();
// no http:outbound granted -> egress guard blocks all outbound -> onLoad throws
await expect(new PluginRuntimeService().activate('nettry')).rejects.toThrow(/egress/);
await new PluginRuntimeService().deactivate('nettry').catch(() => {});
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/);
await createPluginRuntime(new DatabaseService(dbConn)).deactivate('nettry').catch(() => {});
});
it('onModuleDestroy tears down cleanly', async () => {
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
await expect(createPluginRuntime(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined();
});
});
@@ -352,7 +355,7 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
it('restarts transparently when the new version requests no new permissions', async () => {
seed('upd-same', 1, ['db:own'], ['db:own']);
const rt = new PluginRuntimeService(fakeRegistry(() => {})); // declared perms unchanged
const rt = createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})); // declared perms unchanged
await rt.activate('upd-same');
const res = await rt.update('upd-same');
expect(res).toMatchObject({ activated: true, newPermissions: [], newEgress: [] });
@@ -362,7 +365,8 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
it('leaves the plugin inactive and reports the delta when new rights are requested', async () => {
seed('upd-wider', 1, ['db:own'], ['db:own']);
const rt = new PluginRuntimeService(
const rt = createPluginRuntime(
new DatabaseService(dbConn),
fakeRegistry(() => {
testDb.prepare("UPDATE plugins SET permissions = ? WHERE id = 'upd-wider'")
.run(JSON.stringify(['db:own', 'db:read:trips', 'http:outbound:api.new.com']));
@@ -379,17 +383,17 @@ describe('PluginRuntimeService.update (re-consent gate)', () => {
it('a disabled plugin stays disabled even with no new permissions', async () => {
seed('upd-off', 0, ['db:own'], ['db:own']);
const res = await new PluginRuntimeService(fakeRegistry(() => {})).update('upd-off');
const res = await createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('upd-off');
expect(res).toMatchObject({ activated: false, newPermissions: [], newEgress: [] });
});
it('throws for an unknown plugin id', async () => {
await expect(new PluginRuntimeService(fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
await expect(createPluginRuntime(new DatabaseService(dbConn), fakeRegistry(() => {})).update('ghost-upd')).rejects.toThrow(/not found/);
});
it('throws if no registry service is wired', async () => {
seed('upd-noreg', 0, ['db:own'], ['db:own']);
await expect(new PluginRuntimeService().update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
await expect(createPluginRuntime(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
});
});
@@ -556,7 +560,7 @@ describe('PluginRuntimeService.retrust', () => {
it('re-pins AND installs in one call: the new key is pinned, the version moved, the block gone', async () => {
seed('rt-ok', 'OLDKEY');
const registry = registryFor('rt-ok');
const rt = new PluginRuntimeService(registry);
const rt = createPluginRuntime(new DatabaseService(dbConn), registry);
await rt.activate('rt-ok');
await rt.retrust('rt-ok', '2.0.0', 'NEWKEY', { userId: 1, ip: '10.0.0.1' });
@@ -601,7 +605,7 @@ describe('PluginRuntimeService.retrust', () => {
seed('rt-no', 'OLDKEY');
const registry = registryFor('rt-no');
vi.mocked(registry.assertRetrustable).mockRejectedValue(new Error('nothing to re-trust'));
const rt = new PluginRuntimeService(registry);
const rt = createPluginRuntime(new DatabaseService(dbConn), registry);
await expect(rt.retrust('rt-no', '2.0.0', 'NEWKEY', { userId: 1 })).rejects.toThrow(/nothing to re-trust/);
@@ -627,7 +631,7 @@ describe('PluginRuntimeService.retrust', () => {
// exactly the "quietly stops updating" failure the block exists to prevent.
it('activate does NOT clear a recorded update block', async () => {
seed('rt-act', 'OLDKEY');
const rt = new PluginRuntimeService(registryFor('rt-act'));
const rt = createPluginRuntime(new DatabaseService(dbConn), registryFor('rt-act'));
await rt.deactivate('rt-act');
await rt.activate('rt-act');
@@ -663,7 +667,7 @@ describe('TREK host-version gating', () => {
it('refuses to activate a plugin this TREK has outgrown, and says so with a code', async () => {
process.env.APP_VERSION = '4.0.0';
seedRanged('outgrown', '>=3.2.0 <4.0.0');
const err = await new PluginRuntimeService().activate('outgrown').catch((e) => e);
const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('outgrown').catch((e) => e);
expect(err).toBeInstanceOf(PluginDependencyError);
expect(err).toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
expect(err.detail).toMatchObject({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' });
@@ -675,14 +679,14 @@ describe('TREK host-version gating', () => {
it('refuses a plugin that never declared a range at all', async () => {
process.env.APP_VERSION = '3.3.0';
seedRanged('undeclared', null);
await expect(new PluginRuntimeService().activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('undeclared')).rejects.toMatchObject({ code: 'TREK_VERSION_UNKNOWN' });
cleanup('undeclared');
});
it('activates normally when the range admits the host', async () => {
process.env.APP_VERSION = '3.3.0';
seedRanged('fits', '>=3.2.0 <4.0.0');
const rt = new PluginRuntimeService();
const rt = createPluginRuntime(new DatabaseService(dbConn));
await rt.activate('fits');
expect(rt.isActive('fits')).toBe(true);
await rt.deactivate('fits');
@@ -696,8 +700,8 @@ describe('TREK host-version gating', () => {
// panel showing a green, running plugin that does not exist.
process.env.APP_VERSION = '4.0.0';
seedRanged('boot-broken', '>=3.2.0 <4.0.0', 1);
const rt = new PluginRuntimeService();
expect(() => rt.onModuleInit()).not.toThrow();
const rt = createPluginRuntime(new DatabaseService(dbConn));
expect(() => rt.onApplicationBootstrap()).not.toThrow();
for (let i = 0; i < 40; i++) {
const row = testDb.prepare("SELECT enabled FROM plugins WHERE id='boot-broken'").get() as { enabled: number };
if (row.enabled === 0) break;
@@ -714,7 +718,43 @@ describe('TREK host-version gating', () => {
fs.mkdirSync(path.join(codeRoot, 'both-wrong', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'both-wrong', 'server', 'index.js'), 'module.exports = {};');
testDb.prepare("INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('both-wrong','inactive',0,'[\"db:own\"]','[]','{}','>=3.2.0 <4.0.0')").run();
await expect(new PluginRuntimeService().activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
await expect(createPluginRuntime(new DatabaseService(dbConn)).activate('both-wrong')).rejects.toMatchObject({ code: 'TREK_VERSION_INCOMPATIBLE' });
cleanup('both-wrong');
});
});
/**
* The activation gate also refuses a plugin whose manifest `apiVersion` is newer than
* what this TREK's plugin-API surface supports (`PLUGIN_API_VERSION`) — mirrors the
* TREK-version gate above, just for the plugin-API surface instead of the host version.
*/
describe('plugin-API version gating', () => {
const seedApi = (id: string, apiVersion: number) => {
fs.mkdirSync(path.join(codeRoot, id, 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, id, 'server', 'index.js'), 'module.exports = { async onLoad() {} };');
testDb
.prepare(
"INSERT INTO plugins (id, status, enabled, permissions, granted_permissions, config, trek_range, api_version) VALUES (?, 'inactive', 0, '[]', '[]', '{}', '>=3.0.0', ?)",
)
.run(id, apiVersion);
};
const cleanup = (...ids: string[]) => { for (const id of ids) testDb.prepare('DELETE FROM plugins WHERE id = ?').run(id); };
it('refuses to activate a plugin whose apiVersion this TREK does not support', async () => {
seedApi('future-api', 2);
const err = await createPluginRuntime(new DatabaseService(dbConn)).activate('future-api').catch((e) => e);
expect(err).toBeInstanceOf(PluginDependencyError);
expect(err).toMatchObject({ code: 'API_VERSION_INCOMPATIBLE' });
expect(err.message).toBe('plugin requires plugin-API v2; this TREK supports v1');
cleanup('future-api');
});
it('activates normally when apiVersion is within what this TREK supports', async () => {
seedApi('current-api', 1);
const rt = createPluginRuntime(new DatabaseService(dbConn));
await rt.activate('current-api');
expect(rt.isActive('current-api')).toBe(true);
await rt.deactivate('current-api');
cleanup('current-api');
});
});
@@ -23,14 +23,17 @@ const { testDb } = vi.hoisted(() => {
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, enabled INTEGER DEFAULT 0, config TEXT,
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT,
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT);
update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0);
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, sort_order INTEGER);
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
import { db as dbConn } from '../../../src/db/database';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } from '../../../src/nest/plugins/registry/registry.service';
import type { ManifestPreview } from '../../../src/nest/plugins/registry/registry.service';
// ── tiny tar.gz builder (wraps the plugin in a codeload-style top dir) ────────
function tarHeader(name: string, size: number, typeflag = '0'): Buffer {
@@ -86,7 +89,7 @@ beforeEach(() => {
testDb.exec('DELETE FROM plugins; DELETE FROM plugin_settings_fields; DELETE FROM plugin_error_log');
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response));
svc = new PluginRegistryService();
svc = new PluginRegistryService(new DatabaseService(dbConn));
});
afterEach(() => {
vi.unstubAllGlobals();
@@ -179,6 +182,27 @@ describe('PluginRegistryService', () => {
});
});
it('detail carries the UI-facing capabilities, so the review sheet can chip a tab takeover', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({
id: 'flight-tracker', permissions: [], egress: [],
capabilities: { widget: { slot: 'hero' }, tripPage: { replaces: ['places'], position: 3 }, settingsUi: true },
})),
sha256: 'unused',
});
const d = (await svc.detail('flight-tracker')) as { manifest: ManifestPreview };
expect(d.manifest.capabilities).toEqual({ widget: { slot: 'hero' }, tripPage: { replaces: ['places'] } });
});
it('detail leaves capabilities empty when the manifest declares none', async () => {
safeDownload.mockResolvedValue({
bytes: Buffer.from(JSON.stringify({ id: 'flight-tracker', permissions: [], capabilities: { widget: {} } })),
sha256: 'unused',
});
const d = (await svc.detail('flight-tracker')) as { manifest: ManifestPreview };
expect(d.manifest.capabilities).toEqual({ widget: {} });
});
it('detail soft-fails the manifest fetch (registry metadata still renders)', async () => {
safeDownload.mockRejectedValue(new Error('offline'));
const d = await svc.detail('flight-tracker');
@@ -233,10 +257,127 @@ describe('PluginRegistryService', () => {
await expect(svc.detail('ghost')).rejects.toThrow(RegistryError);
});
// The per-plugin update hold: a DELIBERATE non-latest install excludes the plugin from
// the update banner until the admin resumes updates or lands back on the newest
// compatible version. Dependency resolution pins versions too, but never deliberately —
// the `explicit` flag is what separates the two.
describe('recomputeUpdateHold', () => {
const twoVersions = {
schemaVersion: 1,
plugins: [
{
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight', type: 'widget',
versions: [
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64) },
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64) },
],
},
],
};
const seedRow = (hold = 0) =>
testDb.prepare("INSERT INTO plugins (id, status, enabled, update_hold) VALUES ('flight-tracker','inactive',0,?)").run(hold);
const holdInDb = () =>
(testDb.prepare("SELECT update_hold FROM plugins WHERE id='flight-tracker'").get() as { update_hold: number }).update_hold;
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => twoVersions }) as unknown as Response));
__clearRegistryCacheForTests();
});
it('holds when the admin explicitly picked an older version', async () => {
seedRow();
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(true);
expect(holdInDb()).toBe(1);
});
it('does not hold when the explicit pick IS the newest compatible version', async () => {
seedRow(1);
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
});
// The comparison is against the newest version THIS TREK can run, not the newest
// published one. Both fixtures above leave `trek` unset, so every version is
// compatible and the two readings agree; only a version this host must skip tells
// them apart. Getting it wrong stamps a hold on an admin who picked the best
// available version, and the row silently leaves the banner and "Update all".
it('does not hold when a newer version exists but this TREK cannot run it', async () => {
const withIncompatibleNewest = {
schemaVersion: 1,
plugins: [
{
...twoVersions.plugins[0],
versions: [
{ version: '3.0.0', gitTag: 'v3.0.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/3', sha256: '3'.repeat(64), trek: '>=4.0.0' },
...twoVersions.plugins[0].versions,
],
},
],
};
process.env.APP_VERSION = '3.3.0';
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => withIncompatibleNewest }) as unknown as Response));
__clearRegistryCacheForTests();
try {
seedRow(1);
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', true)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
} finally {
delete process.env.APP_VERSION;
}
});
it('a non-deliberate update clears a stale hold', async () => {
seedRow(1);
await expect(svc.recomputeUpdateHold('flight-tracker', '2.0.0', false)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
});
it('an unresolvable registry never sets a hold', async () => {
seedRow();
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
__clearRegistryCacheForTests();
await expect(svc.recomputeUpdateHold('flight-tracker', '1.0.0', true)).resolves.toBe(false);
expect(holdInDb()).toBe(0);
});
});
// The version picker's data: every published version, each with its OWN server-computed
// compat verdict — the client has no semver and must never re-derive range logic.
it('detail lists every published version with its own compat verdict', async () => {
process.env.APP_VERSION = '3.3.0';
const multi = {
schemaVersion: 1,
plugins: [
{
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
type: 'widget', authorPublicKey: 'K'.repeat(44),
versions: [
{ version: '2.0.0', gitTag: 'v2.0.0', commitSha: 'b'.repeat(40), downloadUrl: 'https://x/2', sha256: '2'.repeat(64), trek: '>=4.0.0', publishedAt: '2026-08-01', size: 2048, signature: 'sig2' },
{ version: '1.5.0', gitTag: 'v1.5.0', commitSha: 'c'.repeat(40), downloadUrl: 'https://x/15', sha256: '1'.repeat(64), trek: '>=3.0.0 <4.0.0', publishedAt: '2026-07-01', size: 1024 },
{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'd'.repeat(40), downloadUrl: 'https://x/1', sha256: '0'.repeat(64), minTrekVersion: '3.0.0' },
],
},
],
};
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => multi }) as unknown as Response));
__clearRegistryCacheForTests();
try {
const d = (await svc.detail('flight-tracker')) as { versions: unknown };
expect(d.versions).toEqual([
{ version: '2.0.0', publishedAt: '2026-08-01', size: 2048, signed: true, trek: '>=4.0.0', compatible: false },
{ version: '1.5.0', publishedAt: '2026-07-01', size: 1024, signed: false, trek: '>=3.0.0 <4.0.0', compatible: true },
// A legacy entry composes its requirement from the min/max bounds it has.
{ version: '1.0.0', publishedAt: null, size: null, signed: false, trek: '>=3.0.0', compatible: true },
]);
} finally {
delete process.env.APP_VERSION;
}
});
it('fetchRegistry soft-fails to an empty registry on a cold cache', async () => {
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); }));
expect((await new PluginRegistryService().fetchRegistry()).plugins).toEqual([]);
expect((await new PluginRegistryService(new DatabaseService(dbConn)).fetchRegistry()).plugins).toEqual([]);
});
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
@@ -800,6 +941,14 @@ describe('TREK-version gating on install', () => {
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', trek: undefined });
expect(() => svc.stageUpload(bytes)).toThrow(/missing "trek"/);
});
it('refuses an uploaded archive declaring apiVersion 2 (this TREK only speaks plugin-API v1), and writes nothing to the plugin code dir', () => {
// trek range is satisfiable on its own — parseManifest's apiVersion check must
// fire (and stop the upload) before assertHostCompatible is ever reached.
const bytes = makeArtifact({ id: 'my-upload', name: 'Up', version: '1.0.0', type: 'integration', apiVersion: 2 });
expect(() => svc.stageUpload(bytes)).toThrow('plugin requires plugin-API v2; this TREK supports v1');
expect(fs.existsSync(path.join(codeRoot, 'my-upload'))).toBe(false);
});
});
it('installWithDependencies installs the version it RESOLVED, not versions[0]', async () => {
@@ -14,6 +14,17 @@ 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';
import { createTestPluginRegistry } from '../../../src/nest/plugins/host/rpc-kit/testing';
import { DbRpc } from '../../../src/nest/plugins/host/rpc/db.rpc';
import type { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user-settings.service';
/**
* DbRpc also carries `settings.get`, which needs the per-user settings store. No
* fixture below calls it, and none of these plugins has a stored setting anyway, so
* the stub is the one read DbRpc makes, typed off the real signature and answering
* "nothing stored" exactly as the real service would.
*/
const stubUserSettings: Pick<PluginUserSettingsService, 'readOne'> = { readOne: () => undefined };
let codeRoot: string;
let dataRoot: string;
@@ -31,12 +42,12 @@ function makeSupervisor(events: Array<{ topic: string; data: unknown }>, tuning:
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: () => {},
callPlugin: async () => undefined,
emitPluginEvent: (event, payload) => broadcasts.push({ id, event, payload }),
};
return new PluginRpcHost(id, granted, deps);
// Only db.* is exercised from a child here; the rest of the surface has its own
// unit suites and would drag every domain service into this integration test.
return new PluginRpcHost(id, granted, deps, createTestPluginRegistry([new DbRpc(stubUserSettings as PluginUserSettingsService)]));
};
const hooks: SupervisorHooks = {
onEvent: (_id, topic, data) => events.push({ topic, data }),