diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index cae6dcce41..1f9fe2c743 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -92,7 +92,11 @@ jobs: - name: Update functions env: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }} - run: supabase functions deploy + # Capgo cloud publishes allowlisted functions only (pg_net + functions.invoke). + # Self-hosting keeps deploying all functions via `supabase functions deploy`. + run: | + mapfile -t FUNCTIONS < <(bun scripts/supabase-cloud-functions.ts list) + supabase functions deploy "${FUNCTIONS[@]}" read_replica_schema: needs: changes diff --git a/README.md b/README.md index f562511515..cd47f9572f 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,10 @@ We support both deployments for practical reasons: In production, we route most traffic through Cloudflare Workers for cost and scale, while Supabase remains the reference backend and the default for -self-hosted deployments. Private endpoints and trigger/CRON workloads still run -on Supabase in production. +self-hosted deployments. Capgo cloud still publishes the Supabase functions used +by Postgres `pg_net` (`triggers`) and by `supabase.functions.invoke` from the +console/CLI (those always target `SUPABASE_URL`, not Cloudflare). Plugin hot +paths and other unused Capgo cloud endpoints are not published on Supabase. ## Project structure (self-hosting map) @@ -448,7 +450,22 @@ Seed the secret for functions: supabase secrets set --env-file supabase/functions/.env ``` -Push the functions to the cloud: +### Deploy Capgo Cloud Supabase functions + +Capgo Cloud (prod / preprod / alpha) only publishes allowlisted Supabase +functions from `scripts/supabase-cloud-functions.ts`: `triggers` for `pg_net`, +plus every function still reached by console/CLI `supabase.functions.invoke` +(`private`, `apikey`, `app`, `bundle`, `channel`, `files`, `organization`, +`statistics`, `webhooks`). Plugin hot paths and unused ops endpoints are skipped. + +```bash +bun run deploy:supabase:prod +# or: bunx supabase functions deploy $(bun scripts/supabase-cloud-functions.ts deploy-args) --project-ref +``` + +### Deploy self-hosted Supabase functions + +Self-hosted installs should keep deploying every function: ```bash supabase functions deploy diff --git a/package.json b/package.json index aca9b28052..efc93e2e28 100644 --- a/package.json +++ b/package.json @@ -184,8 +184,8 @@ "deploy:cloudflare_env:plugin_me:prod": "bunx wrangler secret bulk internal/cloudflare/.env.prod --config cloudflare_workers/plugin/wrangler.jsonc --env=prod_me", "deploy:cloudflare_env:plugin_hk:prod": "bunx wrangler secret bulk internal/cloudflare/.env.prod --config cloudflare_workers/plugin/wrangler.jsonc --env=prod_hk", "deploy:cloudflare_env:plugin_jp:prod": "bunx wrangler secret bulk internal/cloudflare/.env.prod --config cloudflare_workers/plugin/wrangler.jsonc --env=prod_jp", - "deploy:supabase:prod": "bunx supabase functions deploy --project-ref xvwzpoazmxkqosrdewyv", - "deploy:supabase:preprod": "bunx supabase functions deploy --project-ref ibwjdnhknbkcqfbabwei", + "deploy:supabase:prod": "bunx supabase functions deploy $(bun scripts/supabase-cloud-functions.ts deploy-args) --project-ref xvwzpoazmxkqosrdewyv", + "deploy:supabase:preprod": "bunx supabase functions deploy $(bun scripts/supabase-cloud-functions.ts deploy-args) --project-ref ibwjdnhknbkcqfbabwei", "deploy:supabase_env:prod": "bunx supabase secrets set --project-ref xvwzpoazmxkqosrdewyv --env-file internal/cloudflare/.env.prod", "deploy:supabase_env:preprod": "bunx supabase secrets set --project-ref ibwjdnhknbkcqfbabwei --env-file internal/cloudflare/.env.preprod", "deploy:supabase_env:dev": "bunx supabase secrets set --project-ref aucsybvnhavogdmzwtcw --env-file internal/cloudflare/.env.alpha", diff --git a/scripts/supabase-cloud-functions.ts b/scripts/supabase-cloud-functions.ts new file mode 100644 index 0000000000..db6d95c63e --- /dev/null +++ b/scripts/supabase-cloud-functions.ts @@ -0,0 +1,77 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +/** + * Capgo cloud (prod/preprod/alpha) Supabase Edge Functions that must stay + * published on sb.capgo.app. + * + * Keep: + * - `triggers` — Postgres pg_net calls /functions/v1/triggers/queue_consumer/sync + * - console/CLI `supabase.functions.invoke(...)` targets (SDK always uses + * SUPABASE_URL, not api.capgo.app) + * + * Skip (Cloudflare or unused on Capgo cloud Supabase): + * plugin hot paths, device public API, build, notifications, ops probes, etc. + * Self-hosted installs still deploy every function under supabase/functions/. + */ +export const CAPGO_CLOUD_SUPABASE_FUNCTIONS = [ + 'apikey', + 'app', + 'bundle', + 'channel', + 'files', + 'organization', + 'private', + 'statistics', + 'triggers', + 'webhooks', +] as const + +export type CapgoCloudSupabaseFunction = typeof CAPGO_CLOUD_SUPABASE_FUNCTIONS[number] + +const SKIP_FUNCTION_DIRS = new Set([ + '_backend', + 'shared', + 'plugin_runtime', +]) + +export function listLocalSupabaseFunctions(functionsDir = join(process.cwd(), 'supabase', 'functions')): string[] { + return readdirSync(functionsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !SKIP_FUNCTION_DIRS.has(entry.name)) + .map(entry => entry.name) + .sort() +} + +export function listCapgoCloudSkippedSupabaseFunctions(localFunctions = listLocalSupabaseFunctions()): string[] { + const keep = new Set(CAPGO_CLOUD_SUPABASE_FUNCTIONS) + return localFunctions.filter(name => !keep.has(name)) +} + +export function buildCapgoCloudSupabaseDeployArgs( + functions: readonly string[] = CAPGO_CLOUD_SUPABASE_FUNCTIONS, +): string[] { + if (functions.length === 0) + throw new Error('CAPGO_CLOUD_SUPABASE_FUNCTIONS must not be empty') + return [...functions] +} + +if (import.meta.main) { + const mode = process.argv[2] ?? 'deploy-args' + if (mode === 'list') { + for (const name of CAPGO_CLOUD_SUPABASE_FUNCTIONS) + console.log(name) + process.exit(0) + } + if (mode === 'skip-list') { + for (const name of listCapgoCloudSkippedSupabaseFunctions()) + console.log(name) + process.exit(0) + } + if (mode === 'deploy-args') { + console.log(buildCapgoCloudSupabaseDeployArgs().join(' ')) + process.exit(0) + } + console.error(`Unknown mode: ${mode}. Use deploy-args | list | skip-list`) + process.exit(1) +} diff --git a/tests/supabase-cloud-functions.unit.test.ts b/tests/supabase-cloud-functions.unit.test.ts new file mode 100644 index 0000000000..81218c8227 --- /dev/null +++ b/tests/supabase-cloud-functions.unit.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { + buildCapgoCloudSupabaseDeployArgs, + CAPGO_CLOUD_SUPABASE_FUNCTIONS, + listCapgoCloudSkippedSupabaseFunctions, + listLocalSupabaseFunctions, +} from '../scripts/supabase-cloud-functions.ts' + +describe('supabase cloud function allowlist', () => { + it('keeps SDK invoke + pg_net Capgo cloud functions', () => { + expect([...CAPGO_CLOUD_SUPABASE_FUNCTIONS]).toEqual([ + 'apikey', + 'app', + 'bundle', + 'channel', + 'files', + 'organization', + 'private', + 'statistics', + 'triggers', + 'webhooks', + ]) + expect(buildCapgoCloudSupabaseDeployArgs()).toEqual([...CAPGO_CLOUD_SUPABASE_FUNCTIONS]) + }) + + it('rejects an empty explicit deploy list', () => { + expect(() => buildCapgoCloudSupabaseDeployArgs([])).toThrow(/must not be empty/) + }) + + it('passes through multiple explicit deploy targets', () => { + expect(buildCapgoCloudSupabaseDeployArgs(['triggers', 'ok'])).toEqual(['triggers', 'ok']) + }) + + it('skips plugin/ops functions not used via supabase.functions.invoke', () => { + const local = listLocalSupabaseFunctions() + expect(local).toContain('triggers') + expect(local).toContain('private') + expect(local).toContain('updates') + expect(local).toContain('stats') + + const skipped = listCapgoCloudSkippedSupabaseFunctions(local) + for (const keep of CAPGO_CLOUD_SUPABASE_FUNCTIONS) + expect(skipped).not.toContain(keep) + + expect(skipped).toContain('updates') + expect(skipped).toContain('stats') + expect(skipped).toContain('channel_self') + expect(skipped).toContain('updates_debug') + expect(skipped).toContain('device') + expect(skipped).toContain('build') + expect(skipped).toContain('ok') + expect(skipped).toContain('queue_health') + expect(skipped.length).toBe(local.length - CAPGO_CLOUD_SUPABASE_FUNCTIONS.length) + }) +})