Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pkgs/edge-worker/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pkgs/edge-worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export { FlowWorkerLifecycle } from './flow/FlowWorkerLifecycle.js';
// Export ControlPlane for HTTP-based flow compilation
export { ControlPlane } from './control-plane/index.js';

// Export Installer for no-CLI platforms (e.g., Lovable)
export { Installer } from './installer/index.js';

// Export platform adapters
export * from './platform/index.js';

Expand Down
8 changes: 8 additions & 0 deletions pkgs/edge-worker/src/installer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createInstallerHandler } from './server.ts';

export const Installer = {
run: (token: string) => {
const handler = createInstallerHandler(token);
Deno.serve({}, handler);
},
};
176 changes: 176 additions & 0 deletions pkgs/edge-worker/src/installer/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { InstallerResult, StepResult } from './types.ts';
import postgres from 'postgres';
import { MigrationRunner } from '../control-plane/migrations/index.ts';
import { extractProjectId } from '../control-plane/server.ts';

// Dependency injection for testability
export interface InstallerDeps {
getEnv: (key: string) => string | undefined;
}

const defaultDeps: InstallerDeps = {
getEnv: (key) => Deno.env.get(key),
};

export function createInstallerHandler(
expectedToken: string,
deps: InstallerDeps = defaultDeps
): (req: Request) => Promise<Response> {
return async (req: Request) => {
// Validate token from query params first (fail fast)
const url = new URL(req.url);
const token = url.searchParams.get('token');

if (token !== expectedToken) {
return jsonResponse(
{
success: false,
message:
'Invalid or missing token. Use the exact URL from your Lovable prompt.',
},
401
);
}

// Read env vars inside handler (not at module level)
const supabaseUrl = deps.getEnv('SUPABASE_URL');
const serviceRoleKey = deps.getEnv('SUPABASE_SERVICE_ROLE_KEY');
const dbUrl = deps.getEnv('SUPABASE_DB_URL');

if (!supabaseUrl || !serviceRoleKey) {
return jsonResponse(
{
success: false,
message: 'Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY',
},
500
);
}

if (!dbUrl) {
return jsonResponse(
{
success: false,
message: 'Missing SUPABASE_DB_URL',
},
500
);
}

console.log('pgflow installer starting...');

// Create database connection
const sql = postgres(dbUrl, { prepare: false });

let secrets: StepResult;
let migrations: StepResult;

try {
// Step 1: Configure vault secrets
console.log('Configuring vault secrets...');
secrets = await configureSecrets(sql, supabaseUrl, serviceRoleKey);

if (!secrets.success) {
const result: InstallerResult = {
success: false,
secrets,
migrations: {
success: false,
status: 0,
error: 'Skipped - secrets failed',
},
message: 'Failed to configure vault secrets.',
};
return jsonResponse(result, 500);
}

// Step 2: Run migrations
console.log('Running migrations...');
migrations = await runMigrations(sql);

const result: InstallerResult = {
success: secrets.success && migrations.success,
secrets,
migrations,
message: migrations.success
? 'pgflow installed successfully! Vault secrets configured and migrations applied.'
: 'Secrets configured but migrations failed. Check the error details.',
};

console.log('Installer complete:', result.message);
return jsonResponse(result, result.success ? 200 : 500);
} finally {
await sql.end();
}
};
}

/**
* Configure vault secrets for pgflow
*/
async function configureSecrets(
sql: postgres.Sql,
supabaseUrl: string,
serviceRoleKey: string
): Promise<StepResult> {
try {
const projectId = extractProjectId(supabaseUrl);
if (!projectId) {
return {
success: false,
status: 500,
error: 'Could not extract project ID from SUPABASE_URL',
};
}

// Upsert secrets (delete + create pattern) in single transaction
await sql.begin(async (tx) => {
await tx`DELETE FROM vault.secrets WHERE name = 'supabase_project_id'`;
await tx`SELECT vault.create_secret(${projectId}, 'supabase_project_id')`;

await tx`DELETE FROM vault.secrets WHERE name = 'supabase_service_role_key'`;
await tx`SELECT vault.create_secret(${serviceRoleKey}, 'supabase_service_role_key')`;
});

return {
success: true,
status: 200,
data: { configured: ['supabase_project_id', 'supabase_service_role_key'] },
};
} catch (error) {
return {
success: false,
status: 500,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

/**
* Run pending migrations
*/
async function runMigrations(sql: postgres.Sql): Promise<StepResult> {
try {
const runner = new MigrationRunner(sql);
const result = await runner.up();

return {
success: result.success,
status: result.success ? 200 : 500,
data: result,
};
} catch (error) {
return {
success: false,
status: 500,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}

function jsonResponse(data: unknown, status: number): Response {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
13 changes: 13 additions & 0 deletions pkgs/edge-worker/src/installer/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface StepResult {
success: boolean;
status: number;
data?: unknown;
error?: string;
}

export interface InstallerResult {
success: boolean;
secrets: StepResult;
migrations: StepResult;
message: string;
}
118 changes: 118 additions & 0 deletions pkgs/edge-worker/tests/unit/installer/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { assertEquals, assertMatch } from '@std/assert';
import {
createInstallerHandler,
type InstallerDeps,
} from '../../../src/installer/server.ts';

// Helper to create mock dependencies
function createMockDeps(overrides?: Partial<InstallerDeps>): InstallerDeps {
return {
getEnv: (key: string) =>
({
SUPABASE_URL: 'https://test-project.supabase.co',
SUPABASE_SERVICE_ROLE_KEY: 'test-service-role-key',
SUPABASE_DB_URL: 'postgresql://postgres:postgres@localhost:54322/postgres',
})[key],
...overrides,
};
}

// Helper to create a request with optional token
function createRequest(token?: string): Request {
const url = token
? `http://localhost/pgflow-installer?token=${token}`
: 'http://localhost/pgflow-installer';
return new Request(url);
}

// ============================================================
// Token validation tests
// ============================================================

Deno.test('Installer Handler - returns 401 when token missing', async () => {
const deps = createMockDeps();
const handler = createInstallerHandler('expected-token', deps);

const request = createRequest(); // no token
const response = await handler(request);

assertEquals(response.status, 401);
const data = await response.json();
assertEquals(data.success, false);
assertMatch(data.message, /Invalid or missing token/);
});

Deno.test('Installer Handler - returns 401 when token incorrect', async () => {
const deps = createMockDeps();
const handler = createInstallerHandler('expected-token', deps);

const request = createRequest('wrong-token');
const response = await handler(request);

assertEquals(response.status, 401);
const data = await response.json();
assertEquals(data.success, false);
assertMatch(data.message, /Invalid or missing token/);
});

// ============================================================
// Environment variable validation tests
// ============================================================

Deno.test('Installer Handler - returns 500 when SUPABASE_URL missing', async () => {
const deps = createMockDeps({
getEnv: (key: string) =>
({
SUPABASE_SERVICE_ROLE_KEY: 'test-key',
// SUPABASE_URL is undefined
})[key],
});
const handler = createInstallerHandler('valid-token', deps);

const request = createRequest('valid-token');
const response = await handler(request);

assertEquals(response.status, 500);
const data = await response.json();
assertEquals(data.success, false);
assertMatch(data.message, /Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY/);
});

Deno.test('Installer Handler - returns 500 when SUPABASE_SERVICE_ROLE_KEY missing', async () => {
const deps = createMockDeps({
getEnv: (key: string) =>
({
SUPABASE_URL: 'https://test.supabase.co',
// SUPABASE_SERVICE_ROLE_KEY is undefined
})[key],
});
const handler = createInstallerHandler('valid-token', deps);

const request = createRequest('valid-token');
const response = await handler(request);

assertEquals(response.status, 500);
const data = await response.json();
assertEquals(data.success, false);
assertMatch(data.message, /Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY/);
});

Deno.test('Installer Handler - returns 500 when SUPABASE_DB_URL missing', async () => {
const deps = createMockDeps({
getEnv: (key: string) =>
({
SUPABASE_URL: 'https://test.supabase.co',
SUPABASE_SERVICE_ROLE_KEY: 'test-key',
// SUPABASE_DB_URL is undefined
})[key],
});
const handler = createInstallerHandler('valid-token', deps);

const request = createRequest('valid-token');
const response = await handler(request);

assertEquals(response.status, 500);
const data = await response.json();
assertEquals(data.success, false);
assertMatch(data.message, /Missing SUPABASE_DB_URL/);
});
Loading