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
@@ -1,6 +1,7 @@
import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Put, UseGuards } from '@nestjs/common';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { PluginRegistryService } from './registry/registry.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { AdminGuard } from '../auth/admin.guard';
import { pluginsEnabled } from './kill-switch';
@@ -19,6 +20,7 @@ export class PluginsController {
constructor(
private readonly plugins: PluginsService,
private readonly runtime: PluginRuntimeService,
private readonly registry: PluginRegistryService,
) {}
@Get()
@@ -26,6 +28,23 @@ export class PluginsController {
return this.plugins.list();
}
@Get('registry')
browse() {
return this.registry.browse();
}
@Post('install')
@HttpCode(200)
async install(@Body() body: { id?: string; version?: string }) {
if (!pluginsEnabled()) throw new HttpException({ error: 'Plugins are disabled by server configuration' }, 503);
if (!body?.id) throw new HttpException({ error: 'id is required' }, 400);
try {
return await this.registry.install(body.id, body.version);
} catch (e) {
throw new HttpException({ error: e instanceof Error ? e.message : 'install failed' }, 400);
}
}
@Get(':id/config')
getConfig(@Param('id') id: string) {
return { config: this.plugins.getInstanceConfig(id) };
+2 -1
View File
@@ -5,6 +5,7 @@ import { PluginsProxyController } from './plugins-proxy.controller';
import { PluginFrameController } from './plugin-frame.controller';
import { PluginsService } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { PluginRegistryService } from './registry/registry.service';
/**
* Plugin system (#plugins). M0 read side + M2 isolated runtime + M3 frontend:
@@ -15,6 +16,6 @@ import { PluginRuntimeService } from './plugin-runtime.service';
*/
@Module({
controllers: [PluginsController, PluginsFeedController, PluginsProxyController, PluginFrameController],
providers: [PluginsService, PluginRuntimeService],
providers: [PluginsService, PluginRuntimeService, PluginRegistryService],
})
export class PluginsModule {}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import fs from 'node:fs';
import path from 'node:path';
import { db } from '../../../db/database';
import { pluginCodeDir, pluginsCodeRoot, pluginsDataRoot } from '../paths';
import { safeDownload, sha256Matches } from '../install/safe-fetch';
import { extractArchive } from '../install/safe-extract';
import { scanForNativeBinaries } from '../install/native-scan';
import { parseManifest } from '../install/manifest';
import { discoverPlugins } from '../install/discovery';
/**
* TREK-side of the plugin registry (#plugins, M5). Fetches the single aggregated
* dist/index.json (never per-plugin GitHub API calls — the HACS rate-limit
* lesson), caches it briefly + soft-fails, and installs a pinned version through
* the M4 pipeline: SSRF-safe download -> sha256 verify -> zip/tar-slip-safe
* extract -> manifest re-validate -> native re-scan -> atomic move -> discover
* (registers INACTIVE). Nothing executes on install; activation is separate.
*/
const REGISTRY_URL =
process.env.TREK_PLUGIN_REGISTRY_URL ||
'https://raw.githubusercontent.com/mauriceboe/TREK-Plugins/main/dist/index.json';
const CACHE_TTL = 30 * 60 * 1000;
interface RegistryVersion {
version: string;
gitTag: string;
commitSha: string;
downloadUrl: string;
sha256: string;
minTrekVersion: string;
maxTrekVersion?: string | null;
size?: number;
apiVersion?: number;
nativeModules?: boolean;
}
export interface RegistryEntry {
id: string;
name: string;
author: string;
description: string;
repo: string;
homepage?: string;
tags?: string[];
type: string;
reviewedAt?: string | null;
versions: RegistryVersion[];
}
interface Registry {
schemaVersion: number;
generatedAt?: string;
plugins: RegistryEntry[];
}
let _cache: { data: Registry; expiresAt: number } | null = null;
/** Test hook: drop the in-memory registry cache. */
export function __clearRegistryCacheForTests(): void {
_cache = null;
}
export class RegistryError extends Error {}
@Injectable()
export class PluginRegistryService {
/** Fetch the aggregated registry (cached, soft-fail, stale-serve). */
async fetchRegistry(): Promise<Registry> {
if (_cache && Date.now() < _cache.expiresAt) return _cache.data;
try {
const resp = await fetch(REGISTRY_URL, { headers: { 'User-Agent': 'TREK-Server' } });
if (!resp.ok) throw new Error(`registry ${resp.status}`);
const data = (await resp.json()) as Registry;
if (!data || !Array.isArray(data.plugins)) throw new Error('malformed registry');
_cache = { data, expiresAt: Date.now() + CACHE_TTL };
return data;
} catch {
return _cache?.data ?? { schemaVersion: 1, plugins: [] };
}
}
/** The browse list the admin UI renders (metadata only, no code). */
async browse(): Promise<Array<Omit<RegistryEntry, 'versions'> & { latest: string | null; minTrekVersion: string | null }>> {
const reg = await this.fetchRegistry();
return reg.plugins.map((p) => {
const latest = p.versions[0] ?? null;
return {
id: p.id, name: p.name, author: p.author, description: p.description, repo: p.repo,
homepage: p.homepage, tags: p.tags, type: p.type, reviewedAt: p.reviewedAt ?? null,
latest: latest?.version ?? null, minTrekVersion: latest?.minTrekVersion ?? null,
};
});
}
/** Install a pinned version from the registry. Returns the installed plugin id. */
async install(id: string, version?: string): Promise<{ id: string; version: string }> {
const reg = await this.fetchRegistry();
const entry = reg.plugins.find((p) => p.id === id);
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
const ver = version ? entry.versions.find((v) => v.version === version) : entry.versions[0];
if (!ver) throw new RegistryError(`version ${version ?? 'latest'} not found for ${id}`);
// 1. SSRF-safe download + 2. sha256 verify
const { bytes, sha256 } = await safeDownload(ver.downloadUrl, (ver.size ?? 50 * 1024 * 1024) + 4096);
if (!sha256Matches(sha256, ver.sha256)) throw new RegistryError('integrity check failed (sha256 mismatch)');
// 3. zip/tar-slip-safe extract to staging
const staging = path.join(pluginsDataRoot(), '.staging', `${id}-${ver.version}-${Date.now()}`);
try {
extractArchive(bytes, staging);
const pluginRoot = locateManifestDir(staging);
if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json');
// 4. re-validate the bundled manifest + 5. native re-scan
const manifest = parseManifest(JSON.parse(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8')));
if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`);
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
// 6. atomic move into place
const dest = pluginCodeDir(id);
fs.mkdirSync(pluginsCodeRoot(), { recursive: true });
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(pluginRoot, dest);
// 7. register INACTIVE (record provenance)
discoverPlugins(db);
db.prepare('UPDATE plugins SET source_repo = ?, source_commit = ?, sha256 = ?, reviewed_at = ? WHERE id = ?').run(
entry.repo, ver.commitSha, ver.sha256, entry.reviewedAt ?? null, id,
);
return { id, version: ver.version };
} finally {
fs.rmSync(staging, { recursive: true, force: true });
}
}
}
/** The extracted plugin root: staging itself, or its single wrapper subdir (codeload archives wrap in {repo}-{sha}/). */
function locateManifestDir(staging: string): string | null {
if (fs.existsSync(path.join(staging, 'trek-plugin.json'))) return staging;
const subs = fs.existsSync(staging)
? fs.readdirSync(staging, { withFileTypes: true }).filter((d) => d.isDirectory())
: [];
for (const s of subs) {
const p = path.join(staging, s.name);
if (fs.existsSync(path.join(p, 'trek-plugin.json'))) return p;
}
return null;
}
@@ -0,0 +1,170 @@
/**
* TREK-side registry service (#plugins, M5): browse the aggregated registry and
* install a pinned version through the full verify -> extract -> validate ->
* move -> register pipeline (with the network download mocked).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import zlib from 'node:zlib';
import { createHash } from 'node:crypto';
const { safeDownload } = vi.hoisted(() => ({ safeDownload: vi.fn() }));
vi.mock('../../../src/nest/plugins/install/safe-fetch', async (orig) => ({
...(await orig<Record<string, unknown>>()),
safeDownload,
}));
const { testDb } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const 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, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT,
source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at 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, 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 { PluginRegistryService, RegistryError, __clearRegistryCacheForTests } 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 {
const h = Buffer.alloc(512, 0);
h.write(name, 0); h.write('0000644', 100); h.write('0000000', 108); h.write('0000000', 116);
h.write(size.toString(8).padStart(11, '0'), 124); h.write('00000000000', 136);
h.write(' ', 148); h.write(typeflag, 156); h.write('ustar\0', 257); h.write('00', 263);
let sum = 0; for (let i = 0; i < 512; i++) sum += h[i];
h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148);
return h;
}
function makeArtifact(manifest: object): Buffer {
const files = [
{ name: 'plug-abc/', type: '5' as const, data: '' },
{ name: 'plug-abc/trek-plugin.json', type: '0' as const, data: JSON.stringify(manifest) },
{ name: 'plug-abc/server/', type: '5' as const, data: '' },
{ name: 'plug-abc/server/index.js', type: '0' as const, data: 'module.exports={}' },
];
const parts: Buffer[] = [];
for (const f of files) {
const body = Buffer.from(f.data);
parts.push(tarHeader(f.name, f.type === '5' ? 0 : body.length, f.type));
if (f.type === '0') { parts.push(body); const pad = (512 - (body.length % 512)) % 512; if (pad) parts.push(Buffer.alloc(pad, 0)); }
}
parts.push(Buffer.alloc(1024, 0));
return zlib.gzipSync(Buffer.concat(parts));
}
const REGISTRY = {
schemaVersion: 1,
plugins: [
{
id: 'flight-tracker', name: 'Flight', author: 'Acme', description: 'flights', repo: 'acme/trek-flight',
type: 'widget', reviewedAt: '2026-06-20',
versions: [{ version: '1.0.0', gitTag: 'v1.0.0', commitSha: 'a'.repeat(40), downloadUrl: 'https://codeload.github.com/acme/trek-flight/tar.gz/aaaa', sha256: '', minTrekVersion: '3.2.0' }],
},
],
};
let dataRoot: string;
let codeRoot: string;
let svc: PluginRegistryService;
beforeEach(() => {
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-data-'));
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'reg-code-'));
process.env.TREK_PLUGINS_DATA_DIR = dataRoot;
process.env.TREK_PLUGINS_DIR = codeRoot;
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();
});
afterEach(() => {
vi.unstubAllGlobals();
safeDownload.mockReset();
delete process.env.TREK_PLUGINS_DATA_DIR;
delete process.env.TREK_PLUGINS_DIR;
fs.rmSync(dataRoot, { recursive: true, force: true });
fs.rmSync(codeRoot, { recursive: true, force: true });
});
describe('PluginRegistryService', () => {
it('browse maps the aggregated registry to metadata', async () => {
const list = await svc.browse();
expect(list).toEqual([
expect.objectContaining({ id: 'flight-tracker', name: 'Flight', latest: '1.0.0', minTrekVersion: '3.2.0', reviewedAt: '2026-06-20' }),
]);
});
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([]);
});
it('installs a pinned version end to end (verify -> extract -> register inactive)', async () => {
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget', permissions: ['db:own'] });
const sha = createHash('sha256').update(artifact).digest('hex');
REGISTRY.plugins[0].versions[0].sha256 = sha;
safeDownload.mockResolvedValue({ bytes: artifact, sha256: sha });
const out = await svc.install('flight-tracker');
expect(out).toEqual({ id: 'flight-tracker', version: '1.0.0' });
// moved into place + registered inactive with provenance
expect(fs.existsSync(path.join(codeRoot, 'flight-tracker', 'trek-plugin.json'))).toBe(true);
const row = testDb.prepare("SELECT status, source_repo, source_commit FROM plugins WHERE id='flight-tracker'").get() as { status: string; source_repo: string; source_commit: string };
expect(row).toMatchObject({ status: 'inactive', source_repo: 'acme/trek-flight', source_commit: 'a'.repeat(40) });
// no staging left behind
expect(fs.existsSync(path.join(dataRoot, '.staging'))).toBe(false || fs.readdirSync(path.join(dataRoot, '.staging')).length === 0);
});
it('rejects an sha256 mismatch', async () => {
const artifact = makeArtifact({ id: 'flight-tracker', name: 'Flight', version: '1.0.0', type: 'widget' });
REGISTRY.plugins[0].versions[0].sha256 = 'b'.repeat(64);
safeDownload.mockResolvedValue({ bytes: artifact, sha256: 'c'.repeat(64) });
await expect(svc.install('flight-tracker')).rejects.toThrow(/integrity/);
});
it('rejects an unknown plugin id', async () => {
await expect(svc.install('ghost')).rejects.toThrow(RegistryError);
});
it('caches the registry (one fetch across calls)', async () => {
const spy = vi.fn(async () => ({ ok: true, json: async () => REGISTRY }) as unknown as Response);
vi.stubGlobal('fetch', spy);
__clearRegistryCacheForTests();
await svc.fetchRegistry();
await svc.fetchRegistry();
expect(spy).toHaveBeenCalledTimes(1);
});
it('soft-fails on a non-ok registry response', async () => {
__clearRegistryCacheForTests();
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, status: 500 }) as unknown as Response));
expect((await svc.fetchRegistry()).plugins).toEqual([]);
});
it('rejects a version that is not listed', async () => {
await expect(svc.install('flight-tracker', '9.9.9')).rejects.toThrow(/not found/);
});
it('rejects an archive without a manifest', async () => {
const parts = [Buffer.alloc(1024, 0)]; // empty tar
const empty = zlib.gzipSync(Buffer.concat(parts));
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(empty).digest('hex');
safeDownload.mockResolvedValue({ bytes: empty, sha256: REGISTRY.plugins[0].versions[0].sha256 });
await expect(svc.install('flight-tracker')).rejects.toThrow(/no trek-plugin.json/);
});
it('rejects a manifest id that does not match the registry id', async () => {
const artifact = makeArtifact({ id: 'other-id', name: 'X', version: '1.0.0', type: 'widget' });
REGISTRY.plugins[0].versions[0].sha256 = createHash('sha256').update(artifact).digest('hex');
safeDownload.mockResolvedValue({ bytes: artifact, sha256: REGISTRY.plugins[0].versions[0].sha256 });
await expect(svc.install('flight-tracker')).rejects.toThrow(/!=/);
});
});
+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);