From 2c6cde0c907eb5a2ef093f7d79cfc7493ebb3240 Mon Sep 17 00:00:00 2001 From: Wojtek Majewski Date: Sun, 30 Nov 2025 18:42:30 +0100 Subject: [PATCH] use SUPABASE_SERVICE_ROLE_KEY for ControlPlane auth (zero-config) --- PLAN.md | 25 +- pkgs/edge-worker/src/control-plane/server.ts | 13 +- .../tests/unit/control-plane/server.test.ts | 469 ++++++++++-------- 3 files changed, 277 insertions(+), 230 deletions(-) diff --git a/PLAN.md b/PLAN.md index c4fb38fcb..c75d69d30 100644 --- a/PLAN.md +++ b/PLAN.md @@ -30,26 +30,25 @@ ## Authentication -Workers authenticate with ControlPlane using a dedicated secret key: +Workers authenticate with ControlPlane using the Supabase service role key (zero-config): ``` -Env var: PGFLOW_SECRET_KEY -Header: apikey: +Env var: SUPABASE_SERVICE_ROLE_KEY (automatically available in Edge Functions) +Header: apikey: ``` **Setup:** -1. Users generate a secret key in Supabase dashboard (Supabase Secrets) -2. Set `PGFLOW_SECRET_KEY` env var for both ControlPlane and Worker edge functions -3. Workers include `apikey` header in compilation requests -4. ControlPlane verifies `apikey` header matches `PGFLOW_SECRET_KEY` env var +- No setup required - both ControlPlane and Worker Edge Functions automatically have access to `SUPABASE_SERVICE_ROLE_KEY` via `Deno.env` +- Workers include `apikey` header in compilation requests +- ControlPlane verifies `apikey` header matches `SUPABASE_SERVICE_ROLE_KEY` env var **ControlPlane Verification:** ```typescript function verifyAuth(request: Request): boolean { + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + if (!serviceRoleKey) return false; // Not configured - reject all const apikey = request.headers.get('apikey'); - const secretKey = Deno.env.get('PGFLOW_SECRET_KEY'); - if (!secretKey) return false; // Not configured - reject all - return apikey === secretKey; + return apikey === serviceRoleKey; } ``` @@ -66,7 +65,7 @@ Worker.start(MyFlow) │ └── POST /flows/:slug/ensure-compiled │ Body: { shape: workerShape, mode: 'development' | 'production' } - │ Headers: { apikey: PGFLOW_SECRET_KEY } + │ Headers: { apikey: SUPABASE_SERVICE_ROLE_KEY } │ └── ControlPlane (Layer 1: Deployment Validation) │ @@ -119,7 +118,7 @@ Worker.start(MyFlow) ``` POST /flows/:slug/ensure-compiled - Headers: { apikey: PGFLOW_SECRET_KEY } + Headers: { apikey: SUPABASE_SERVICE_ROLE_KEY } Body: { shape: FlowShape, mode: 'development' | 'production' @@ -718,7 +717,7 @@ Test-Driven Development order - write tests FIRST, then implement: ### Phase 7: ControlPlane Endpoint (~0.5 day) **Order within phase:** -1. Add auth verification (check `PGFLOW_SECRET_KEY`) +1. Add auth verification (check `SUPABASE_SERVICE_ROLE_KEY`) 2. Add flow registry lookup (404 if not found) 3. Add Layer 1: TypeScript comparison (409 if worker≠ControlPlane) 4. Add Layer 2: SQL function call diff --git a/pkgs/edge-worker/src/control-plane/server.ts b/pkgs/edge-worker/src/control-plane/server.ts index 9a4c10d0e..ff7f720e3 100644 --- a/pkgs/edge-worker/src/control-plane/server.ts +++ b/pkgs/edge-worker/src/control-plane/server.ts @@ -47,8 +47,6 @@ export type SqlFunction = (strings: TemplateStringsArray, ...values: any[]) => P export interface ControlPlaneOptions { /** SQL function for database operations (required for ensure-compiled endpoint) */ sql?: SqlFunction; - /** Secret key for authentication (required for ensure-compiled endpoint) */ - secretKey?: string; } /** @@ -195,12 +193,13 @@ function jsonResponse(data: unknown, status: number): Response { } /** - * Verifies authentication using apikey header + * Verifies authentication using apikey header against SUPABASE_SERVICE_ROLE_KEY env var */ -function verifyAuth(request: Request, secretKey: string | undefined): boolean { - if (!secretKey) return false; +function verifyAuth(request: Request): boolean { + const serviceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + if (!serviceRoleKey) return false; const apikey = request.headers.get('apikey'); - return apikey === secretKey; + return apikey === serviceRoleKey; } /** @@ -249,7 +248,7 @@ async function handleEnsureCompiled( } // Verify authentication - if (!verifyAuth(request, options.secretKey)) { + if (!verifyAuth(request)) { return jsonResponse( { error: 'Unauthorized', diff --git a/pkgs/edge-worker/tests/unit/control-plane/server.test.ts b/pkgs/edge-worker/tests/unit/control-plane/server.test.ts index 44c1c4fa2..d8d8e2cf5 100644 --- a/pkgs/edge-worker/tests/unit/control-plane/server.test.ts +++ b/pkgs/edge-worker/tests/unit/control-plane/server.test.ts @@ -278,259 +278,308 @@ Deno.test('ControlPlane Handler - empty array creates handler with no flows', as // Tests for POST /flows/:slug/ensure-compiled endpoint // ============================================================ -const TEST_SECRET_KEY = 'test-secret-key-12345'; +const TEST_SERVICE_ROLE_KEY = 'test-service-role-key-12345'; +const ENV_KEY = 'SUPABASE_SERVICE_ROLE_KEY'; Deno.test('ensure-compiled - returns 401 without apikey header', async () => { - const mockSql = createMockSql({ status: 'verified', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' } - // No apikey - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' } + // No apikey + ); + const response = await handler(request); - assertEquals(response.status, 401); - const data = await response.json(); - assertEquals(data.error, 'Unauthorized'); + assertEquals(response.status, 401); + const data = await response.json(); + assertEquals(data.error, 'Unauthorized'); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 401 with wrong apikey', async () => { - const mockSql = createMockSql({ status: 'verified', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + 'wrong-api-key' + ); + const response = await handler(request); - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' }, - 'wrong-api-key' - ); - const response = await handler(request); + assertEquals(response.status, 401); + const data = await response.json(); + assertEquals(data.error, 'Unauthorized'); + } finally { + Deno.env.delete(ENV_KEY); + } +}); - assertEquals(response.status, 401); - const data = await response.json(); - assertEquals(data.error, 'Unauthorized'); +Deno.test('ensure-compiled - returns 401 when SUPABASE_SERVICE_ROLE_KEY not set', async () => { + Deno.env.delete(ENV_KEY); // Ensure it's not set + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + 'any-key' + ); + const response = await handler(request); + + assertEquals(response.status, 401); + const data = await response.json(); + assertEquals(data.error, 'Unauthorized'); + } finally { + // Nothing to restore + } }); Deno.test('ensure-compiled - returns 200 with status compiled for new flow', async () => { - const mockSql = createMockSql({ status: 'compiled', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' }, - TEST_SECRET_KEY - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'compiled', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + TEST_SERVICE_ROLE_KEY + ); + const response = await handler(request); - assertEquals(response.status, 200); - const data = await response.json(); - assertEquals(data.status, 'compiled'); - assertEquals(data.differences, []); + assertEquals(response.status, 200); + const data = await response.json(); + assertEquals(data.status, 'compiled'); + assertEquals(data.differences, []); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 200 with status verified for matching shape', async () => { - const mockSql = createMockSql({ status: 'verified', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' }, - TEST_SECRET_KEY - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + TEST_SERVICE_ROLE_KEY + ); + const response = await handler(request); - assertEquals(response.status, 200); - const data = await response.json(); - assertEquals(data.status, 'verified'); + assertEquals(response.status, 200); + const data = await response.json(); + assertEquals(data.status, 'verified'); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 200 with status recompiled in development mode', async () => { - const mockSql = createMockSql({ - status: 'recompiled', - differences: ['Step count differs: 1 vs 2'], - }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'development' }, - TEST_SECRET_KEY - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ + status: 'recompiled', + differences: ['Step count differs: 1 vs 2'], + }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'development' }, + TEST_SERVICE_ROLE_KEY + ); + const response = await handler(request); - assertEquals(response.status, 200); - const data = await response.json(); - assertEquals(data.status, 'recompiled'); - assertEquals(data.differences, ['Step count differs: 1 vs 2']); + assertEquals(response.status, 200); + const data = await response.json(); + assertEquals(data.status, 'recompiled'); + assertEquals(data.differences, ['Step count differs: 1 vs 2']); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 409 on shape mismatch in production mode', async () => { - const mockSql = createMockSql({ - status: 'mismatch', - differences: ['Step count differs: 1 vs 2'], - }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' }, - TEST_SECRET_KEY - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ + status: 'mismatch', + differences: ['Step count differs: 1 vs 2'], + }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + TEST_SERVICE_ROLE_KEY + ); + const response = await handler(request); - assertEquals(response.status, 409); - const data = await response.json(); - assertEquals(data.status, 'mismatch'); - assertEquals(data.differences, ['Step count differs: 1 vs 2']); + assertEquals(response.status, 409); + const data = await response.json(); + assertEquals(data.status, 'mismatch'); + assertEquals(data.differences, ['Step count differs: 1 vs 2']); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 500 on database error', async () => { - const mockSql = createErrorSql('Connection failed'); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' }, - TEST_SECRET_KEY - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createErrorSql('Connection failed'); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + TEST_SERVICE_ROLE_KEY + ); + const response = await handler(request); - assertEquals(response.status, 500); - const data = await response.json(); - assertEquals(data.error, 'Database Error'); - assertMatch(data.message, /Connection failed/); + assertEquals(response.status, 500); + const data = await response.json(); + assertEquals(data.error, 'Database Error'); + assertMatch(data.message, /Connection failed/); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 404 when SQL not configured', async () => { - // No sql option provided - const handler = createControlPlaneHandler(ALL_TEST_FLOWS); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + // No sql option provided + const handler = createControlPlaneHandler(ALL_TEST_FLOWS); - const shape = extractFlowShape(FlowWithSingleStep); - const request = createEnsureCompiledRequest( - 'flow_single_step', - { shape, mode: 'production' }, - TEST_SECRET_KEY - ); - const response = await handler(request); + const shape = extractFlowShape(FlowWithSingleStep); + const request = createEnsureCompiledRequest( + 'flow_single_step', + { shape, mode: 'production' }, + TEST_SERVICE_ROLE_KEY + ); + const response = await handler(request); - assertEquals(response.status, 404); - const data = await response.json(); - assertEquals(data.error, 'Not Found'); + assertEquals(response.status, 404); + const data = await response.json(); + assertEquals(data.error, 'Not Found'); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 400 for invalid JSON body', async () => { - const mockSql = createMockSql({ status: 'verified', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const request = new Request( - 'http://localhost/pgflow/flows/flow_single_step/ensure-compiled', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - apikey: TEST_SECRET_KEY, - }, - body: 'invalid json', - } - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const request = new Request( + 'http://localhost/pgflow/flows/flow_single_step/ensure-compiled', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: TEST_SERVICE_ROLE_KEY, + }, + body: 'invalid json', + } + ); + const response = await handler(request); - assertEquals(response.status, 400); - const data = await response.json(); - assertEquals(data.error, 'Bad Request'); + assertEquals(response.status, 400); + const data = await response.json(); + assertEquals(data.error, 'Bad Request'); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 400 for missing shape in body', async () => { - const mockSql = createMockSql({ status: 'verified', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const request = new Request( - 'http://localhost/pgflow/flows/flow_single_step/ensure-compiled', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - apikey: TEST_SECRET_KEY, - }, - body: JSON.stringify({ mode: 'production' }), // missing shape - } - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const request = new Request( + 'http://localhost/pgflow/flows/flow_single_step/ensure-compiled', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: TEST_SERVICE_ROLE_KEY, + }, + body: JSON.stringify({ mode: 'production' }), // missing shape + } + ); + const response = await handler(request); - assertEquals(response.status, 400); - const data = await response.json(); - assertEquals(data.error, 'Bad Request'); - assertMatch(data.message, /shape/); + assertEquals(response.status, 400); + const data = await response.json(); + assertEquals(data.error, 'Bad Request'); + assertMatch(data.message, /shape/); + } finally { + Deno.env.delete(ENV_KEY); + } }); Deno.test('ensure-compiled - returns 400 for invalid mode', async () => { - const mockSql = createMockSql({ status: 'verified', differences: [] }); - const options: ControlPlaneOptions = { - sql: mockSql, - secretKey: TEST_SECRET_KEY, - }; - const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); - - const shape = extractFlowShape(FlowWithSingleStep); - const request = new Request( - 'http://localhost/pgflow/flows/flow_single_step/ensure-compiled', - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - apikey: TEST_SECRET_KEY, - }, - body: JSON.stringify({ shape, mode: 'invalid' }), - } - ); - const response = await handler(request); + Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); + try { + const mockSql = createMockSql({ status: 'verified', differences: [] }); + const options: ControlPlaneOptions = { sql: mockSql }; + const handler = createControlPlaneHandler(ALL_TEST_FLOWS, options); + + const shape = extractFlowShape(FlowWithSingleStep); + const request = new Request( + 'http://localhost/pgflow/flows/flow_single_step/ensure-compiled', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + apikey: TEST_SERVICE_ROLE_KEY, + }, + body: JSON.stringify({ shape, mode: 'invalid' }), + } + ); + const response = await handler(request); - assertEquals(response.status, 400); - const data = await response.json(); - assertEquals(data.error, 'Bad Request'); - assertMatch(data.message, /mode/); + assertEquals(response.status, 400); + const data = await response.json(); + assertEquals(data.error, 'Bad Request'); + assertMatch(data.message, /mode/); + } finally { + Deno.env.delete(ENV_KEY); + } });