refactor(plugins): wire PluginHostDepsFactory through DI, drop tags/categories bridges

This commit is contained in:
jubnl
2026-07-26 15:28:55 +02:00
parent 167db04d69
commit 811230fa5d
21 changed files with 197 additions and 226 deletions
@@ -48,6 +48,7 @@ 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;
@@ -79,7 +80,7 @@ beforeAll(() => {
);
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('counter','active','[\"db:own\"]','{}')").run();
runtime = new PluginRuntimeService(new DatabaseService(dbConn));
runtime = createPluginRuntime(new DatabaseService(dbConn));
});
afterAll(async () => {
@@ -149,7 +150,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
it('onModuleInit is a no-op when the runtime is disabled', () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
expect(() => new PluginRuntimeService(new DatabaseService(dbConn)).onModuleInit()).not.toThrow();
expect(() => createPluginRuntime(new DatabaseService(dbConn)).onModuleInit()).not.toThrow();
process.env.TREK_PLUGINS_ENABLED = 'true';
});
@@ -172,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(new DatabaseService(dbConn));
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');
@@ -251,7 +252,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
// 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(new DatabaseService(dbConn));
const rt = createPluginRuntime(new DatabaseService(dbConn));
rt.onModuleInit(); // 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);
@@ -263,7 +264,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
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(new DatabaseService(dbConn));
const rt = createPluginRuntime(new DatabaseService(dbConn));
rt.onModuleInit();
await new Promise((r) => setTimeout(r, 300));
expect(rt.isActive('sleeper')).toBe(false);
@@ -271,7 +272,7 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
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(new DatabaseService(dbConn));
const rt = createPluginRuntime(new DatabaseService(dbConn));
expect(rt.outboundHostsOf('net')).toEqual(['api.x.com', '*.y.com']);
expect(rt.outboundHostsOf('missing')).toEqual([]);
});
@@ -293,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(new DatabaseService(dbConn)).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 });
@@ -312,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(new DatabaseService(dbConn)).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
@@ -327,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(new DatabaseService(dbConn)).activate('nettry')).rejects.toThrow(/egress/);
await new PluginRuntimeService(new DatabaseService(dbConn)).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(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined();
await expect(createPluginRuntime(new DatabaseService(dbConn)).onModuleDestroy()).resolves.toBeUndefined();
});
});
@@ -354,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(new DatabaseService(dbConn), 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: [] });
@@ -364,7 +365,7 @@ 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'")
@@ -382,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(new DatabaseService(dbConn), 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(new DatabaseService(dbConn), 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(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
await expect(createPluginRuntime(new DatabaseService(dbConn)).update('upd-noreg')).rejects.toThrow(/registry service unavailable/);
});
});
@@ -559,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(new DatabaseService(dbConn), 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' });
@@ -604,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(new DatabaseService(dbConn), 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/);
@@ -630,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(new DatabaseService(dbConn), registryFor('rt-act'));
const rt = createPluginRuntime(new DatabaseService(dbConn), registryFor('rt-act'));
await rt.deactivate('rt-act');
await rt.activate('rt-act');
@@ -666,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(new DatabaseService(dbConn)).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' });
@@ -678,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(new DatabaseService(dbConn)).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(new DatabaseService(dbConn));
const rt = createPluginRuntime(new DatabaseService(dbConn));
await rt.activate('fits');
expect(rt.isActive('fits')).toBe(true);
await rt.deactivate('fits');
@@ -699,7 +700,7 @@ 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(new DatabaseService(dbConn));
const rt = createPluginRuntime(new DatabaseService(dbConn));
expect(() => rt.onModuleInit()).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 };
@@ -717,7 +718,7 @@ 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(new DatabaseService(dbConn)).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');
});
});