diff --git a/.env.local.example b/.env.local.example index badced10f4..d5b4f33b4d 100644 --- a/.env.local.example +++ b/.env.local.example @@ -113,6 +113,9 @@ INTERNAL_API_SECRET=changeme # Access disable happens later in worker preflight; pending requests can be cancelled. # Keep production values off preview deployments; rotate Cloud+CSA together. SUPPORT_API_SECRET=changeme +# CSA Vercel Deployment Protection automation bypass (header only, never a query param). +# Required when CSA has Vercel Authentication enabled. +CSA_VERCEL_PROTECTION_BYPASS= # Git token service persisted-authorization disconnect GIT_TOKEN_SERVICE_API_URL=http://localhost:8802 # Optional public HTTPS origin for Bitbucket Code Reviewer webhooks. diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index d3dbf28302..3ecabebbd8 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -45,6 +45,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set { expect(deletionAttentionHint('usage_prefix_progress_invalid')?.action).not.toMatch(/Mark done/); expect(deletionAttentionHint('delete_ready_missing')?.action).toMatch(/delete-ready/); expect(deletionAttentionHint('csa_unauthorized')?.title).toMatch(/CSA/); + expect(deletionAttentionHint('csa_unauthorized')?.action).toMatch( + /CSA_VERCEL_PROTECTION_BYPASS/ + ); }); it('falls back for other HTTP statuses', () => { diff --git a/apps/web/src/lib/user/deletion-queue/deletion-hints.ts b/apps/web/src/lib/user/deletion-queue/deletion-hints.ts index 22fa353c76..6964c448fb 100644 --- a/apps/web/src/lib/user/deletion-queue/deletion-hints.ts +++ b/apps/web/src/lib/user/deletion-queue/deletion-hints.ts @@ -326,7 +326,8 @@ const KNOWN_HINTS: Record = { }, csa_unauthorized: { title: 'CSA rejected the support-DB scrub', - action: 'Confirm SUPPORT_API_SECRET matches CSA KILO_SUPPORT_API_SECRET, then Retry.', + action: + 'Confirm SUPPORT_API_SECRET matches CSA KILO_SUPPORT_API_SECRET and CSA_VERCEL_PROTECTION_BYPASS matches CSA Vercel automation bypass, then Retry.', }, csa_blocked_email: { title: 'CSA refused this email as a relay or internal target', diff --git a/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.test.ts b/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.test.ts index a62b8ac059..281b5a8f3f 100644 --- a/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.test.ts +++ b/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.test.ts @@ -6,10 +6,12 @@ import { handleCsaSupportDb } from '@/lib/user/deletion-queue/handlers/csa-suppo describe('handleCsaSupportDb', () => { const originalSecret = process.env.SUPPORT_API_SECRET; const originalBase = process.env.CSA_APP_BASE_URL; + const originalBypass = process.env.CSA_VERCEL_PROTECTION_BYPASS; beforeEach(() => { process.env.SUPPORT_API_SECRET = 'shared-support-secret'; process.env.CSA_APP_BASE_URL = 'https://csa.example.test'; + delete process.env.CSA_VERCEL_PROTECTION_BYPASS; }); afterEach(() => { @@ -18,6 +20,8 @@ describe('handleCsaSupportDb', () => { else process.env.SUPPORT_API_SECRET = originalSecret; if (originalBase === undefined) delete process.env.CSA_APP_BASE_URL; else process.env.CSA_APP_BASE_URL = originalBase; + if (originalBypass === undefined) delete process.env.CSA_VERCEL_PROTECTION_BYPASS; + else process.env.CSA_VERCEL_PROTECTION_BYPASS = originalBypass; }); it('succeeds when CSA returns updated', async () => { @@ -76,6 +80,27 @@ describe('handleCsaSupportDb', () => { const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; expect((init.headers as Record)['X-Actor-Email']).toBeUndefined(); }); + + it('omits the Vercel protection bypass header when unset', async () => { + const fetchSpy = mockCsa({ status: 200, body: { status: 'updated' } }); + await handleCsaSupportDb(handlerArgs()); + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect((init.headers as Record)['x-vercel-protection-bypass']).toBeUndefined(); + }); + + it('sends the Vercel protection bypass header when configured', async () => { + process.env.CSA_VERCEL_PROTECTION_BYPASS = 'csa-vercel-bypass'; + const fetchSpy = mockCsa({ status: 200, body: { status: 'updated' } }); + const outcome = await handleCsaSupportDb(handlerArgs()); + expect(outcome).toEqual({ kind: 'succeeded' }); + const init = fetchSpy.mock.calls[0]?.[1] as RequestInit; + expect((init.headers as Record)['x-vercel-protection-bypass']).toBe( + 'csa-vercel-bypass' + ); + expect(fetchSpy.mock.calls[0]?.[0]).toBe( + 'https://csa.example.test/api/internal/cloud/users/gdpr-scrub' + ); + }); }); function handlerArgs(request: Partial = {}): { diff --git a/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.ts b/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.ts index d68e2ceb0b..509766b540 100644 --- a/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.ts +++ b/apps/web/src/lib/user/deletion-queue/handlers/csa-support-db.ts @@ -33,6 +33,8 @@ export const handleCsaSupportDb: DeletionHandler = async ({ request, context }) }; const actorEmail = request.requested_by_email?.trim(); if (actorEmail) headers['X-Actor-Email'] = actorEmail; + const protectionBypass = getEnvVariable('CSA_VERCEL_PROTECTION_BYPASS').trim(); + if (protectionBypass) headers['x-vercel-protection-bypass'] = protectionBypass; const result = await deletionFetch(context, `${baseUrl}/api/internal/cloud/users/gdpr-scrub`, { method: 'POST',