From e064981404475ee25c9d17b9081502323c5c98a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:18:24 +0000 Subject: [PATCH 01/17] fix(db): block org customer_id writes without org.update_billing Add a BEFORE UPDATE trigger on public.orgs.customer_id so PostgREST callers with org.update_settings alone cannot mutate the Stripe customer pointer. Internal service paths and principals with org.update_billing remain allowed. Includes pgTAP and integration regression tests. Co-authored-by: Martin DONADIEU --- ...0260826061707_org_billing_column_guard.sql | 41 ++++ .../73_test_org_billing_column_guard.sql | 122 ++++++++++ tests/org-billing-column-guard.test.ts | 215 ++++++++++++++++++ 3 files changed, 378 insertions(+) create mode 100644 supabase/migrations/20260826061707_org_billing_column_guard.sql create mode 100644 supabase/tests/73_test_org_billing_column_guard.sql create mode 100644 tests/org-billing-column-guard.test.ts diff --git a/supabase/migrations/20260826061707_org_billing_column_guard.sql b/supabase/migrations/20260826061707_org_billing_column_guard.sql new file mode 100644 index 0000000000..3e2251ec8b --- /dev/null +++ b/supabase/migrations/20260826061707_org_billing_column_guard.sql @@ -0,0 +1,41 @@ +CREATE OR REPLACE FUNCTION "public"."guard_org_billing_columns"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + v_request_role text := public.current_request_role(); +BEGIN + IF public.is_internal_request_role(v_request_role) THEN + RETURN NEW; + END IF; + + IF NEW.customer_id IS DISTINCT FROM OLD.customer_id THEN + IF NOT public.rbac_check_permission_request( + public.rbac_perm_org_update_billing(), + NEW.id, + NULL::character varying, + NULL::bigint + ) THEN + RAISE EXCEPTION 'PERMISSION_DENIED_ORG_UPDATE_BILLING' + USING ERRCODE = '42501'; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."guard_org_billing_columns"() OWNER TO "postgres"; + +REVOKE ALL ON FUNCTION "public"."guard_org_billing_columns"() FROM PUBLIC; + +GRANT ALL ON FUNCTION "public"."guard_org_billing_columns"() TO "service_role"; + +COMMENT ON FUNCTION "public"."guard_org_billing_columns"() IS 'Blocks user-context writes to org.customer_id unless the caller has org.update_billing. Internal/service paths remain unrestricted.'; + +DROP TRIGGER IF EXISTS "guard_org_billing_columns" ON "public"."orgs"; + +CREATE TRIGGER "guard_org_billing_columns" + BEFORE UPDATE OF "customer_id" ON "public"."orgs" + FOR EACH ROW + EXECUTE FUNCTION "public"."guard_org_billing_columns"(); diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql new file mode 100644 index 0000000000..82abfd72c7 --- /dev/null +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -0,0 +1,122 @@ +-- org.customer_id must not be writable via PostgREST unless org.update_billing is granted. +BEGIN; + +SELECT plan(4); + +SELECT tests.authenticate_as_service_role(); +SELECT tests.create_supabase_user('org_billing_guard_admin', 'org_billing_guard_admin@test.local'); +SELECT tests.create_supabase_user('org_billing_guard_super', 'org_billing_guard_super@test.local'); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES + (tests.get_supabase_uid('org_billing_guard_admin'), 'org_billing_guard_admin@test.local', NOW(), NOW()), + (tests.get_supabase_uid('org_billing_guard_super'), 'org_billing_guard_super@test.local', NOW(), NOW()) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.stripe_info ( + customer_id, + status, + product_id, + subscription_id, + trial_at, + is_good_plan +) +VALUES ( + 'cus_org_billing_guard_730001', + 'succeeded', + 'prod_LQIregjtNduh4q', + 'sub_org_billing_guard_730001', + NOW() + INTERVAL '15 days', + true +) +ON CONFLICT (customer_id) DO NOTHING; + +INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) +VALUES ( + '73000000-0000-4000-8000-000000000073', + tests.get_supabase_uid('org_billing_guard_admin'), + 'Org billing column guard', + 'org-billing-guard@test.local', + 'cus_org_billing_guard_730001' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.role_bindings ( + principal_type, + principal_id, + role_id, + scope_type, + org_id, + granted_by, + reason, + is_direct +) +SELECT + public.rbac_principal_user(), + members.user_id, + roles.id, + public.rbac_scope_org(), + '73000000-0000-4000-8000-000000000073'::uuid, + tests.get_supabase_uid('org_billing_guard_admin'), + 'pgTAP org billing column guard fixture', + true +FROM ( + VALUES + (tests.get_supabase_uid('org_billing_guard_admin'), public.rbac_role_org_admin()), + (tests.get_supabase_uid('org_billing_guard_super'), public.rbac_role_org_super_admin()) +) AS members(user_id, role_name) +CROSS JOIN public.roles AS roles +WHERE roles.name = members.role_name + AND roles.scope_type = public.rbac_scope_org() +ON CONFLICT DO NOTHING; + +SELECT tests.authenticate_as('org_billing_guard_admin'); + +SELECT throws_ok( + $$ + UPDATE public.orgs + SET customer_id = NULL + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + '42501', + 'PERMISSION_DENIED_ORG_UPDATE_BILLING', + 'org_admin cannot mutate customer_id without org.update_billing' +); + +SELECT lives_ok( + $$ + UPDATE public.orgs + SET name = 'Org billing column guard updated' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + 'org_admin can still update org settings columns' +); + +SELECT tests.authenticate_as_service_role(); + +SELECT lives_ok( + $$ + UPDATE public.orgs + SET customer_id = 'cus_org_billing_guard_730001' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + 'service_role can update org customer_id' +); + +SELECT tests.authenticate_as('org_billing_guard_super'); + +SELECT lives_ok( + $$ + UPDATE public.orgs + SET customer_id = 'cus_org_billing_guard_730001' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + 'org_super_admin can update org customer_id with org.update_billing' +); + +SELECT tests.clear_authentication(); +SELECT set_config('request.headers', '{}', true); + +SELECT * FROM finish(); + +ROLLBACK; diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts new file mode 100644 index 0000000000..804260e6c3 --- /dev/null +++ b/tests/org-billing-column-guard.test.ts @@ -0,0 +1,215 @@ +import type { Database } from '~/types/supabase.types' +import { randomUUID } from 'node:crypto' +import { env } from 'node:process' +import { createClient } from '@supabase/supabase-js' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + executeSQL, + getSupabaseClient, +} from './test-utils.ts' + +const SUPABASE_URL = (env.SUPABASE_URL ?? '').replace(/\/$/, '') +const SUPABASE_ANON_KEY = env.SUPABASE_ANON_KEY ?? '' +const USE_CLOUDFLARE_WORKERS = env.USE_CLOUDFLARE_WORKERS === 'true' + +if (!SUPABASE_URL) + throw new Error('SUPABASE_URL is required for org billing column guard tests') +if (!SUPABASE_ANON_KEY) + throw new Error('SUPABASE_ANON_KEY is required for org billing column guard tests') + +const serviceRoleSupabase = getSupabaseClient() + +const fixtureId = randomUUID() +const orgId = randomUUID() +let settingsAdminUserId = randomUUID() +let billingSuperAdminUserId = randomUUID() +const settingsAdminEmail = `org-billing-guard-admin-${fixtureId}@capgo.test` +const billingSuperAdminEmail = `org-billing-guard-super-${fixtureId}@capgo.test` +const testPassword = `Capgo!${fixtureId}` +const originalCustomerId = `cus_org_billing_guard_${fixtureId.replaceAll('-', '').slice(0, 18)}` +const replacementCustomerId = `cus_org_billing_guard_alt_${fixtureId.replaceAll('-', '').slice(0, 14)}` + +async function createAuthenticatedClient(email: string, password: string) { + const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + auth: { + persistSession: false, + autoRefreshToken: false, + detectSessionInUrl: false, + }, + }) + + const { error } = await client.auth.signInWithPassword({ email, password }) + if (error) + throw error + + return client +} + +async function bindOrgRole(userId: string, roleName: string) { + const [role] = await executeSQL( + `SELECT id FROM public.roles WHERE name = $1 AND scope_type = public.rbac_scope_org() LIMIT 1`, + [roleName], + ) + if (!role?.id) + throw new Error(`Unable to resolve org role ${roleName}`) + + await executeSQL( + `INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), $1::uuid, $2::uuid, public.rbac_scope_org(), $3::uuid, + $1::uuid, 'org billing column guard fixture', true + ) + ON CONFLICT DO NOTHING`, + [userId, role.id, orgId], + ) +} + +describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { + let settingsAdminClient: Awaited> + let billingSuperAdminClient: Awaited> + + beforeAll(async () => { + const { data: settingsAdminAuth, error: settingsAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ + email: settingsAdminEmail, + password: testPassword, + email_confirm: true, + }) + if (settingsAdminAuthError) + throw settingsAdminAuthError + + const { data: billingSuperAdminAuth, error: billingSuperAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ + email: billingSuperAdminEmail, + password: testPassword, + email_confirm: true, + }) + if (billingSuperAdminAuthError) + throw billingSuperAdminAuthError + + await executeSQL( + `INSERT INTO public.users (id, email, first_name) + VALUES ($1::uuid, $2, 'Org Billing Guard Admin'), ($3::uuid, $4, 'Org Billing Guard Super') + ON CONFLICT (id) DO NOTHING`, + [settingsAdminAuth.user.id, settingsAdminEmail, billingSuperAdminAuth.user.id, billingSuperAdminEmail], + ) + + settingsAdminUserId = settingsAdminAuth.user.id + billingSuperAdminUserId = billingSuperAdminAuth.user.id + + await executeSQL( + `INSERT INTO public.stripe_info ( + customer_id, status, product_id, subscription_id, trial_at, is_good_plan + ) VALUES ($1, 'succeeded', 'prod_LQIregjtNduh4q', $2, NOW() + INTERVAL '15 days', true), + ($3, 'succeeded', 'prod_LQIregjtNduh4q', $4, NOW() + INTERVAL '15 days', true)`, + [originalCustomerId, `sub_${fixtureId}`, replacementCustomerId, `sub_alt_${fixtureId}`], + ) + + await executeSQL( + `INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) + VALUES ($1::uuid, $2::uuid, $3, $4, $5)`, + [orgId, settingsAdminUserId, `Org billing guard ${fixtureId}`, settingsAdminEmail, originalCustomerId], + ) + + await executeSQL( + `INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES ($1::uuid, $3::uuid, public.rbac_role_org_admin(), false), + ($2::uuid, $3::uuid, public.rbac_role_org_super_admin(), false) + ON CONFLICT DO NOTHING`, + [settingsAdminUserId, billingSuperAdminUserId, orgId], + ) + + await bindOrgRole(settingsAdminUserId, 'org_admin') + await bindOrgRole(billingSuperAdminUserId, 'org_super_admin') + + settingsAdminClient = await createAuthenticatedClient(settingsAdminEmail, testPassword) + billingSuperAdminClient = await createAuthenticatedClient(billingSuperAdminEmail, testPassword) + }) + + afterAll(async () => { + await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [orgId]) + await executeSQL( + 'DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', + [[originalCustomerId, replacementCustomerId]], + ) + await serviceRoleSupabase.auth.admin.deleteUser(settingsAdminUserId) + await serviceRoleSupabase.auth.admin.deleteUser(billingSuperAdminUserId) + }) + + it('blocks org.update_settings principals from changing customer_id via PostgREST', async () => { + const { data, error } = await settingsAdminClient + .from('orgs') + .update({ customer_id: null }) + .eq('id', orgId) + .select('customer_id') + + expect(error?.code).toBe('42501') + expect(error?.message ?? '').toContain('PERMISSION_DENIED_ORG_UPDATE_BILLING') + expect(data).toBeNull() + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('customer_id') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.customer_id).toBe(originalCustomerId) + }) + + it('still allows org.update_settings principals to change cosmetic org fields', async () => { + const updatedName = `Org billing guard renamed ${fixtureId}` + + const { error } = await settingsAdminClient + .from('orgs') + .update({ name: updatedName }) + .eq('id', orgId) + + expect(error).toBeNull() + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('name') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.name).toBe(updatedName) + }) + + it('allows org.update_billing principals to change customer_id via PostgREST', async () => { + const { error } = await billingSuperAdminClient + .from('orgs') + .update({ customer_id: replacementCustomerId }) + .eq('id', orgId) + + expect(error).toBeNull() + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('customer_id') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.customer_id).toBe(replacementCustomerId) + }) + + it('allows service_role to change customer_id via PostgREST', async () => { + const { error } = await serviceRoleSupabase + .from('orgs') + .update({ customer_id: originalCustomerId }) + .eq('id', orgId) + + expect(error).toBeNull() + + const { data: orgRow, error: readError } = await serviceRoleSupabase + .from('orgs') + .select('customer_id') + .eq('id', orgId) + .single() + + expect(readError).toBeNull() + expect(orgRow?.customer_id).toBe(originalCustomerId) + }) +}) From 7f38013eb2f0f10873e322c71a5eb172724c4c3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:24:28 +0000 Subject: [PATCH 02/17] test: fix org billing guard user id typing Co-authored-by: Martin DONADIEU --- tests/org-billing-column-guard.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts index 804260e6c3..c161f2182f 100644 --- a/tests/org-billing-column-guard.test.ts +++ b/tests/org-billing-column-guard.test.ts @@ -21,8 +21,8 @@ const serviceRoleSupabase = getSupabaseClient() const fixtureId = randomUUID() const orgId = randomUUID() -let settingsAdminUserId = randomUUID() -let billingSuperAdminUserId = randomUUID() +let settingsAdminUserId: string +let billingSuperAdminUserId: string const settingsAdminEmail = `org-billing-guard-admin-${fixtureId}@capgo.test` const billingSuperAdminEmail = `org-billing-guard-super-${fixtureId}@capgo.test` const testPassword = `Capgo!${fixtureId}` From 8082e60a85fba7c342127f0541938cd8dff0b158 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:35:59 +0000 Subject: [PATCH 03/17] test: isolate org billing guard fixtures from creator bootstrap Co-authored-by: Martin DONADIEU --- supabase/tests/73_test_org_billing_column_guard.sql | 4 +++- tests/org-billing-column-guard.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql index 82abfd72c7..9fc99cc8e1 100644 --- a/supabase/tests/73_test_org_billing_column_guard.sql +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -34,7 +34,7 @@ ON CONFLICT (customer_id) DO NOTHING; INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) VALUES ( '73000000-0000-4000-8000-000000000073', - tests.get_supabase_uid('org_billing_guard_admin'), + tests.get_supabase_uid('org_billing_guard_super'), 'Org billing column guard', 'org-billing-guard@test.local', 'cus_org_billing_guard_730001' @@ -93,6 +93,8 @@ SELECT lives_ok( ); SELECT tests.authenticate_as_service_role(); +SET LOCAL ROLE service_role; +SET LOCAL "request.jwt.claim.role" = 'service_role'; SELECT lives_ok( $$ diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts index c161f2182f..3f35686d61 100644 --- a/tests/org-billing-column-guard.test.ts +++ b/tests/org-billing-column-guard.test.ts @@ -108,7 +108,7 @@ describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { await executeSQL( `INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) VALUES ($1::uuid, $2::uuid, $3, $4, $5)`, - [orgId, settingsAdminUserId, `Org billing guard ${fixtureId}`, settingsAdminEmail, originalCustomerId], + [orgId, billingSuperAdminUserId, `Org billing guard ${fixtureId}`, settingsAdminEmail, originalCustomerId], ) await executeSQL( From 8618d1a2bbc7597beb0f4e0aa16cd7b2ec808c9e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 07:02:25 +0000 Subject: [PATCH 04/17] test: strengthen org billing guard pgTAP assertions Co-authored-by: Martin DONADIEU --- .../73_test_org_billing_column_guard.sql | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql index 9fc99cc8e1..6c57716f9e 100644 --- a/supabase/tests/73_test_org_billing_column_guard.sql +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -21,14 +21,23 @@ INSERT INTO public.stripe_info ( trial_at, is_good_plan ) -VALUES ( - 'cus_org_billing_guard_730001', - 'succeeded', - 'prod_LQIregjtNduh4q', - 'sub_org_billing_guard_730001', - NOW() + INTERVAL '15 days', - true -) +VALUES + ( + 'cus_org_billing_guard_730001', + 'succeeded', + 'prod_LQIregjtNduh4q', + 'sub_org_billing_guard_730001', + NOW() + INTERVAL '15 days', + true + ), + ( + 'cus_org_billing_guard_730002', + 'succeeded', + 'prod_LQIregjtNduh4q', + 'sub_org_billing_guard_730002', + NOW() + INTERVAL '15 days', + true + ) ON CONFLICT (customer_id) DO NOTHING; INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) @@ -62,8 +71,7 @@ SELECT true FROM ( VALUES - (tests.get_supabase_uid('org_billing_guard_admin'), public.rbac_role_org_admin()), - (tests.get_supabase_uid('org_billing_guard_super'), public.rbac_role_org_super_admin()) + (tests.get_supabase_uid('org_billing_guard_admin'), public.rbac_role_org_admin()) ) AS members(user_id, role_name) CROSS JOIN public.roles AS roles WHERE roles.name = members.role_name @@ -99,7 +107,7 @@ SET LOCAL "request.jwt.claim.role" = 'service_role'; SELECT lives_ok( $$ UPDATE public.orgs - SET customer_id = 'cus_org_billing_guard_730001' + SET customer_id = 'cus_org_billing_guard_730002' WHERE id = '73000000-0000-4000-8000-000000000073'::uuid $$, 'service_role can update org customer_id' @@ -113,7 +121,7 @@ SELECT lives_ok( SET customer_id = 'cus_org_billing_guard_730001' WHERE id = '73000000-0000-4000-8000-000000000073'::uuid $$, - 'org_super_admin can update org customer_id with org.update_billing' + 'org creator super admin can update org customer_id with org.update_billing' ); SELECT tests.clear_authentication(); From 3268af6f5c313bb9e34d7d3f847563e528fc7781 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 07:18:06 +0000 Subject: [PATCH 05/17] ci: retrigger workflow after Cloudflare shard flake Co-authored-by: Martin DONADIEU From b34457c1f5e80fcccac7c6c4222b959fb60072ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 07:52:27 +0000 Subject: [PATCH 06/17] fix(db): address CodeRabbit review on org billing guard Document guard_org_billing_columns execution profile and RBAC index path in the migration. Route PostgREST regression tests through getEndpointUrl (/rest/) so they run under Cloudflare CI too, and drop redundant super-admin fixture bindings that org creation already grants. Co-authored-by: Martin DONADIEU --- ...0260826061707_org_billing_column_guard.sql | 25 ++++- tests/org-billing-column-guard.test.ts | 97 +++++++++---------- tests/test-utils.ts | 4 + 3 files changed, 74 insertions(+), 52 deletions(-) diff --git a/supabase/migrations/20260826061707_org_billing_column_guard.sql b/supabase/migrations/20260826061707_org_billing_column_guard.sql index 3e2251ec8b..4010075c6a 100644 --- a/supabase/migrations/20260826061707_org_billing_column_guard.sql +++ b/supabase/migrations/20260826061707_org_billing_column_guard.sql @@ -1,3 +1,23 @@ +-- Block direct PostgREST writes to orgs.customer_id unless the caller has org.update_billing. +-- Internal/service paths bypass via is_internal_request_role. +-- +-- Execution profile for guard_org_billing_columns (BEFORE UPDATE OF customer_id): +-- - Frequency: at most once per row when customer_id actually changes (trigger column list +-- skips name/settings-only org updates). Console-scale billing writes, not plugin hot path. +-- - Roles: authenticated and anon (capgkey) via PostgREST; service_role/postgres bypass the +-- RBAC gate through is_internal_request_role(current_request_role()). +-- - Authorization path: one rbac_check_permission_request(org.update_billing, org_id, NULL, NULL) +-- per guarded update, which resolves auth.uid()/capgkey once and walks org-scoped role_bindings. +-- - Cardinality: role_bindings per (principal, org) are typically single-digit; permission +-- inheritance stays bounded to that org scope (no app/channel fan-out for this check). +-- - Indexes: role_bindings_principal_scope_idx (principal_type, principal_id, scope_type, org_id, +-- app_id, channel_id); role_bindings_scope_idx (scope_type, org_id, app_id, channel_id); +-- role_bindings_principal_org_idx when present (principal_type, principal_id, org_id, expires_at). +-- - Worst case (authenticated org member with many bindings): Index Scan on +-- role_bindings_principal_scope_idx with org_id/scope_type filters; nested permission-role +-- lookups stay bounded to the caller's bindings. No sequential scan over role_bindings in +-- EXPLAIN (ANALYZE, BUFFERS) on local seed data for org-scoped org.update_billing checks. + CREATE OR REPLACE FUNCTION "public"."guard_org_billing_columns"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER SET "search_path" TO '' @@ -31,7 +51,10 @@ REVOKE ALL ON FUNCTION "public"."guard_org_billing_columns"() FROM PUBLIC; GRANT ALL ON FUNCTION "public"."guard_org_billing_columns"() TO "service_role"; -COMMENT ON FUNCTION "public"."guard_org_billing_columns"() IS 'Blocks user-context writes to org.customer_id unless the caller has org.update_billing. Internal/service paths remain unrestricted.'; +COMMENT ON FUNCTION "public"."guard_org_billing_columns"() IS + 'BEFORE UPDATE OF customer_id guard. Runs once per changed customer_id (console billing writes, not plugin /updates). ' + 'User-context callers need org.update_billing via rbac_check_permission_request; service_role/postgres bypass. ' + 'Org-scoped RBAC lookups use role_bindings_principal_scope_idx / role_bindings_scope_idx (Index Scan at seed scale).'; DROP TRIGGER IF EXISTS "guard_org_billing_columns" ON "public"."orgs"; diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts index 3f35686d61..4f0ab1f3b5 100644 --- a/tests/org-billing-column-guard.test.ts +++ b/tests/org-billing-column-guard.test.ts @@ -1,18 +1,16 @@ -import type { Database } from '~/types/supabase.types' import { randomUUID } from 'node:crypto' -import { env } from 'node:process' -import { createClient } from '@supabase/supabase-js' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { executeSQL, + fetchTestRequest, + getAuthHeadersForCredentials, + getEndpointUrl, getSupabaseClient, + SUPABASE_ANON_KEY, + SUPABASE_BASE_URL, } from './test-utils.ts' -const SUPABASE_URL = (env.SUPABASE_URL ?? '').replace(/\/$/, '') -const SUPABASE_ANON_KEY = env.SUPABASE_ANON_KEY ?? '' -const USE_CLOUDFLARE_WORKERS = env.USE_CLOUDFLARE_WORKERS === 'true' - -if (!SUPABASE_URL) +if (!SUPABASE_BASE_URL) throw new Error('SUPABASE_URL is required for org billing column guard tests') if (!SUPABASE_ANON_KEY) throw new Error('SUPABASE_ANON_KEY is required for org billing column guard tests') @@ -29,22 +27,6 @@ const testPassword = `Capgo!${fixtureId}` const originalCustomerId = `cus_org_billing_guard_${fixtureId.replaceAll('-', '').slice(0, 18)}` const replacementCustomerId = `cus_org_billing_guard_alt_${fixtureId.replaceAll('-', '').slice(0, 14)}` -async function createAuthenticatedClient(email: string, password: string) { - const client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { - auth: { - persistSession: false, - autoRefreshToken: false, - detectSessionInUrl: false, - }, - }) - - const { error } = await client.auth.signInWithPassword({ email, password }) - if (error) - throw error - - return client -} - async function bindOrgRole(userId: string, roleName: string) { const [role] = await executeSQL( `SELECT id FROM public.roles WHERE name = $1 AND scope_type = public.rbac_scope_org() LIMIT 1`, @@ -66,9 +48,31 @@ async function bindOrgRole(userId: string, roleName: string) { ) } -describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { - let settingsAdminClient: Awaited> - let billingSuperAdminClient: Awaited> +function withRestHeaders(headers: Record) { + return { + ...headers, + 'apikey': SUPABASE_ANON_KEY, + 'Content-Type': 'application/json', + } +} + +async function patchOrg(headers: Record, body: Record) { + const response = await fetchTestRequest(getEndpointUrl(`/rest/v1/orgs?id=eq.${orgId}`), { + method: 'PATCH', + headers: { + ...withRestHeaders(headers), + Prefer: 'return=representation', + }, + body: JSON.stringify(body), + }) + const text = await response.text() + const data = text ? JSON.parse(text) as unknown : null + return { response, data } +} + +describe('org billing column guard', () => { + let settingsAdminHeaders: Record + let billingSuperAdminHeaders: Record beforeAll(async () => { const { data: settingsAdminAuth, error: settingsAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ @@ -113,17 +117,15 @@ describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { await executeSQL( `INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) - VALUES ($1::uuid, $3::uuid, public.rbac_role_org_admin(), false), - ($2::uuid, $3::uuid, public.rbac_role_org_super_admin(), false) + VALUES ($1::uuid, $2::uuid, public.rbac_role_org_admin(), false) ON CONFLICT DO NOTHING`, - [settingsAdminUserId, billingSuperAdminUserId, orgId], + [settingsAdminUserId, orgId], ) await bindOrgRole(settingsAdminUserId, 'org_admin') - await bindOrgRole(billingSuperAdminUserId, 'org_super_admin') - settingsAdminClient = await createAuthenticatedClient(settingsAdminEmail, testPassword) - billingSuperAdminClient = await createAuthenticatedClient(billingSuperAdminEmail, testPassword) + settingsAdminHeaders = await getAuthHeadersForCredentials(settingsAdminEmail, testPassword) + billingSuperAdminHeaders = await getAuthHeadersForCredentials(billingSuperAdminEmail, testPassword) }) afterAll(async () => { @@ -137,15 +139,13 @@ describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { }) it('blocks org.update_settings principals from changing customer_id via PostgREST', async () => { - const { data, error } = await settingsAdminClient - .from('orgs') - .update({ customer_id: null }) - .eq('id', orgId) - .select('customer_id') + const { response, data } = await patchOrg(settingsAdminHeaders, { customer_id: null }) - expect(error?.code).toBe('42501') - expect(error?.message ?? '').toContain('PERMISSION_DENIED_ORG_UPDATE_BILLING') - expect(data).toBeNull() + expect(response.status).toBe(403) + expect(data).toMatchObject({ + code: '42501', + message: 'PERMISSION_DENIED_ORG_UPDATE_BILLING', + }) const { data: orgRow, error: readError } = await serviceRoleSupabase .from('orgs') @@ -160,12 +160,10 @@ describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { it('still allows org.update_settings principals to change cosmetic org fields', async () => { const updatedName = `Org billing guard renamed ${fixtureId}` - const { error } = await settingsAdminClient - .from('orgs') - .update({ name: updatedName }) - .eq('id', orgId) + const { response, data } = await patchOrg(settingsAdminHeaders, { name: updatedName }) - expect(error).toBeNull() + expect(response.status).toBe(200) + expect(Array.isArray(data)).toBe(true) const { data: orgRow, error: readError } = await serviceRoleSupabase .from('orgs') @@ -178,12 +176,9 @@ describe.skipIf(USE_CLOUDFLARE_WORKERS)('org billing column guard', () => { }) it('allows org.update_billing principals to change customer_id via PostgREST', async () => { - const { error } = await billingSuperAdminClient - .from('orgs') - .update({ customer_id: replacementCustomerId }) - .eq('id', orgId) + const { response } = await patchOrg(billingSuperAdminHeaders, { customer_id: replacementCustomerId }) - expect(error).toBeNull() + expect(response.status).toBe(200) const { data: orgRow, error: readError } = await serviceRoleSupabase .from('orgs') diff --git a/tests/test-utils.ts b/tests/test-utils.ts index ab65932ffe..2e60a4ac28 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -136,6 +136,10 @@ export const SUPABASE_ANON_KEY = env.SUPABASE_ANON_KEY ?? '' * All other endpoints go to the API worker */ export function getEndpointUrl(path: string): string { + // PostgREST is always served from the Supabase API host (also under Cloudflare CI). + if (path.startsWith('/rest/')) + return `${SUPABASE_BASE_URL}${path}` + if (!USE_CLOUDFLARE) { // In CI, Node/Undici prefers IPv6 for localhost (::1). Supabase Edge runtime // is bound to IPv4 (127.0.0.1) in the workflow, so normalize to IPv4. From 20a1464403023aca6c155e183f3e63ae69a44f6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 09:41:11 +0000 Subject: [PATCH 07/17] fix(db): wrap org billing guard migration comments for SQLFluff LT05 Keep execution-profile header comments under 80 columns so SQLFluff layout.long_lines passes on the guarded trigger migration. Co-authored-by: Martin DONADIEU --- ...0260826061707_org_billing_column_guard.sql | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/supabase/migrations/20260826061707_org_billing_column_guard.sql b/supabase/migrations/20260826061707_org_billing_column_guard.sql index 4010075c6a..07f031a33c 100644 --- a/supabase/migrations/20260826061707_org_billing_column_guard.sql +++ b/supabase/migrations/20260826061707_org_billing_column_guard.sql @@ -1,22 +1,30 @@ --- Block direct PostgREST writes to orgs.customer_id unless the caller has org.update_billing. --- Internal/service paths bypass via is_internal_request_role. +-- Block direct PostgREST writes to orgs.customer_id unless the caller has +-- org.update_billing. Internal/service paths bypass via +-- is_internal_request_role. -- --- Execution profile for guard_org_billing_columns (BEFORE UPDATE OF customer_id): --- - Frequency: at most once per row when customer_id actually changes (trigger column list --- skips name/settings-only org updates). Console-scale billing writes, not plugin hot path. --- - Roles: authenticated and anon (capgkey) via PostgREST; service_role/postgres bypass the --- RBAC gate through is_internal_request_role(current_request_role()). --- - Authorization path: one rbac_check_permission_request(org.update_billing, org_id, NULL, NULL) --- per guarded update, which resolves auth.uid()/capgkey once and walks org-scoped role_bindings. --- - Cardinality: role_bindings per (principal, org) are typically single-digit; permission --- inheritance stays bounded to that org scope (no app/channel fan-out for this check). --- - Indexes: role_bindings_principal_scope_idx (principal_type, principal_id, scope_type, org_id, --- app_id, channel_id); role_bindings_scope_idx (scope_type, org_id, app_id, channel_id); --- role_bindings_principal_org_idx when present (principal_type, principal_id, org_id, expires_at). +-- Execution profile for guard_org_billing_columns (BEFORE UPDATE OF +-- customer_id): +-- - Frequency: at most once per row when customer_id actually changes (trigger +-- column list skips name/settings-only org updates). Console-scale billing +-- writes, not plugin hot path. +-- - Roles: authenticated and anon (capgkey) via PostgREST; +-- service_role/postgres bypass the RBAC gate through +-- is_internal_request_role(current_request_role()). +-- - Authorization path: one rbac_check_permission_request(org.update_billing, +-- org_id, NULL, NULL) per guarded update, which resolves auth.uid()/capgkey +-- once and walks org-scoped role_bindings. +-- - Cardinality: role_bindings per (principal, org) are typically single-digit; +-- permission inheritance stays bounded to that org scope (no app/channel +-- fan-out for this check). +-- - Indexes: role_bindings_principal_scope_idx (principal_type, principal_id, +-- scope_type, org_id, app_id, channel_id); role_bindings_scope_idx +-- (scope_type, org_id, app_id, channel_id); role_bindings_principal_org_idx +-- when present (principal_type, principal_id, org_id, expires_at). -- - Worst case (authenticated org member with many bindings): Index Scan on --- role_bindings_principal_scope_idx with org_id/scope_type filters; nested permission-role --- lookups stay bounded to the caller's bindings. No sequential scan over role_bindings in --- EXPLAIN (ANALYZE, BUFFERS) on local seed data for org-scoped org.update_billing checks. +-- role_bindings_principal_scope_idx with org_id/scope_type filters; nested +-- permission-role lookups stay bounded to the caller's bindings. No +-- sequential scan over role_bindings in EXPLAIN (ANALYZE, BUFFERS) on local +-- seed data for org-scoped org.update_billing checks. CREATE OR REPLACE FUNCTION "public"."guard_org_billing_columns"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER From 622fa84b3a69b7f892c68c4ed4fb00f81699ebd7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 09:58:45 +0000 Subject: [PATCH 08/17] fix(db): re-stamp org billing guard migration after main advance Rename migration to 20260826095730 so it sorts after 20260826073300_app_fame on main. Content-preserving R100 re-stamp only. Co-authored-by: Martin DONADIEU --- ...lumn_guard.sql => 20260826095730_org_billing_column_guard.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260826061707_org_billing_column_guard.sql => 20260826095730_org_billing_column_guard.sql} (100%) diff --git a/supabase/migrations/20260826061707_org_billing_column_guard.sql b/supabase/migrations/20260826095730_org_billing_column_guard.sql similarity index 100% rename from supabase/migrations/20260826061707_org_billing_column_guard.sql rename to supabase/migrations/20260826095730_org_billing_column_guard.sql From cfee12d53cad621871ebf2b050462f275590d38e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:08:42 +0000 Subject: [PATCH 09/17] fix(db): make org customer_id service-managed only Deny all user/capgkey writes to orgs.customer_id (including org_admin and org_super_admin). Allow service_role/postgres and the org-create bootstrap path only. Route JWT org creation through service_role insert after auth checks. Co-authored-by: Martin DONADIEU --- .../_backend/public/organization/post.ts | 3 +- ...0260826095730_org_billing_column_guard.sql | 156 +++++++++++++----- .../73_test_org_billing_column_guard.sql | 45 +++-- tests/org-billing-column-guard.test.ts | 109 ++++++++---- 4 files changed, 228 insertions(+), 85 deletions(-) diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index 0524c84e98..62b17cf6fd 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -290,7 +290,8 @@ export async function post( await insertOrgForApiKey(c, auth, newOrg) } else { - const { error: errorOrg } = await supabaseWithAuth(c, auth) + // customer_id is service-managed; user JWT cannot write it under org RLS/triggers. + const { error: errorOrg } = await supabaseAdmin(c) .from('orgs') .insert({ ...newOrg, diff --git a/supabase/migrations/20260826095730_org_billing_column_guard.sql b/supabase/migrations/20260826095730_org_billing_column_guard.sql index 07f031a33c..71c0b49897 100644 --- a/supabase/migrations/20260826095730_org_billing_column_guard.sql +++ b/supabase/migrations/20260826095730_org_billing_column_guard.sql @@ -1,30 +1,14 @@ --- Block direct PostgREST writes to orgs.customer_id unless the caller has --- org.update_billing. Internal/service paths bypass via --- is_internal_request_role. +-- Block user-context writes to orgs.customer_id. Only service_role/postgres +-- (is_internal_request_role) and the org-create bootstrap path may set it. -- --- Execution profile for guard_org_billing_columns (BEFORE UPDATE OF +-- Execution profile for guard_org_billing_columns (BEFORE INSERT OR UPDATE OF -- customer_id): --- - Frequency: at most once per row when customer_id actually changes (trigger --- column list skips name/settings-only org updates). Console-scale billing --- writes, not plugin hot path. --- - Roles: authenticated and anon (capgkey) via PostgREST; --- service_role/postgres bypass the RBAC gate through --- is_internal_request_role(current_request_role()). --- - Authorization path: one rbac_check_permission_request(org.update_billing, --- org_id, NULL, NULL) per guarded update, which resolves auth.uid()/capgkey --- once and walks org-scoped role_bindings. --- - Cardinality: role_bindings per (principal, org) are typically single-digit; --- permission inheritance stays bounded to that org scope (no app/channel --- fan-out for this check). --- - Indexes: role_bindings_principal_scope_idx (principal_type, principal_id, --- scope_type, org_id, app_id, channel_id); role_bindings_scope_idx --- (scope_type, org_id, app_id, channel_id); role_bindings_principal_org_idx --- when present (principal_type, principal_id, org_id, expires_at). --- - Worst case (authenticated org member with many bindings): Index Scan on --- role_bindings_principal_scope_idx with org_id/scope_type filters; nested --- permission-role lookups stay bounded to the caller's bindings. No --- sequential scan over role_bindings in EXPLAIN (ANALYZE, BUFFERS) on local --- seed data for org-scoped org.update_billing checks. +-- - Frequency: once per org row when customer_id is supplied on INSERT or +-- changes on UPDATE. Console-scale, not plugin hot path. +-- - Roles: authenticated and anon (capgkey) via PostgREST are always denied +-- unless org-create bootstrap GUC matches the row during AFTER INSERT setup. +-- service_role/postgres bypass via is_internal_request_role(). +-- - Cardinality: single-row trigger; no table scans. CREATE OR REPLACE FUNCTION "public"."guard_org_billing_columns"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER @@ -32,21 +16,28 @@ CREATE OR REPLACE FUNCTION "public"."guard_org_billing_columns"() RETURNS "trigg AS $$ DECLARE v_request_role text := public.current_request_role(); + v_bootstrap_org_id text := pg_catalog.current_setting('capgo.org_creation_bootstrap_org_id', true); BEGIN IF public.is_internal_request_role(v_request_role) THEN RETURN NEW; END IF; - IF NEW.customer_id IS DISTINCT FROM OLD.customer_id THEN - IF NOT public.rbac_check_permission_request( - public.rbac_perm_org_update_billing(), - NEW.id, - NULL::character varying, - NULL::bigint - ) THEN - RAISE EXCEPTION 'PERMISSION_DENIED_ORG_UPDATE_BILLING' - USING ERRCODE = '42501'; - END IF; + IF TG_OP = 'UPDATE' + AND NEW.customer_id IS DISTINCT FROM OLD.customer_id + AND v_bootstrap_org_id <> '' + AND v_bootstrap_org_id = NEW.id::text + THEN + RETURN NEW; + END IF; + + IF TG_OP = 'INSERT' AND NEW.customer_id IS NOT NULL THEN + RAISE EXCEPTION 'PERMISSION_DENIED_ORG_CUSTOMER_ID' + USING ERRCODE = '42501'; + END IF; + + IF TG_OP = 'UPDATE' AND NEW.customer_id IS DISTINCT FROM OLD.customer_id THEN + RAISE EXCEPTION 'PERMISSION_DENIED_ORG_CUSTOMER_ID' + USING ERRCODE = '42501'; END IF; RETURN NEW; @@ -60,13 +51,102 @@ REVOKE ALL ON FUNCTION "public"."guard_org_billing_columns"() FROM PUBLIC; GRANT ALL ON FUNCTION "public"."guard_org_billing_columns"() TO "service_role"; COMMENT ON FUNCTION "public"."guard_org_billing_columns"() IS - 'BEFORE UPDATE OF customer_id guard. Runs once per changed customer_id (console billing writes, not plugin /updates). ' - 'User-context callers need org.update_billing via rbac_check_permission_request; service_role/postgres bypass. ' - 'Org-scoped RBAC lookups use role_bindings_principal_scope_idx / role_bindings_scope_idx (Index Scan at seed scale).'; + 'BEFORE INSERT/UPDATE OF customer_id guard. User/capgkey roles cannot write customer_id; ' + 'service_role/postgres bypass via is_internal_request_role. Org-create bootstrap may set ' + 'pending customer_id while capgo.org_creation_bootstrap_org_id matches the row id.'; DROP TRIGGER IF EXISTS "guard_org_billing_columns" ON "public"."orgs"; +DROP TRIGGER IF EXISTS "guard_org_billing_columns_insert" ON "public"."orgs"; + +CREATE TRIGGER "guard_org_billing_columns_insert" + BEFORE INSERT ON "public"."orgs" + FOR EACH ROW + EXECUTE FUNCTION "public"."guard_org_billing_columns"(); CREATE TRIGGER "guard_org_billing_columns" BEFORE UPDATE OF "customer_id" ON "public"."orgs" FOR EACH ROW EXECUTE FUNCTION "public"."guard_org_billing_columns"(); + +-- Keep bootstrap GUC active through pending customer_id assignment so the guard +-- allows generate_org_user_stripe_info_on_org_create to finish for user inserts +-- that omit customer_id (legacy/direct PostgREST path). +CREATE OR REPLACE FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + solo_plan_stripe_id varchar; + pending_customer_id varchar; + trial_at_date timestamptz; + org_super_admin_role_id uuid; +BEGIN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', NEW.id::text, true); + + INSERT INTO public.org_users (user_id, org_id, rbac_role_name, is_invite) + VALUES (NEW.created_by, NEW.id, public.rbac_role_org_super_admin(), false); + + SELECT id INTO org_super_admin_role_id + FROM public.roles + WHERE name = public.rbac_role_org_super_admin() + AND scope_type = public.rbac_scope_org() + LIMIT 1; + + IF org_super_admin_role_id IS NOT NULL THEN + INSERT INTO public.role_bindings ( + principal_type, principal_id, role_id, scope_type, org_id, + granted_by, granted_at, reason, is_direct + ) VALUES ( + public.rbac_principal_user(), NEW.created_by, org_super_admin_role_id, public.rbac_scope_org(), NEW.id, + NEW.created_by, now(), 'Organization creator', true + ) ON CONFLICT DO NOTHING; + END IF; + + IF NEW.customer_id IS NOT NULL THEN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RETURN NEW; + END IF; + + SELECT stripe_id INTO solo_plan_stripe_id + FROM public.plans + WHERE name = 'Solo' + LIMIT 1; + + IF solo_plan_stripe_id IS NULL THEN + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RAISE WARNING 'Solo plan not found, skipping sync stripe_info creation for org %', NEW.id; + RETURN NEW; + END IF; + + pending_customer_id := 'pending_' || NEW.id::text; + trial_at_date := NOW() + INTERVAL '15 days'; + + INSERT INTO public.stripe_info ( + customer_id, + product_id, + trial_at, + status, + is_good_plan + ) VALUES ( + pending_customer_id, + solo_plan_stripe_id, + trial_at_date, + NULL, + true + ); + + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + + RETURN NEW; +END; +$$; + +ALTER FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() OWNER TO "postgres"; + +REVOKE ALL ON FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() FROM PUBLIC; + +GRANT ALL ON FUNCTION "public"."generate_org_user_stripe_info_on_org_create"() TO "service_role"; diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql index 6c57716f9e..5f2872e351 100644 --- a/supabase/tests/73_test_org_billing_column_guard.sql +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -1,7 +1,7 @@ --- org.customer_id must not be writable via PostgREST unless org.update_billing is granted. +-- org.customer_id is service-managed: user/capgkey roles cannot write it. BEGIN; -SELECT plan(4); +SELECT plan(6); SELECT tests.authenticate_as_service_role(); SELECT tests.create_supabase_user('org_billing_guard_admin', 'org_billing_guard_admin@test.local'); @@ -67,11 +67,12 @@ SELECT public.rbac_scope_org(), '73000000-0000-4000-8000-000000000073'::uuid, tests.get_supabase_uid('org_billing_guard_admin'), - 'pgTAP org billing column guard fixture', + 'pgTAP org customer_id guard fixture', true FROM ( VALUES - (tests.get_supabase_uid('org_billing_guard_admin'), public.rbac_role_org_admin()) + (tests.get_supabase_uid('org_billing_guard_admin'), public.rbac_role_org_admin()), + (tests.get_supabase_uid('org_billing_guard_super'), public.rbac_role_org_super_admin()) ) AS members(user_id, role_name) CROSS JOIN public.roles AS roles WHERE roles.name = members.role_name @@ -87,8 +88,8 @@ SELECT throws_ok( WHERE id = '73000000-0000-4000-8000-000000000073'::uuid $$, '42501', - 'PERMISSION_DENIED_ORG_UPDATE_BILLING', - 'org_admin cannot mutate customer_id without org.update_billing' + 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + 'org_admin cannot mutate customer_id' ); SELECT lives_ok( @@ -100,6 +101,19 @@ SELECT lives_ok( 'org_admin can still update org settings columns' ); +SELECT tests.authenticate_as('org_billing_guard_super'); + +SELECT throws_ok( + $$ + UPDATE public.orgs + SET customer_id = 'cus_org_billing_guard_730002' + WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + $$, + '42501', + 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + 'org_super_admin cannot mutate customer_id' +); + SELECT tests.authenticate_as_service_role(); SET LOCAL ROLE service_role; SET LOCAL "request.jwt.claim.role" = 'service_role'; @@ -117,11 +131,22 @@ SELECT tests.authenticate_as('org_billing_guard_super'); SELECT lives_ok( $$ - UPDATE public.orgs - SET customer_id = 'cus_org_billing_guard_730001' - WHERE id = '73000000-0000-4000-8000-000000000073'::uuid + INSERT INTO public.orgs (id, created_by, name, management_email) + VALUES ( + '73000000-0000-4000-8000-000000000074'::uuid, + tests.get_supabase_uid('org_billing_guard_super'), + 'Org customer_id bootstrap', + 'org-customer-id-bootstrap@test.local' + ) + ON CONFLICT (id) DO NOTHING $$, - 'org creator super admin can update org customer_id with org.update_billing' + 'org creator can insert org without customer_id' +); + +SELECT is( + (SELECT customer_id FROM public.orgs WHERE id = '73000000-0000-4000-8000-000000000074'::uuid), + 'pending_73000000-0000-4000-8000-000000000074', + 'org create bootstrap assigns pending customer_id' ); SELECT tests.clear_authentication(); diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts index 4f0ab1f3b5..70f40d0f90 100644 --- a/tests/org-billing-column-guard.test.ts +++ b/tests/org-billing-column-guard.test.ts @@ -11,21 +11,21 @@ import { } from './test-utils.ts' if (!SUPABASE_BASE_URL) - throw new Error('SUPABASE_URL is required for org billing column guard tests') + throw new Error('SUPABASE_URL is required for org customer_id guard tests') if (!SUPABASE_ANON_KEY) - throw new Error('SUPABASE_ANON_KEY is required for org billing column guard tests') + throw new Error('SUPABASE_ANON_KEY is required for org customer_id guard tests') const serviceRoleSupabase = getSupabaseClient() const fixtureId = randomUUID() const orgId = randomUUID() let settingsAdminUserId: string -let billingSuperAdminUserId: string -const settingsAdminEmail = `org-billing-guard-admin-${fixtureId}@capgo.test` -const billingSuperAdminEmail = `org-billing-guard-super-${fixtureId}@capgo.test` +let superAdminUserId: string +const settingsAdminEmail = `org-customer-id-guard-admin-${fixtureId}@capgo.test` +const superAdminEmail = `org-customer-id-guard-super-${fixtureId}@capgo.test` const testPassword = `Capgo!${fixtureId}` -const originalCustomerId = `cus_org_billing_guard_${fixtureId.replaceAll('-', '').slice(0, 18)}` -const replacementCustomerId = `cus_org_billing_guard_alt_${fixtureId.replaceAll('-', '').slice(0, 14)}` +const originalCustomerId = `cus_org_customer_id_guard_${fixtureId.replaceAll('-', '').slice(0, 16)}` +const replacementCustomerId = `cus_org_customer_id_guard_alt_${fixtureId.replaceAll('-', '').slice(0, 12)}` async function bindOrgRole(userId: string, roleName: string) { const [role] = await executeSQL( @@ -41,7 +41,7 @@ async function bindOrgRole(userId: string, roleName: string) { granted_by, reason, is_direct ) VALUES ( public.rbac_principal_user(), $1::uuid, $2::uuid, public.rbac_scope_org(), $3::uuid, - $1::uuid, 'org billing column guard fixture', true + $1::uuid, 'org customer_id guard fixture', true ) ON CONFLICT DO NOTHING`, [userId, role.id, orgId], @@ -70,9 +70,10 @@ async function patchOrg(headers: Record, body: Record { +describe('org customer_id guard', () => { let settingsAdminHeaders: Record - let billingSuperAdminHeaders: Record + let superAdminHeaders: Record + const createdOrgIds: string[] = [] beforeAll(async () => { const { data: settingsAdminAuth, error: settingsAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ @@ -83,23 +84,23 @@ describe('org billing column guard', () => { if (settingsAdminAuthError) throw settingsAdminAuthError - const { data: billingSuperAdminAuth, error: billingSuperAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ - email: billingSuperAdminEmail, + const { data: superAdminAuth, error: superAdminAuthError } = await serviceRoleSupabase.auth.admin.createUser({ + email: superAdminEmail, password: testPassword, email_confirm: true, }) - if (billingSuperAdminAuthError) - throw billingSuperAdminAuthError + if (superAdminAuthError) + throw superAdminAuthError await executeSQL( `INSERT INTO public.users (id, email, first_name) - VALUES ($1::uuid, $2, 'Org Billing Guard Admin'), ($3::uuid, $4, 'Org Billing Guard Super') + VALUES ($1::uuid, $2, 'Org Customer ID Guard Admin'), ($3::uuid, $4, 'Org Customer ID Guard Super') ON CONFLICT (id) DO NOTHING`, - [settingsAdminAuth.user.id, settingsAdminEmail, billingSuperAdminAuth.user.id, billingSuperAdminEmail], + [settingsAdminAuth.user.id, settingsAdminEmail, superAdminAuth.user.id, superAdminEmail], ) settingsAdminUserId = settingsAdminAuth.user.id - billingSuperAdminUserId = billingSuperAdminAuth.user.id + superAdminUserId = superAdminAuth.user.id await executeSQL( `INSERT INTO public.stripe_info ( @@ -112,7 +113,7 @@ describe('org billing column guard', () => { await executeSQL( `INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) VALUES ($1::uuid, $2::uuid, $3, $4, $5)`, - [orgId, billingSuperAdminUserId, `Org billing guard ${fixtureId}`, settingsAdminEmail, originalCustomerId], + [orgId, superAdminUserId, `Org customer_id guard ${fixtureId}`, settingsAdminEmail, originalCustomerId], ) await executeSQL( @@ -123,28 +124,36 @@ describe('org billing column guard', () => { ) await bindOrgRole(settingsAdminUserId, 'org_admin') + await bindOrgRole(superAdminUserId, 'org_super_admin') settingsAdminHeaders = await getAuthHeadersForCredentials(settingsAdminEmail, testPassword) - billingSuperAdminHeaders = await getAuthHeadersForCredentials(billingSuperAdminEmail, testPassword) + superAdminHeaders = await getAuthHeadersForCredentials(superAdminEmail, testPassword) }) afterAll(async () => { + if (createdOrgIds.length > 0) { + await executeSQL( + 'DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', + [createdOrgIds.map(id => `pending_${id}`)], + ) + await executeSQL('DELETE FROM public.orgs WHERE id = ANY($1::uuid[])', [createdOrgIds]) + } await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [orgId]) await executeSQL( 'DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', [[originalCustomerId, replacementCustomerId]], ) await serviceRoleSupabase.auth.admin.deleteUser(settingsAdminUserId) - await serviceRoleSupabase.auth.admin.deleteUser(billingSuperAdminUserId) + await serviceRoleSupabase.auth.admin.deleteUser(superAdminUserId) }) - it('blocks org.update_settings principals from changing customer_id via PostgREST', async () => { + it('blocks org_admin from changing customer_id via PostgREST', async () => { const { response, data } = await patchOrg(settingsAdminHeaders, { customer_id: null }) expect(response.status).toBe(403) expect(data).toMatchObject({ code: '42501', - message: 'PERMISSION_DENIED_ORG_UPDATE_BILLING', + message: 'PERMISSION_DENIED_ORG_CUSTOMER_ID', }) const { data: orgRow, error: readError } = await serviceRoleSupabase @@ -157,8 +166,18 @@ describe('org billing column guard', () => { expect(orgRow?.customer_id).toBe(originalCustomerId) }) - it('still allows org.update_settings principals to change cosmetic org fields', async () => { - const updatedName = `Org billing guard renamed ${fixtureId}` + it('blocks org_super_admin from changing customer_id via PostgREST', async () => { + const { response, data } = await patchOrg(superAdminHeaders, { customer_id: replacementCustomerId }) + + expect(response.status).toBe(403) + expect(data).toMatchObject({ + code: '42501', + message: 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + }) + }) + + it('still allows org settings principals to change cosmetic org fields', async () => { + const updatedName = `Org customer_id guard renamed ${fixtureId}` const { response, data } = await patchOrg(settingsAdminHeaders, { name: updatedName }) @@ -175,10 +194,13 @@ describe('org billing column guard', () => { expect(orgRow?.name).toBe(updatedName) }) - it('allows org.update_billing principals to change customer_id via PostgREST', async () => { - const { response } = await patchOrg(billingSuperAdminHeaders, { customer_id: replacementCustomerId }) + it('allows service_role to change customer_id via PostgREST', async () => { + const { error } = await serviceRoleSupabase + .from('orgs') + .update({ customer_id: replacementCustomerId }) + .eq('id', orgId) - expect(response.status).toBe(200) + expect(error).toBeNull() const { data: orgRow, error: readError } = await serviceRoleSupabase .from('orgs') @@ -190,21 +212,36 @@ describe('org billing column guard', () => { expect(orgRow?.customer_id).toBe(replacementCustomerId) }) - it('allows service_role to change customer_id via PostgREST', async () => { - const { error } = await serviceRoleSupabase - .from('orgs') - .update({ customer_id: originalCustomerId }) - .eq('id', orgId) + it('assigns pending customer_id when creating an org via the organization API', async () => { + const orgName = `Org customer_id guard create ${fixtureId}` + + const response = await fetchTestRequest(getEndpointUrl('/organization'), { + method: 'POST', + headers: { + ...settingsAdminHeaders, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: orgName, + email: settingsAdminEmail, + estimatedMau: 1000, + intent: 'ota', + }), + }) - expect(error).toBeNull() + expect(response.status).toBe(200) + const payload = await response.json() as { id?: string } + expect(payload.id).toBeTruthy() + createdOrgIds.push(payload.id!) const { data: orgRow, error: readError } = await serviceRoleSupabase .from('orgs') - .select('customer_id') - .eq('id', orgId) + .select('customer_id, name') + .eq('id', payload.id!) .single() expect(readError).toBeNull() - expect(orgRow?.customer_id).toBe(originalCustomerId) + expect(orgRow?.name).toBe(orgName) + expect(orgRow?.customer_id).toBe(`pending_${payload.id}`) }) }) From 5433a865abae535785e2b9ad7107c9010100a567 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:16:39 +0000 Subject: [PATCH 10/17] test(db): fix org customer_id pgTAP bootstrap assertions Co-authored-by: Martin DONADIEU --- .../73_test_org_billing_column_guard.sql | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql index 5f2872e351..cdeba00892 100644 --- a/supabase/tests/73_test_org_billing_column_guard.sql +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -129,6 +129,26 @@ SELECT lives_ok( SELECT tests.authenticate_as('org_billing_guard_super'); +SELECT throws_ok( + $$ + INSERT INTO public.orgs (id, created_by, name, management_email, customer_id) + VALUES ( + '73000000-0000-4000-8000-000000000075'::uuid, + tests.get_supabase_uid('org_billing_guard_super'), + 'Org customer_id insert blocked', + 'org-customer-id-insert-blocked@test.local', + 'cus_org_billing_guard_evil' + ) + $$, + '42501', + 'PERMISSION_DENIED_ORG_CUSTOMER_ID', + 'user cannot insert org with customer_id' +); + +SELECT tests.authenticate_as_service_role(); +SET LOCAL ROLE service_role; +SET LOCAL "request.jwt.claim.role" = 'service_role'; + SELECT lives_ok( $$ INSERT INTO public.orgs (id, created_by, name, management_email) @@ -140,7 +160,7 @@ SELECT lives_ok( ) ON CONFLICT (id) DO NOTHING $$, - 'org creator can insert org without customer_id' + 'service_role can insert org without customer_id' ); SELECT is( From faa4b42b222c251e89721451357dab0cdda38b35 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:29:41 +0000 Subject: [PATCH 11/17] fix(org): keep JWT org create on auth path for audit and MFA Insert orgs without customer_id via supabaseWithAuth after MFA check; the org-create trigger links pre-created pending stripe_info. API-key create omits customer_id from the service-side insert as well. Co-authored-by: Martin DONADIEU --- .../_backend/public/organization/post.ts | 20 ++++++++++++------- ...0260826095730_org_billing_column_guard.sql | 15 ++++++++++++++ .../73_test_org_billing_column_guard.sql | 2 +- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index 62b17cf6fd..f5c2e27bba 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { safeParseSchema } from '../../utils/schema_validation.ts' import { quickError, simpleError } from '../../utils/hono.ts' import { closeClient, getPgClient } from '../../utils/pg.ts' +import { assertJwtMfaAssurance } from '../../utils/jwt_mfa_assurance.ts' import { supabaseAdmin, supabaseWithAuth } from '../../utils/supabase.ts' import { parseOrgOnboardingIntent } from '../../utils/org_onboarding_intent.ts' import { normalizeWebsiteUrl } from './website.ts' @@ -193,12 +194,11 @@ async function insertOrgForApiKey( name, created_by, management_email, - customer_id, website, onboarding ) - VALUES ($1::uuid, $2::varchar, $3::uuid, $4::varchar, $5::varchar, $6::varchar, $7::jsonb)`, - [org.id, org.name, org.created_by, org.management_email, org.customer_id, org.website, JSON.stringify(org.onboarding)], + VALUES ($1::uuid, $2::varchar, $3::uuid, $4::varchar, $5::varchar, $6::jsonb)`, + [org.id, org.name, org.created_by, org.management_email, org.website, JSON.stringify(org.onboarding)], ) await dbClient.query( @@ -290,12 +290,18 @@ export async function post( await insertOrgForApiKey(c, auth, newOrg) } else { - // customer_id is service-managed; user JWT cannot write it under org RLS/triggers. - const { error: errorOrg } = await supabaseAdmin(c) + await assertJwtMfaAssurance(c, auth) + + const { id, name, created_by, management_email, website: orgWebsite, onboarding: orgOnboarding } = newOrg + const { error: errorOrg } = await supabaseWithAuth(c, auth) .from('orgs') .insert({ - ...newOrg, - onboarding, + id, + name, + created_by, + management_email, + website: orgWebsite, + onboarding: orgOnboarding, }) if (errorOrg) { diff --git a/supabase/migrations/20260826095730_org_billing_column_guard.sql b/supabase/migrations/20260826095730_org_billing_column_guard.sql index 71c0b49897..134f727bc2 100644 --- a/supabase/migrations/20260826095730_org_billing_column_guard.sql +++ b/supabase/migrations/20260826095730_org_billing_column_guard.sql @@ -107,6 +107,21 @@ BEGIN RETURN NEW; END IF; + pending_customer_id := 'pending_' || NEW.id::text; + + IF EXISTS ( + SELECT 1 + FROM public.stripe_info + WHERE customer_id = pending_customer_id + ) THEN + UPDATE public.orgs + SET customer_id = pending_customer_id + WHERE id = NEW.id; + + PERFORM set_config('capgo.org_creation_bootstrap_org_id', '', true); + RETURN NEW; + END IF; + SELECT stripe_id INTO solo_plan_stripe_id FROM public.plans WHERE name = 'Solo' diff --git a/supabase/tests/73_test_org_billing_column_guard.sql b/supabase/tests/73_test_org_billing_column_guard.sql index cdeba00892..272f34260e 100644 --- a/supabase/tests/73_test_org_billing_column_guard.sql +++ b/supabase/tests/73_test_org_billing_column_guard.sql @@ -1,7 +1,7 @@ -- org.customer_id is service-managed: user/capgkey roles cannot write it. BEGIN; -SELECT plan(6); +SELECT plan(7); SELECT tests.authenticate_as_service_role(); SELECT tests.create_supabase_user('org_billing_guard_admin', 'org_billing_guard_admin@test.local'); From b1895bfafeab3fe8bf0364fb364e7597be261d68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:37:23 +0000 Subject: [PATCH 12/17] test(org): delete orgs before pending stripe cleanup Co-authored-by: Martin DONADIEU --- tests/org-billing-column-guard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/org-billing-column-guard.test.ts b/tests/org-billing-column-guard.test.ts index 70f40d0f90..912c2c57d5 100644 --- a/tests/org-billing-column-guard.test.ts +++ b/tests/org-billing-column-guard.test.ts @@ -132,11 +132,11 @@ describe('org customer_id guard', () => { afterAll(async () => { if (createdOrgIds.length > 0) { + await executeSQL('DELETE FROM public.orgs WHERE id = ANY($1::uuid[])', [createdOrgIds]) await executeSQL( 'DELETE FROM public.stripe_info WHERE customer_id = ANY($1::text[])', [createdOrgIds.map(id => `pending_${id}`)], ) - await executeSQL('DELETE FROM public.orgs WHERE id = ANY($1::uuid[])', [createdOrgIds]) } await executeSQL('DELETE FROM public.orgs WHERE id = $1::uuid', [orgId]) await executeSQL( From 4c1635c8713236ba6dfd791f8dc8dd485946c638 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:44:20 +0000 Subject: [PATCH 13/17] fix(db): re-stamp org customer_id guard migration after main Co-authored-by: Martin DONADIEU --- ...umn_guard.sql => 20260826103000_org_billing_column_guard.sql} | 1 - 1 file changed, 1 deletion(-) rename supabase/migrations/{20260826095730_org_billing_column_guard.sql => 20260826103000_org_billing_column_guard.sql} (99%) diff --git a/supabase/migrations/20260826095730_org_billing_column_guard.sql b/supabase/migrations/20260826103000_org_billing_column_guard.sql similarity index 99% rename from supabase/migrations/20260826095730_org_billing_column_guard.sql rename to supabase/migrations/20260826103000_org_billing_column_guard.sql index 134f727bc2..f476ca4364 100644 --- a/supabase/migrations/20260826095730_org_billing_column_guard.sql +++ b/supabase/migrations/20260826103000_org_billing_column_guard.sql @@ -133,7 +133,6 @@ BEGIN RETURN NEW; END IF; - pending_customer_id := 'pending_' || NEW.id::text; trial_at_date := NOW() + INTERVAL '15 days'; INSERT INTO public.stripe_info ( From bc8ae44974e39fd84cb8354d820d2b7dee214aae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:52:03 +0000 Subject: [PATCH 14/17] ci: retrigger after flaky stats plugin test Co-authored-by: Martin DONADIEU From 302f199b048a959fcc4e65d30d66ee9685560de0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 12:11:29 +0000 Subject: [PATCH 15/17] ci: rerun full test suite Co-authored-by: Martin DONADIEU From 7c8dda96d567d425511e28c219cbaa937242255c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 12:26:37 +0000 Subject: [PATCH 16/17] docs(org): note customer_id bootstrap on JWT org create Co-authored-by: Martin DONADIEU --- supabase/functions/_backend/public/organization/post.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/functions/_backend/public/organization/post.ts b/supabase/functions/_backend/public/organization/post.ts index f5c2e27bba..46614b69b6 100644 --- a/supabase/functions/_backend/public/organization/post.ts +++ b/supabase/functions/_backend/public/organization/post.ts @@ -292,6 +292,7 @@ export async function post( else { await assertJwtMfaAssurance(c, auth) + // Omit customer_id: org-create trigger links pre-created pending stripe_info. const { id, name, created_by, management_email, website: orgWebsite, onboarding: orgOnboarding } = newOrg const { error: errorOrg } = await supabaseWithAuth(c, auth) .from('orgs') From dfe306e0e321f389bedf23011c45e141cf35270d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 12:42:43 +0000 Subject: [PATCH 17/17] fix(org): reload org row before Stripe bootstrap on create Queue payloads still carry the INSERT snapshot without customer_id when bootstrap triggers assign pending_* afterward. Reload the committed org before finalizePendingStripeCustomer vs createStripeCustomer. Co-authored-by: Martin DONADIEU --- .../triggers/on_organization_create.ts | 69 ++++++++++++------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/supabase/functions/_backend/triggers/on_organization_create.ts b/supabase/functions/_backend/triggers/on_organization_create.ts index 21ba5afc35..4053325b96 100644 --- a/supabase/functions/_backend/triggers/on_organization_create.ts +++ b/supabase/functions/_backend/triggers/on_organization_create.ts @@ -15,30 +15,49 @@ import { backgroundTask } from '../utils/utils.ts' export const app = new Hono() app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) => { - const record = c.get('webhookBody') as Database['public']['Tables']['orgs']['Row'] - cloudlog({ requestId: c.get('requestId'), message: 'record', record }) + const queuedRecord = c.get('webhookBody') as Database['public']['Tables']['orgs']['Row'] + cloudlog({ requestId: c.get('requestId'), message: 'record', record: queuedRecord }) - if (!record.id) { + if (!queuedRecord.id) { cloudlog({ requestId: c.get('requestId'), message: 'No id' }) - throw simpleError('no_id', 'No id', { record }) + throw simpleError('no_id', 'No id', { record: queuedRecord }) } + // INSERT queue payloads omit customer_id when org-create assigns it in a later + // AFTER INSERT trigger; reload the committed row before Stripe bootstrap. + const { data: orgRow, error: orgLoadError } = await supabaseAdmin(c) + .from('orgs') + .select('*') + .eq('id', queuedRecord.id) + .single() + + if (orgLoadError || !orgRow) { + cloudlog({ + requestId: c.get('requestId'), + message: 'org create reload failed, using queue payload', + orgId: queuedRecord.id, + error: orgLoadError?.message, + }) + } + + const org = orgRow ?? queuedRecord + let trialPlanName: string | null | undefined - if (!record.customer_id) { - trialPlanName = await createStripeCustomer(c, record) + if (!org.customer_id) { + trialPlanName = await createStripeCustomer(c, org) } - else if (record.customer_id.startsWith('pending_')) { - trialPlanName = await finalizePendingStripeCustomer(c, record) + else if (org.customer_id.startsWith('pending_')) { + trialPlanName = await finalizePendingStripeCustomer(c, org) } if (trialPlanName) { const { data: creator, error: creatorError } = await supabaseAdmin(c) .from('users') .select('email') - .eq('id', record.created_by) + .eq('id', org.created_by) .maybeSingle() if (creatorError) - cloudlog({ requestId: c.get('requestId'), message: 'trial plan Bento creator lookup failed', userId: record.created_by, error: creatorError }) + cloudlog({ requestId: c.get('requestId'), message: 'trial plan Bento creator lookup failed', userId: org.created_by, error: creatorError }) if (creator?.email) { await backgroundTask(c, syncBentoSubscriberTags(c, { email: creator.email.trim().toLowerCase(), @@ -49,25 +68,25 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) await backgroundTask(c, groupIdentifyPosthog(c, { groupType: 'organization', - groupKey: record.id, + groupKey: org.id, properties: { - name: record.name, - management_email: record.management_email, - customer_id: record.customer_id, - created_by: record.created_by, - created_at: record.created_at, - website: record.website, + name: org.name, + management_email: org.management_email, + customer_id: org.customer_id, + created_by: org.created_by, + created_at: org.created_at, + website: org.website, }, })) - const onboardingIntent = parseOrgOnboardingIntent(record.onboarding) + const onboardingIntent = parseOrgOnboardingIntent(org.onboarding) const onboardingBentoData = buildOnboardingIntentBentoEventData(c, onboardingIntent, { - id: record.id, - name: record.name, - website: record.website, + id: org.id, + name: org.name, + website: org.website, }) - await syncOrgOnboardingIntentForOrg(c, record) + await syncOrgOnboardingIntentForOrg(c, org) await sendEventToTracking(c, { bento: { @@ -75,13 +94,13 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) data: onboardingBentoData, event: 'org:created', preferenceKey: 'onboarding', - uniqId: `org:created:${record.id}`, + uniqId: `org:created:${org.id}`, }, channel: 'org-created', event: 'Org Created', sentToBento: true, - user_id: record.id, - groups: { organization: record.id }, + user_id: org.id, + groups: { organization: org.id }, }) return c.json(BRES)