diff --git a/pkgs/edge-worker/src/control-plane/server.ts b/pkgs/edge-worker/src/control-plane/server.ts index e5e25fc9e..443265f55 100644 --- a/pkgs/edge-worker/src/control-plane/server.ts +++ b/pkgs/edge-worker/src/control-plane/server.ts @@ -1,6 +1,5 @@ -import type { AnyFlow, FlowShape } from '@pgflow/dsl'; +import type { AnyFlow } from '@pgflow/dsl'; import { compileFlow } from '@pgflow/dsl'; -import { isLocalSupabase } from '../shared/localDetection.ts'; /** * Response type for the /flows/:slug endpoint @@ -18,38 +17,6 @@ export interface ErrorResponse { message: string; } -/** - * Response type for the /flows/:slug/ensure-compiled endpoint - */ -export interface EnsureCompiledResponse { - status: 'compiled' | 'verified' | 'recompiled' | 'mismatch'; - differences: string[]; - mode: 'development' | 'production'; -} - -/** - * Request body for the /flows/:slug/ensure-compiled endpoint - */ -export interface EnsureCompiledRequest { - shape: FlowShape; -} - -/** - * SQL function interface for database operations - * Compatible with the postgres library's tagged template interface - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -// deno-lint-ignore no-explicit-any -export type SqlFunction = (strings: TemplateStringsArray, ...values: any[]) => Promise; - -/** - * Options for configuring the ControlPlane handler - */ -export interface ControlPlaneOptions { - /** SQL function for database operations (required for ensure-compiled endpoint) */ - sql?: SqlFunction; -} - /** * Input type for flow registration - accepts array or object (for namespace imports) */ @@ -87,13 +54,9 @@ function buildFlowRegistry(flows: AnyFlow[]): Map { /** * Creates a request handler for the ControlPlane HTTP API * @param flowsInput Array or object of flow definitions to register - * @param options Optional configuration for database and authentication * @returns Request handler function */ -export function createControlPlaneHandler( - flowsInput: FlowInput, - options?: ControlPlaneOptions -) { +export function createControlPlaneHandler(flowsInput: FlowInput) { const flows = normalizeFlowInput(flowsInput); const registry = buildFlowRegistry(flows); @@ -112,15 +75,6 @@ export function createControlPlaneHandler( return handleGetFlow(registry, slug); } - // Handle POST /flows/:slug/ensure-compiled - const ensureCompiledMatch = pathname.match( - /^\/flows\/([a-zA-Z0-9_]+)\/ensure-compiled$/ - ); - if (ensureCompiledMatch && req.method === 'POST') { - const slug = ensureCompiledMatch[1]; - return handleEnsureCompiled(req, slug, options); - } - // 404 for unknown routes return jsonResponse( { @@ -192,121 +146,3 @@ function jsonResponse(data: unknown, status: number): Response { }, }); } - -/** - * Verifies authentication using apikey header against SUPABASE_SERVICE_ROLE_KEY env var - */ -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 === serviceRoleKey; -} - -/** - * Validates the ensure-compiled request body - */ -function validateEnsureCompiledBody( - body: unknown -): { valid: true; data: EnsureCompiledRequest } | { valid: false; error: string } { - if (!body || typeof body !== 'object') { - return { valid: false, error: 'Request body must be an object' }; - } - - const { shape } = body as Record; - - if (!shape || typeof shape !== 'object') { - return { valid: false, error: 'Missing or invalid shape in request body' }; - } - - return { valid: true, data: { shape: shape as FlowShape } }; -} - -/** - * Handles POST /flows/:slug/ensure-compiled requests - */ -async function handleEnsureCompiled( - request: Request, - flowSlug: string, - options?: ControlPlaneOptions -): Promise { - // Check if SQL is configured - if (!options?.sql) { - return jsonResponse( - { - error: 'Not Found', - message: 'ensure-compiled endpoint requires SQL configuration', - }, - 404 - ); - } - - // Verify authentication - if (!verifyAuth(request)) { - return jsonResponse( - { - error: 'Unauthorized', - message: 'Invalid or missing apikey header', - }, - 401 - ); - } - - // Parse and validate request body - let body: unknown; - try { - body = await request.json(); - } catch { - return jsonResponse( - { - error: 'Bad Request', - message: 'Invalid JSON in request body', - }, - 400 - ); - } - - const validation = validateEnsureCompiledBody(body); - if (!validation.valid) { - return jsonResponse( - { - error: 'Bad Request', - message: validation.error, - }, - 400 - ); - } - - const { shape } = validation.data; - - // Auto-detect mode based on environment - const mode = isLocalSupabase() ? 'development' : 'production'; - - // Call SQL function - try { - const [result] = await options.sql` - SELECT pgflow.ensure_flow_compiled( - ${flowSlug}, - ${JSON.stringify(shape)}::jsonb, - ${mode} - ) as result - `; - - // Include detected mode in response for transparency - const response: EnsureCompiledResponse = { - ...result.result, - mode, - }; - - return jsonResponse(response, response.status === 'mismatch' ? 409 : 200); - } catch (error) { - console.error('Error calling ensure_flow_compiled:', error); - return jsonResponse( - { - error: 'Database Error', - message: error instanceof Error ? error.message : 'Unknown error', - }, - 500 - ); - } -} 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 a9ed4a168..70a06721b 100644 --- a/pkgs/edge-worker/tests/unit/control-plane/server.test.ts +++ b/pkgs/edge-worker/tests/unit/control-plane/server.test.ts @@ -1,54 +1,6 @@ import { assertEquals, assertMatch } from '@std/assert'; -import { Flow, compileFlow, extractFlowShape } from '@pgflow/dsl'; -import type { FlowShape } from '@pgflow/dsl'; -import { - createControlPlaneHandler, - type ControlPlaneOptions, -} from '../../../src/control-plane/server.ts'; -import { - KNOWN_LOCAL_ANON_KEY, - KNOWN_LOCAL_SERVICE_ROLE_KEY, -} from '../../../src/shared/localDetection.ts'; - -// Mock SQL function that simulates database responses -function createMockSql(response: { - status: 'compiled' | 'verified' | 'recompiled' | 'mismatch'; - differences: string[]; -}) { - return function mockSql( - _strings: TemplateStringsArray, - ..._values: unknown[] - ) { - // Return array with result object matching SQL query pattern - return Promise.resolve([{ result: response }]); - }; -} - -// Mock SQL that throws an error -function createErrorSql(errorMessage: string) { - return function mockSql() { - return Promise.reject(new Error(errorMessage)); - }; -} - -// Helper to create POST request with body -function createEnsureCompiledRequest( - slug: string, - body: { shape: FlowShape }, - apikey?: string -): Request { - const headers: Record = { - 'Content-Type': 'application/json', - }; - if (apikey) { - headers['apikey'] = apikey; - } - return new Request(`http://localhost/pgflow/flows/${slug}/ensure-compiled`, { - method: 'POST', - headers, - body: JSON.stringify(body), - }); -} +import { Flow, compileFlow } from '@pgflow/dsl'; +import { createControlPlaneHandler } from '../../../src/control-plane/server.ts'; // Test flows covering different DSL features const FlowWithSingleStep = new Flow({ slug: 'flow_single_step' }) @@ -277,339 +229,3 @@ Deno.test('ControlPlane Handler - empty array creates handler with no flows', as const data = await response.json(); assertEquals(data.error, 'Flow Not Found'); }); - -// ============================================================ -// Tests for POST /flows/:slug/ensure-compiled endpoint -// ============================================================ - -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 () => { - 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 } - // No apikey - ); - const response = await handler(request); - - 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 () => { - 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 }, - '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); - } -}); - -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 }, - '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 () => { - 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 }, - 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(data.mode, 'production'); // Non-local key = production mode - } finally { - Deno.env.delete(ENV_KEY); - } -}); - -Deno.test('ensure-compiled - returns 200 with status verified for matching shape', async () => { - 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 }, - TEST_SERVICE_ROLE_KEY - ); - const response = await handler(request); - - 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 (local keys)', async () => { - // Set local Supabase keys to trigger development mode - Deno.env.set(ENV_KEY, KNOWN_LOCAL_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 }, - KNOWN_LOCAL_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(data.mode, 'development'); // Local key = development mode - } finally { - Deno.env.delete(ENV_KEY); - } -}); - -Deno.test('ensure-compiled - returns 409 on shape mismatch in production mode', async () => { - 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 }, - 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']); - } finally { - Deno.env.delete(ENV_KEY); - } -}); - -Deno.test('ensure-compiled - returns 500 on database error', async () => { - 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 }, - 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/); - } finally { - Deno.env.delete(ENV_KEY); - } -}); - -Deno.test('ensure-compiled - returns 404 when SQL not configured', async () => { - 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 }, - TEST_SERVICE_ROLE_KEY - ); - const response = await handler(request); - - 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 () => { - 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'); - } finally { - Deno.env.delete(ENV_KEY); - } -}); - -Deno.test('ensure-compiled - returns 400 for missing shape in body', async () => { - 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({}), // 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/); - } finally { - Deno.env.delete(ENV_KEY); - } -}); - -// ============================================================ -// Tests for auto-detection behavior -// ============================================================ - -Deno.test('ensure-compiled - detects development mode with local anon key', async () => { - const ENV_ANON_KEY = 'SUPABASE_ANON_KEY'; - Deno.env.set(ENV_KEY, TEST_SERVICE_ROLE_KEY); - Deno.env.set(ENV_ANON_KEY, KNOWN_LOCAL_ANON_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 }, - TEST_SERVICE_ROLE_KEY - ); - const response = await handler(request); - - assertEquals(response.status, 200); - const data = await response.json(); - assertEquals(data.mode, 'development'); // Local anon key detected - } finally { - Deno.env.delete(ENV_KEY); - Deno.env.delete(ENV_ANON_KEY); - } -}); - -Deno.test('ensure-compiled - detects production mode with non-local keys', async () => { - 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 }, - TEST_SERVICE_ROLE_KEY - ); - const response = await handler(request); - - assertEquals(response.status, 200); - const data = await response.json(); - assertEquals(data.mode, 'production'); // Non-local key = production - } finally { - Deno.env.delete(ENV_KEY); - } -});