From 38bc06f2d3db5153fbdad0e7aa99d5451a63fb0b Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:03:13 +0100 Subject: [PATCH] fix: answer 503 for plugin lists and marketplace installs while the shared store is down #1066 directory-keys-shared.test.ts: the store-down case now expects the store error instead of an 'invalid' signature, since a store outage no longer counts as a bad signature. --- .../extensions/[kind]/[id]/history/route.ts | 3 +++ app/api/admin/plugins/route.ts | 3 +++ app/api/admin/themes/route.ts | 3 +++ app/api/plugins/route.ts | 3 +++ .../__tests__/directory-keys-shared.test.ts | 4 ++-- .../__tests__/plugin-registry-shared.test.ts | 16 ++++++++++++++++ lib/admin/directory-keys.ts | 4 ++++ 7 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/api/admin/extensions/[kind]/[id]/history/route.ts b/app/api/admin/extensions/[kind]/[id]/history/route.ts index 495513a86..9613bced8 100644 --- a/app/api/admin/extensions/[kind]/[id]/history/route.ts +++ b/app/api/admin/extensions/[kind]/[id]/history/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth } from '@/lib/admin/session'; import { logger } from '@/lib/logger'; +import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { listHistory } from '@/lib/admin/extension-history'; import { getPlugin, getTheme } from '@/lib/admin/plugin-registry'; import { EXTENSION_ID_RE } from '@/lib/extension-package'; @@ -41,6 +42,8 @@ export async function GET( { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { + const unavailable = storeUnavailableResponse(error); + if (unavailable) return unavailable; logger.error('Extension history error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts index aeaf5c7bf..e2f3a4312 100644 --- a/app/api/admin/plugins/route.ts +++ b/app/api/admin/plugins/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; +import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { getPlugin, getPluginRegistry, @@ -46,6 +47,8 @@ export async function GET(request: NextRequest) { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { + const unavailable = storeUnavailableResponse(error); + if (unavailable) return unavailable; logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts index 8a2eeca7d..ee79557de 100644 --- a/app/api/admin/themes/route.ts +++ b/app/api/admin/themes/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; +import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { getTheme, getThemeRegistry, @@ -33,6 +34,8 @@ export async function GET(request: NextRequest) { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { + const unavailable = storeUnavailableResponse(error); + if (unavailable) return unavailable; logger.error('Theme list error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index b5652688a..39dd01460 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -4,6 +4,7 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; +import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response'; import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; @@ -98,6 +99,8 @@ export async function GET(request: NextRequest) { { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { + const unavailable = storeUnavailableResponse(error); + if (unavailable) return unavailable; logger.error('Plugin list error', { error: error instanceof Error ? error.message : 'Unknown error' }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } diff --git a/lib/admin/__tests__/directory-keys-shared.test.ts b/lib/admin/__tests__/directory-keys-shared.test.ts index bcc7db30d..4077501d5 100644 --- a/lib/admin/__tests__/directory-keys-shared.test.ts +++ b/lib/admin/__tests__/directory-keys-shared.test.ts @@ -92,9 +92,9 @@ describe('directory keyring dates in the shared store', () => { expect(existsSync(path.join(dir, 'state', 'directory-keyrings.json'))).toBe(false); }); - it('refuses the bundle while the store is down', async () => { + it('fails with the store outage while the store is down, so the install answers 503', async () => { store.down = true; const a = await replica(); - expect(await a(ringAt('2026-10-20T00:00:00.000Z'))).toMatchObject({ status: 'invalid' }); + await expect(a(ringAt('2026-10-20T00:00:00.000Z'))).rejects.toMatchObject({ name: 'StoreUnavailableError' }); }); }); diff --git a/lib/admin/__tests__/plugin-registry-shared.test.ts b/lib/admin/__tests__/plugin-registry-shared.test.ts index f2b0f4c45..8141fc75e 100644 --- a/lib/admin/__tests__/plugin-registry-shared.test.ts +++ b/lib/admin/__tests__/plugin-registry-shared.test.ts @@ -225,6 +225,22 @@ describe('plugins in the shared store', () => { }); }); +describe('lists while the store is down', () => { + it('answer 503 on a replica that has no copy yet, not 500', async () => { + store.down = true; + const r = await otherReplica(); + const history = await import('@/app/api/admin/extensions/[kind]/[id]/history/route'); + const publicList = await import('@/app/api/plugins/route'); + const responses = await Promise.all([ + r.plugins.GET(new NextRequest('http://localhost/api/admin/plugins')), + r.themes.GET(new NextRequest('http://localhost/api/admin/themes')), + history.GET(new NextRequest('http://localhost/api/admin/extensions/plugin/demo-plugin/history'), { params: Promise.resolve({ kind: 'plugin', id: 'demo-plugin' }) }), + publicList.GET(new NextRequest('http://localhost/api/plugins')), + ]); + expect(responses.map((res) => res.status)).toEqual([503, 503, 503, 503]); + }); +}); + describe('one change of the registry at a time across replicas', () => { const LEASE = 'extension-registry-plugins'; diff --git a/lib/admin/directory-keys.ts b/lib/admin/directory-keys.ts index 51bd25d2f..ddddbb89e 100644 --- a/lib/admin/directory-keys.ts +++ b/lib/admin/directory-keys.ts @@ -13,6 +13,7 @@ // none (a custom directory that set none), bundles install as before. import { createHash, createPublicKey, verify as nodeVerify } from 'node:crypto'; import { canonicalJson } from '@/lib/extension-package'; +import { isStoreUnavailableError } from '@/lib/shared-store'; import { DIRECTORY_KEYRINGS_ID, directoryKeyringDocs } from './extension-store'; export const OFFICIAL_DIRECTORY = 'https://extensions.bulwarkmail.org'; @@ -174,6 +175,9 @@ export async function checkBundleSignature(opts: { try { keys = await onlineKeys(opts.directoryUrl, rootKeys, opts.fetchImpl ?? fetch); } catch (err) { + // The shared store being down says nothing about the bundle: the + // install answers 503 instead of calling it badly signed. + if (isStoreUnavailableError(err)) throw err; return { status: 'invalid', reason: err instanceof Error ? err.message : String(err) }; } const key = keys.find((k) => k.keyId === keyId);