feat(plugins): install an older version of a plugin (#2035)

Adds a version picker to the plugin admin panel, on desktop and mobile: every
published version with its own server-computed compatibility verdict, so the
client never re-derives semver range logic.

A deliberate non-latest pick sets an update hold on the row, which keeps the
update banner and "Update all" from immediately nagging the rollback away. The
hold releases when an install lands back on the newest compatible version by any
path, or when the admin resumes updates explicitly. An unresolvable registry
never sets one: refusing to pin on missing information beats silently muting
future updates.
This commit is contained in:
jubnl
2026-08-25 17:30:27 +02:00
committed by GitHub
parent b5c8182410
commit 4ba407aead
37 changed files with 1383 additions and 42 deletions
@@ -23,7 +23,7 @@ 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 };
@@ -257,6 +257,123 @@ 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'); }));