diff --git a/app/api/admin/__tests__/policy-if-match.test.ts b/app/api/admin/__tests__/policy-if-match.test.ts index cc7bf3518..f98ce7b4f 100644 --- a/app/api/admin/__tests__/policy-if-match.test.ts +++ b/app/api/admin/__tests__/policy-if-match.test.ts @@ -101,4 +101,26 @@ describe('policy save with If-Match', () => { expect(res.status).toBe(200); expect((await stored()).features.calendarEnabled).toBe(false); }); + + it('answers 409 when the config manager was made by another bundle\'s copy of the module', async () => { + // In the server the instrumentation creates the config manager, so its + // errors are of the instrumentation's classes, not the route's. + const KEY = Symbol.for('bulwark.admin.configManager'); + const g = globalThis as Record; + const mine = g[KEY]; + try { + delete g[KEY]; + vi.resetModules(); + await (await import('@/lib/admin/config-manager')).configManager.load(); + vi.resetModules(); + const route = await import('@/app/api/admin/policy/route'); + const res = await route.GET(new NextRequest(URL)); + const etag = res.headers.get('ETag')!; + await writeFile(path.join(dir, 'policy.json'), JSON.stringify({ features: { calendarEnabled: false } })); + const stale = await route.PUT(new NextRequest(URL, { method: 'PUT', headers: { 'if-match': etag }, body: JSON.stringify(await res.json()) })); + expect(stale.status).toBe(409); + } finally { + g[KEY] = mine; + } + }); }); diff --git a/app/api/admin/policy/file/route.ts b/app/api/admin/policy/file/route.ts index 62d7bee0c..d4dca9ef1 100644 --- a/app/api/admin/policy/file/route.ts +++ b/app/api/admin/policy/file/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { completePolicy, configManager, PolicyConflictError } from '@/lib/admin/config-manager'; +import { completePolicy, configManager, isPolicyConflict } from '@/lib/admin/config-manager'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; @@ -71,7 +71,7 @@ export async function POST(request: NextRequest) { await configManager.setPolicy(next, { ifVersion: version }); break; } catch (error) { - if (!(error instanceof PolicyConflictError)) throw error; + if (!isPolicyConflict(error)) throw error; if (attempt >= 3) return NextResponse.json({ error: error.message }, { status: 409 }); } } diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 512307784..a799231df 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { configManager, PolicyConflictError } from '@/lib/admin/config-manager'; +import { configManager, isPolicyConflict } from '@/lib/admin/config-manager'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; @@ -135,7 +135,7 @@ export async function PUT(request: NextRequest) { try { version = await configManager.setPolicy(policy, { ifVersion: ifMatchVersion(request) }); } catch (error) { - if (error instanceof PolicyConflictError) { + if (isPolicyConflict(error)) { return NextResponse.json({ error: error.message }, { status: 409 }); } throw error; diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index a540c7f11..5ffe686eb 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -59,6 +59,15 @@ export class PolicyConflictError extends Error { } } +/** + * Whether `error` is a PolicyConflictError. By name, not instanceof: the + * config manager on globalThis may come from another bundle's copy of this + * module (the instrumentation creates it), whose class is a different object. + */ +export function isPolicyConflict(error: unknown): error is PolicyConflictError { + return error instanceof Error && error.name === 'PolicyConflictError'; +} + /** Policy writes in this process, one after another, so a version check and its write can't interleave. */ const POLICY_WRITE_QUEUE = 'admin-config\0policy.json';