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.
This commit is contained in:
Linus Rath
2026-11-01 01:03:13 +01:00
parent d78ce02f78
commit 38bc06f2d3
7 changed files with 34 additions and 2 deletions
@@ -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 });
}
+3
View File
@@ -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 });
}
+3
View File
@@ -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 });
}
+3
View File
@@ -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 });
}
@@ -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' });
});
});
@@ -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';
+4
View File
@@ -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);