feat(plugins): lifecycle polish — uninstall, error log, egress guard, widget slot (M8)

- Uninstall with data disposition: POST /api/admin/plugins/:id/uninstall kills the
  plugin, removes its code + DB metadata, and (deleteData) drops its data dir,
  error log and per-user settings.
- Error log: GET/DELETE /api/admin/plugins/:id/errors — the plugin's own crash /
  request-failure log, surfaced in the admin panel.
- Egress guard: the isolated child wraps global fetch and refuses any outbound
  host not in the plugin's declared egress[]; with none declared, all outbound is
  blocked. Process-level defense in depth (the container runtime enforces it at
  the network layer in v2).
- Admin → Plugins is now actionable: activate / deactivate / uninstall, a
  registry browser (install), and per-plugin error log. i18n across all locales.
- Dashboard widget slot: active widget plugins render as sandboxed cards.

The trek:request core-data bridge + photo/calendar hook consumers remain follow-ups.
This commit is contained in:
mauriceboe
2026-07-03 18:08:11 +02:00
committed by Maurice
parent 660e4b9dfc
commit 1cee4e93b1
34 changed files with 719 additions and 29 deletions
@@ -15,7 +15,8 @@ const { testDb } = vi.hoisted(() => {
id TEXT PRIMARY KEY, status TEXT, permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]',
config TEXT DEFAULT '{}', last_error TEXT, updated_at 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 plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER);
CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT);`);
return { testDb: db };
});
vi.mock('../../../src/db/database', () => ({ db: testDb, canAccessTrip: () => undefined }));
@@ -150,6 +151,31 @@ describe('PluginRuntimeService (M2 end-to-end)', () => {
expect(rt.outboundHostsOf('missing')).toEqual([]);
});
it('uninstall removes the code, DB rows, settings and (with deleteData) data', async () => {
fs.mkdirSync(path.join(codeRoot, 'gone', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'gone', 'server', 'index.js'), 'module.exports={}');
testDb.prepare("INSERT INTO plugins (id, status, permissions, config) VALUES ('gone','inactive','[]','{}')").run();
testDb.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret) VALUES ('gone','k','instance',0)").run();
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (1, 'plugin:gone:units', 'metric')").run();
await new PluginRuntimeService().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 });
expect(testDb.prepare("SELECT COUNT(*) c FROM plugin_settings_fields WHERE plugin_id='gone'").get()).toMatchObject({ c: 0 });
expect(testDb.prepare("SELECT COUNT(*) c FROM settings WHERE key LIKE 'plugin:gone:%'").get()).toMatchObject({ c: 0 });
});
it('the egress guard blocks a fetch to an undeclared host', async () => {
fs.mkdirSync(path.join(codeRoot, 'nettry', 'server'), { recursive: true });
fs.writeFileSync(path.join(codeRoot, 'nettry', 'server', 'index.js'),
"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(() => {});
});
it('onModuleDestroy tears down cleanly', async () => {
await expect(new PluginRuntimeService().onModuleDestroy()).resolves.toBeUndefined();
});