diff --git a/docs/config/extensions/overview.mdx b/docs/config/extensions/overview.mdx index c60083ba882..7e967cbd896 100644 --- a/docs/config/extensions/overview.mdx +++ b/docs/config/extensions/overview.mdx @@ -57,6 +57,7 @@ Trigger.dev provides a set of built-in extensions that you can use to customize | [additionalPackages](/config/extensions/additionalPackages) | Install additional npm packages in your build image | | [syncEnvVars](/config/extensions/syncEnvVars) | Automatically sync environment variables from external services to Trigger.dev | | [syncVercelEnvVars](/config/extensions/syncEnvVars#syncVercelEnvVars) | Automatically sync environment variables from Vercel to Trigger.dev | +| [syncSupabaseEnvVars](/config/extensions/syncEnvVars#syncSupabaseEnvVars) | Automatically sync environment variables from Supabase to Trigger.dev | | [esbuildPlugin](/config/extensions/esbuildPlugin) | Add existing or custom esbuild extensions to customize your build process | | [emitDecoratorMetadata](/config/extensions/emitDecoratorMetadata) | Enable `emitDecoratorMetadata` in your TypeScript build | | [audioWaveform](/config/extensions/audioWaveform) | Add Audio Waveform to your build image | diff --git a/docs/config/extensions/syncEnvVars.mdx b/docs/config/extensions/syncEnvVars.mdx index d6b0be78323..bdec8a891de 100644 --- a/docs/config/extensions/syncEnvVars.mdx +++ b/docs/config/extensions/syncEnvVars.mdx @@ -220,3 +220,72 @@ The extension syncs the following environment variables (with optional prefix): - `POSTGRES_PRISMA_URL` - Connection string optimized for Prisma - `POSTGRES_HOST`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DATABASE` - `PGHOST`, `PGHOST_UNPOOLED`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` + +### syncSupabaseEnvVars + +The `syncSupabaseEnvVars` build extension syncs environment variables from your Supabase project to Trigger.dev. It uses [Supabase Branching](https://supabase.com/docs/guides/deployment/branching) to automatically detect branches and build the appropriate database connection strings and API keys for your environment. + + + +You need to set the `SUPABASE_ACCESS_TOKEN` and `SUPABASE_PROJECT_ID` environment variables, or pass them +as arguments to the `syncSupabaseEnvVars` build extension. + +You can generate a `SUPABASE_ACCESS_TOKEN` in your Supabase [dashboard](https://supabase.com/dashboard/account/tokens). + + + +When running the build from a Vercel environment (determined by checking if the `VERCEL` +environment variable is present), this extension is skipped entirely. + + + + + This extension is skipped for `prod` and `dev` environments. It is designed to sync + branch-specific database connections and API keys for preview/staging environments using Supabase + Branching. + + +```ts +import { defineConfig } from "@trigger.dev/sdk"; +import { syncSupabaseEnvVars } from "@trigger.dev/build/extensions/core"; + +export default defineConfig({ + project: "", + // Your other config settings... + build: { + // This will automatically use the SUPABASE_ACCESS_TOKEN and SUPABASE_PROJECT_ID environment variables + extensions: [syncSupabaseEnvVars()], + }, +}); +``` + +Or you can pass in the token, project ID, and other options as arguments: + +```ts +import { defineConfig } from "@trigger.dev/sdk"; +import { syncSupabaseEnvVars } from "@trigger.dev/build/extensions/core"; + +export default defineConfig({ + project: "", + // Your other config settings... + build: { + extensions: [ + syncSupabaseEnvVars({ + projectId: "your-supabase-project-id", + supabaseAccessToken: "your-supabase-access-token", // optional, we recommend to keep it as env variable + branch: "your-branch-name", // optional, defaults to ctx.branch + envVarPrefix: "MY_PREFIX_", // optional, prefix for all synced env vars + }), + ], + }, +}); +``` + +The extension syncs the following environment variables (with optional prefix): + +- `DATABASE_URL`, `POSTGRES_URL`, `SUPABASE_DB_URL` — PostgreSQL connection strings +- `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE` — Individual connection parameters +- `SUPABASE_URL` — Supabase API URL +- `SUPABASE_ANON_KEY` — Anonymous API key +- `SUPABASE_SERVICE_ROLE_KEY` — Service role API key +- `SUPABASE_JWT_SECRET` — JWT secret diff --git a/packages/build/src/extensions/core.ts b/packages/build/src/extensions/core.ts index 62671dfd571..d8269fa1d95 100644 --- a/packages/build/src/extensions/core.ts +++ b/packages/build/src/extensions/core.ts @@ -5,3 +5,4 @@ export * from "./core/aptGet.js"; export * from "./core/ffmpeg.js"; export * from "./core/neonSyncEnvVars.js"; export * from "./core/vercelSyncEnvVars.js"; +export * from "./core/syncSupabaseEnvVars.js"; diff --git a/packages/build/src/extensions/core/syncSupabaseEnvVars.ts b/packages/build/src/extensions/core/syncSupabaseEnvVars.ts new file mode 100644 index 00000000000..c2d89b9b370 --- /dev/null +++ b/packages/build/src/extensions/core/syncSupabaseEnvVars.ts @@ -0,0 +1,232 @@ +import { BuildExtension } from "@trigger.dev/core/v3/build"; +import { syncEnvVars } from "../core.js"; + +type EnvVar = { name: string; value: string; isParentEnv?: boolean }; + +type SupabaseBranch = { + id: string; + name: string; + project_ref: string; + parent_project_ref: string; + is_default: boolean; + git_branch: string; + status: string; +}; + +type SupabaseBranchDetail = { + ref: string; + db_host: string; + db_port: number; + db_user: string; + db_pass: string; + jwt_secret: string; + status: string; +}; + +type SupabaseApiKey = { + name: string; + api_key: string; +}; + +// List of Supabase related environment variables to sync +export const SUPABASE_ENV_VARS = [ + "DATABASE_URL", + "POSTGRES_URL", + "SUPABASE_DB_URL", + "PGHOST", + "PGPORT", + "PGUSER", + "PGPASSWORD", + "PGDATABASE", + "SUPABASE_URL", + "SUPABASE_ANON_KEY", + "SUPABASE_SERVICE_ROLE_KEY", + "SUPABASE_JWT_SECRET", +]; + +function buildSupabaseEnvVarMappings(options: { + user: string; + password: string; + host: string; + port: number; + database: string; + ref: string; + jwtSecret: string; + anonKey?: string; + serviceRoleKey?: string; +}): Record { + const { user, password, host, port, database, ref, jwtSecret, anonKey, serviceRoleKey } = options; + + const connectionString = `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${database}`; + + const mappings: Record = { + DATABASE_URL: connectionString, + POSTGRES_URL: connectionString, + SUPABASE_DB_URL: connectionString, + PGHOST: host, + PGPORT: String(port), + PGUSER: user, + PGPASSWORD: password, + PGDATABASE: database, + SUPABASE_URL: `https://${ref}.supabase.co`, + SUPABASE_JWT_SECRET: jwtSecret, + }; + + if (anonKey) { + mappings.SUPABASE_ANON_KEY = anonKey; + } + + if (serviceRoleKey) { + mappings.SUPABASE_SERVICE_ROLE_KEY = serviceRoleKey; + } + + return mappings; +} + +export function syncSupabaseEnvVars(options?: { + projectId?: string; + /** + * Supabase Management API access token for authentication. + * It's recommended to use the SUPABASE_ACCESS_TOKEN environment variable instead of hardcoding this value. + */ + supabaseAccessToken?: string; + branch?: string; + envVarPrefix?: string; +}): BuildExtension { + const sync = syncEnvVars(async (ctx) => { + const projectId = + options?.projectId ?? process.env.SUPABASE_PROJECT_ID ?? ctx.env.SUPABASE_PROJECT_ID; + const supabaseAccessToken = + options?.supabaseAccessToken ?? + process.env.SUPABASE_ACCESS_TOKEN ?? + ctx.env.SUPABASE_ACCESS_TOKEN; + const branch = options?.branch ?? ctx.branch; + const envVarPrefix = options?.envVarPrefix ?? ""; + const outputEnvVars = SUPABASE_ENV_VARS; + + // Skip the whole process for Vercel environments + if (ctx.env.VERCEL) { + return []; + } + + if (!projectId) { + throw new Error( + "syncSupabaseEnvVars: you did not pass in a projectId or set the SUPABASE_PROJECT_ID env var." + ); + } + + if (!supabaseAccessToken) { + throw new Error( + "syncSupabaseEnvVars: you did not pass in a supabaseAccessToken or set the SUPABASE_ACCESS_TOKEN env var." + ); + } + + // Skip branch-specific logic for production environment + if (ctx.environment === "prod") { + return []; + } + + if (!branch) { + throw new Error( + "syncSupabaseEnvVars: you did not pass in a branch and no branch was detected from context." + ); + } + + if (ctx.environment === "dev") { + // Skip syncing for development environment + return []; + } + + const headers = { + Authorization: `Bearer ${supabaseAccessToken}`, + }; + + try { + // Step 1: List branches and find the matching one by git_branch + const branchesUrl = `https://api.supabase.com/v1/projects/${projectId}/branches`; + const branchesResponse = await fetch(branchesUrl, { headers }); + + if (!branchesResponse.ok) { + throw new Error(`Failed to fetch Supabase branches: ${branchesResponse.status}`); + } + + const branches: SupabaseBranch[] = await branchesResponse.json(); + + if (branches.length === 0) { + return []; + } + + const matchingBranch = branches.find( + (b) => b.git_branch === branch || b.name === branch + ); + + if (!matchingBranch) { + // No matching branch found + return []; + } + + // Step 2: Get branch configuration (connection details) + const branchDetailUrl = `https://api.supabase.com/v1/branches/${matchingBranch.id}`; + const branchDetailResponse = await fetch(branchDetailUrl, { headers }); + + if (!branchDetailResponse.ok) { + throw new Error( + `Failed to fetch Supabase branch details: ${branchDetailResponse.status}` + ); + } + + const branchDetail: SupabaseBranchDetail = await branchDetailResponse.json(); + + // Step 3: Get API keys for the branch project + const apiKeysUrl = `https://api.supabase.com/v1/projects/${branchDetail.ref}/api-keys`; + const apiKeysResponse = await fetch(apiKeysUrl, { headers }); + + let anonKey: string | undefined; + let serviceRoleKey: string | undefined; + + if (apiKeysResponse.ok) { + const apiKeys: SupabaseApiKey[] = await apiKeysResponse.json(); + anonKey = apiKeys.find((k) => k.name === "anon")?.api_key; + serviceRoleKey = apiKeys.find((k) => k.name === "service_role")?.api_key; + } + + // Step 4: Build environment variable mappings + const envVarMappings = buildSupabaseEnvVarMappings({ + user: branchDetail.db_user, + password: branchDetail.db_pass, + host: branchDetail.db_host, + port: branchDetail.db_port, + database: "postgres", + ref: branchDetail.ref, + jwtSecret: branchDetail.jwt_secret, + anonKey, + serviceRoleKey, + }); + + // Build output env vars + const newEnvVars: EnvVar[] = []; + + for (const supabaseEnvVar of outputEnvVars) { + const prefixedKey = `${envVarPrefix}${supabaseEnvVar}`; + if (envVarMappings[supabaseEnvVar]) { + newEnvVars.push({ + name: prefixedKey, + value: envVarMappings[supabaseEnvVar], + }); + } + } + + return newEnvVars; + } catch (error) { + console.error("Error fetching Supabase branch environment variables:", error); + throw error; + } + }); + + return { + name: "SyncSupabaseEnvVarsExtension", + async onBuildComplete(context, manifest) { + await sync.onBuildComplete?.(context, manifest); + }, + }; +}