feat(plugins): TREK-side registry — browse + one-click install (M5)

Connects TREK to the static GitHub registry (mauriceboe/TREK-Plugins). The
registry repo + CI gates were already live; this is the server side.

- registry.service: fetches the single aggregated dist/index.json (never
  per-plugin GitHub API calls — the HACS rate-limit lesson), caches it 30 min,
  soft-fails to a stale/empty registry, and installs a pinned version through
  the M4 pipeline: safe download -> sha256 verify -> slip-safe extract ->
  manifest re-validate -> native re-scan -> atomic move -> discover (inactive),
  recording repo/commit/sha provenance. Handles the codeload {repo}-{sha}/
  wrapper directory.
- Admin endpoints: GET /api/admin/plugins/registry (browse metadata) and
  POST /api/admin/plugins/install { id, version }. Install never executes code;
  activation stays a separate, deliberate step.
This commit is contained in:
mauriceboe
2026-07-03 17:38:10 +02:00
committed by Maurice
parent f7c54d3d90
commit f61e144e11
8 changed files with 376 additions and 18 deletions
+5 -1
View File
@@ -44,7 +44,11 @@ describe('discoverPlugins', () => {
name: 'Flight',
type: 'widget',
permissions: ['db:own'],
settings: [{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true }],
settings: [
{ key: 'api_key', input_type: 'password', scope: 'instance', secret: true },
{ key: 'units', input_type: 'select', scope: 'user', options: [{ value: 'm', label: 'Metric' }] },
{ key: 'oauth', input_type: 'oauth', scope: 'user', oauth: { initPath: '/o/start', callbackPath: '/o/cb' } },
],
});
const res = discoverPlugins(db);
expect(res.discovered).toEqual(['flight-tracker']);
@@ -51,7 +51,7 @@ describe('PluginsService.list', () => {
it('controller delegates to the service', () => {
const svc = { list: vi.fn(() => ({ enabled: false, plugins: [] })) } as unknown as PluginsService;
const runtime = {} as unknown as import('../../../src/nest/plugins/plugin-runtime.service').PluginRuntimeService;
const res = new PluginsController(svc, runtime).list();
const res = new PluginsController(svc, runtime, {} as never).list();
expect(svc.list).toHaveBeenCalled();
expect(res).toEqual({ enabled: false, plugins: [] });
});
@@ -86,31 +86,31 @@ describe('PluginsController M2 endpoints', () => {
it('get/update config delegate to the service', () => {
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never;
const c = new PluginsController(svc, rt);
const c = new PluginsController(svc, rt, {} as never);
expect(c.getConfig('x')).toEqual({ config: { a: 1 } });
expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } });
});
it('activate spawns via the runtime when enabled', async () => {
const rt = { activate: vi.fn(async () => {}), isActive: vi.fn(() => true) } as never;
const out = await new PluginsController(svc, rt).activate('x');
const out = await new PluginsController(svc, rt, {} as never).activate('x');
expect(out).toEqual({ status: 'active' });
});
it('activate is 503 when the runtime is disabled', async () => {
process.env.TREK_PLUGINS_ENABLED = 'false';
const rt = { activate: vi.fn(), isActive: vi.fn() } as never;
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 503 });
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 503 });
});
it('activate surfaces an activation error as 400', async () => {
const rt = { activate: vi.fn(async () => { throw new Error('bad code'); }), isActive: vi.fn(() => false) } as never;
await expect(new PluginsController(svc, rt).activate('x')).rejects.toMatchObject({ status: 400 });
await expect(new PluginsController(svc, rt, {} as never).activate('x')).rejects.toMatchObject({ status: 400 });
});
it('deactivate stops the plugin', async () => {
const rt = { deactivate: vi.fn(async () => {}) } as never;
expect(await new PluginsController(svc, rt).deactivate('x')).toEqual({ status: 'inactive' });
expect(await new PluginsController(svc, rt, {} as never).deactivate('x')).toEqual({ status: 'inactive' });
});
});
+17 -10
View File
@@ -43,22 +43,23 @@ function makeTarGz(entries: Array<{ name: string; data?: string; type?: string;
parts.push(Buffer.alloc(1024, 0));
return zlib.gzipSync(Buffer.concat(parts));
}
function makeStoredZip(name: string, data: string): Buffer {
function makeZip(name: string, data: string, method = 0): Buffer {
const nameB = Buffer.from(name);
const dataB = Buffer.from(data);
const raw = Buffer.from(data);
const comp = method === 8 ? zlib.deflateRawSync(raw) : raw;
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50, 0);
local.writeUInt16LE(0, 8); // method 0 (stored)
local.writeUInt32LE(dataB.length, 18);
local.writeUInt32LE(dataB.length, 22);
local.writeUInt16LE(method, 8);
local.writeUInt32LE(comp.length, 18);
local.writeUInt32LE(raw.length, 22);
local.writeUInt16LE(nameB.length, 26);
const localRec = Buffer.concat([local, nameB, dataB]);
const localRec = Buffer.concat([local, nameB, comp]);
const central = Buffer.alloc(46);
central.writeUInt32LE(0x02014b50, 0);
central.writeUInt16LE(0, 10);
central.writeUInt32LE(dataB.length, 20);
central.writeUInt32LE(dataB.length, 24);
central.writeUInt16LE(method, 10);
central.writeUInt32LE(comp.length, 20);
central.writeUInt32LE(raw.length, 24);
central.writeUInt16LE(nameB.length, 28);
central.writeUInt32LE(0, 42); // local header offset
const centralRec = Buffer.concat([central, nameB]);
@@ -101,10 +102,16 @@ describe('extractArchive', () => {
});
it('extracts a stored zip', () => {
extractArchive(makeStoredZip('a.txt', 'hello'), dest);
extractArchive(makeZip('a.txt', 'hello'), dest);
expect(fs.readFileSync(path.join(dest, 'a.txt'), 'utf8')).toBe('hello');
});
it('extracts a deflated zip and rejects an unsupported compression method', () => {
extractArchive(makeZip('b.txt', 'world', 8), dest);
expect(fs.readFileSync(path.join(dest, 'b.txt'), 'utf8')).toBe('world');
expect(() => extractArchive(makeZip('c.txt', 'x', 99), dest)).toThrow(/unsupported zip compression/);
});
it('refuses a symlink entry', () => {
const gz = makeTarGz([{ name: 'link', type: '2', link: '/etc/passwd' }]);
expect(() => extractArchive(gz, dest)).toThrow(/non-regular/);
@@ -58,6 +58,15 @@ describe('safeDownload', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('rejects an invalid url', async () => {
await expect(safeDownload('not-a-url')).rejects.toThrow(/invalid url/);
});
it('rejects a redirect without a location header', async () => {
vi.stubGlobal('fetch', vi.fn(async () => ({ status: 302, headers: new Headers() }) as unknown as Response));
await expect(safeDownload('https://github.com/x')).rejects.toThrow(/redirect without/);
});
it('enforces the size cap', async () => {
const fetchMock = vi.fn().mockResolvedValue({ status: 200, ok: true, headers: new Headers(), arrayBuffer: async () => Buffer.alloc(2000) });
vi.stubGlobal('fetch', fetchMock);