From d21b7516fc38cacb32ea06e555b05879e1848f3c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:23:39 +0000 Subject: [PATCH 01/30] fix(security): block r2-direct manifest jsonb poison path Prevent upload/write API keys from materializing arbitrary manifest rows by updating app_versions.manifest on in-progress r2-direct versions. - Reject non-null manifest jsonb writes while storage_provider stays r2-direct - Skip on_version_update legacy jsonb migration for r2-direct uploads - Legitimate delta uploads continue via POST /private/set_manifest - Add pgTAP and integration regression tests Co-authored-by: Martin DONADIEU --- .../_backend/triggers/on_version_update.ts | 5 +- ..._block_r2_direct_manifest_jsonb_writes.sql | 232 ++++++++++++++++++ ...73_test_block_r2_direct_manifest_jsonb.sql | 100 ++++++++ tests/manifest-poison-guard.test.ts | 207 ++++++++++++++++ 4 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql create mode 100644 supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql create mode 100644 tests/manifest-poison-guard.test.ts diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 9229dba982..06a8b35a32 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -259,9 +259,10 @@ async function updateIt(c: Context, record: Database['public']['Tables']['app_ve } } - // Handle manifest entries (reload when the queue payload omitted the jsonb column) + // Handle manifest entries (reload when the queue payload omitted the jsonb column). + // In-progress r2-direct uploads must use POST /private/set_manifest instead. const recordWithManifest = await ensureVersionManifest(c, record) - if (recordWithManifest.manifest) + if (recordWithManifest.manifest && recordWithManifest.storage_provider !== 'r2-direct') await handleManifest(c, recordWithManifest) return c.json(BRES) diff --git a/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql b/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql new file mode 100644 index 0000000000..ffd4e7ac99 --- /dev/null +++ b/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql @@ -0,0 +1,232 @@ +-- Block PostgREST writes to app_versions.manifest while a bundle is still +-- in-progress (storage_provider = r2-direct). Legitimate delta uploads use +-- POST /private/set_manifest, which inserts into public.manifest directly. +-- Legacy CLIs finalize with r2-direct -> r2 in the same UPDATE. + +CREATE OR REPLACE FUNCTION "public"."check_encrypted_bundle_on_insert"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET "search_path" TO '' + AS $$ +DECLARE + org_id uuid; + org_enforcing boolean; + org_required_key varchar(21); + bundle_is_encrypted boolean; + bundle_key_id varchar(20); + bundle_was_ready boolean; +BEGIN + IF TG_OP = 'UPDATE' THEN + IF pg_catalog.current_setting('capgo.reclaim_manifest_null', true) = 'on' + AND NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND NEW.name IS NOT DISTINCT FROM OLD.name + AND NEW.app_id IS NOT DISTINCT FROM OLD.app_id + AND NEW.session_key IS NOT DISTINCT FROM OLD.session_key + AND NEW.key_id IS NOT DISTINCT FROM OLD.key_id + AND NEW.storage_provider IS NOT DISTINCT FROM OLD.storage_provider + AND NEW.r2_path IS NOT DISTINCT FROM OLD.r2_path + AND NEW.external_url IS NOT DISTINCT FROM OLD.external_url + AND NEW.checksum IS NOT DISTINCT FROM OLD.checksum + THEN + RETURN NEW; + END IF; + + IF NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = OLD.id + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + THEN + RAISE EXCEPTION '%', + 'bundle_manifest_not_migrated: Cannot clear app_versions.manifest ' + || 'until every entry exists in public.manifest.'; + END IF; + + bundle_was_ready := OLD.storage_provider IS DISTINCT FROM 'r2-direct'; + + IF bundle_was_ready + AND ( + NEW.name IS DISTINCT FROM OLD.name + OR NEW.app_id IS DISTINCT FROM OLD.app_id + OR NEW.session_key IS DISTINCT FROM OLD.session_key + OR NEW.key_id IS DISTINCT FROM OLD.key_id + OR NEW.storage_provider IS DISTINCT FROM OLD.storage_provider + OR NEW.r2_path IS DISTINCT FROM OLD.r2_path + OR NEW.external_url IS DISTINCT FROM OLD.external_url + OR NEW.checksum IS DISTINCT FROM OLD.checksum + OR (NEW.manifest IS DISTINCT FROM OLD.manifest AND NEW.manifest IS NOT NULL) + OR ( + NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = OLD.id + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + ) + OR NEW.native_packages IS DISTINCT FROM OLD.native_packages + ) + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', OLD.owner_org, + 'app_id', OLD.app_id, + 'version_name', OLD.name, + 'user_id', OLD.user_id, + 'old_storage_provider', OLD.storage_provider, + 'new_storage_provider', NEW.storage_provider, + 'reason', 'bundle_ready' + )); + RAISE EXCEPTION '%', + 'bundle_already_ready: Bundle content cannot be changed ' + || 'after upload is complete. Upload a new bundle instead.'; + END IF; + + IF OLD.storage_provider = 'r2-direct' + AND NEW.storage_provider = 'r2-direct' + AND NEW.manifest IS DISTINCT FROM OLD.manifest + AND NEW.manifest IS NOT NULL + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', OLD.owner_org, + 'app_id', OLD.app_id, + 'version_name', OLD.name, + 'user_id', OLD.user_id, + 'old_storage_provider', OLD.storage_provider, + 'new_storage_provider', NEW.storage_provider, + 'reason', 'r2_direct_manifest_jsonb' + )); + RAISE EXCEPTION '%', + 'bundle_already_ready: Bundle content cannot be changed ' + || 'after upload is complete. Upload a new bundle instead.'; + END IF; + END IF; + + IF TG_OP = 'UPDATE' + AND NEW.session_key IS NOT DISTINCT FROM OLD.session_key + AND NEW.key_id IS NOT DISTINCT FROM OLD.key_id + AND NEW.name IS NOT DISTINCT FROM OLD.name + AND NEW.app_id IS NOT DISTINCT FROM OLD.app_id + AND NEW.storage_provider IS NOT DISTINCT FROM OLD.storage_provider + AND NEW.r2_path IS NOT DISTINCT FROM OLD.r2_path + AND NEW.external_url IS NOT DISTINCT FROM OLD.external_url + AND NEW.checksum IS NOT DISTINCT FROM OLD.checksum + AND NEW.native_packages IS NOT DISTINCT FROM OLD.native_packages + AND ( + NEW.manifest IS NOT DISTINCT FROM OLD.manifest + OR ( + NEW.manifest IS NULL + AND OLD.manifest IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = OLD.id + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ) + ) + ) + THEN + RETURN NEW; + END IF; + + SELECT apps.owner_org INTO org_id + FROM public.apps + WHERE apps.app_id = NEW.app_id; + + IF org_id IS NULL THEN + org_id := NEW.owner_org; + END IF; + + IF org_id IS NULL THEN + RETURN NEW; + END IF; + + SELECT enforce_encrypted_bundles, required_encryption_key + INTO org_enforcing, org_required_key + FROM public.orgs + WHERE id = org_id; + + IF org_enforcing IS NULL OR org_enforcing = false THEN + RETURN NEW; + END IF; + + bundle_is_encrypted := public.is_bundle_encrypted(NEW.session_key); + bundle_key_id := NULLIF(pg_catalog.btrim(NEW.key_id), '')::varchar(20); + + IF NOT bundle_is_encrypted THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_ENCRYPTED_BUNDLES_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'reason', 'not_encrypted' + )); + RAISE EXCEPTION '%', + 'encryption_required: This organization requires all bundles to be ' + || 'encrypted. Please upload an encrypted bundle with a session_key.'; + END IF; + + IF org_required_key IS NOT NULL AND org_required_key <> '' THEN + IF bundle_key_id IS NULL THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'required_key', org_required_key, + 'bundle_key_id', bundle_key_id, + 'reason', 'missing_key_id' + )); + RAISE EXCEPTION '%', + 'encryption_key_required: This organization requires bundles to be ' + || 'encrypted with a specific key. The uploaded bundle does not have ' + || 'a key_id.'; + END IF; + + IF NOT ( + bundle_key_id = pg_catalog.left(org_required_key, 20) + OR pg_catalog.left(bundle_key_id, pg_catalog.length(org_required_key)) = org_required_key + ) THEN + PERFORM public.pg_log('deny: ORG_REQUIRES_SPECIFIC_ENCRYPTION_KEY_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', org_id, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'required_key', org_required_key, + 'bundle_key_id', bundle_key_id, + 'reason', 'key_mismatch' + )); + RAISE EXCEPTION '%', + 'encryption_key_mismatch: This organization requires bundles to be ' + || 'encrypted with a specific key. The uploaded bundle was encrypted ' + || 'with a different key.'; + END IF; + END IF; + + RETURN NEW; +END; +$$; diff --git a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql new file mode 100644 index 0000000000..0f12e61fc8 --- /dev/null +++ b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql @@ -0,0 +1,100 @@ +-- In-progress r2-direct versions must not accept app_versions.manifest jsonb writes. +BEGIN; + +SELECT plan(2); + +SELECT tests.authenticate_as_service_role(); +SELECT tests.create_supabase_user('r2_direct_manifest_block_owner', 'r2_direct_manifest_block_owner@test.local'); + +INSERT INTO public.users (id, email, created_at, updated_at) +VALUES ( + tests.get_supabase_uid('r2_direct_manifest_block_owner'), + 'r2_direct_manifest_block_owner@test.local', + NOW(), + NOW() +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.orgs (id, created_by, name, management_email) +VALUES ( + '70000000-0000-4000-8000-000000000073', + tests.get_supabase_uid('r2_direct_manifest_block_owner'), + 'r2-direct manifest block org', + 'r2-direct-manifest-block@test.local' +) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO public.apps (app_id, icon_url, user_id, name, owner_org) +VALUES ( + 'com.test.r2direct.manifest.block', + '', + tests.get_supabase_uid('r2_direct_manifest_block_owner'), + 'r2-direct manifest block app', + '70000000-0000-4000-8000-000000000073' +) +ON CONFLICT (app_id) DO NOTHING; + +INSERT INTO public.app_versions ( + app_id, + name, + owner_org, + user_id, + storage_provider, + checksum, + deleted +) +VALUES ( + 'com.test.r2direct.manifest.block', + '1.0.0-in-progress', + '70000000-0000-4000-8000-000000000073', + tests.get_supabase_uid('r2_direct_manifest_block_owner'), + 'r2-direct', + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + false +) +ON CONFLICT (name, app_id) DO UPDATE +SET + storage_provider = EXCLUDED.storage_provider, + checksum = EXCLUDED.checksum, + manifest = NULL, + deleted = false; + +SELECT throws_ok( + $sql$ + UPDATE public.app_versions + SET manifest = ARRAY[ + ROW( + 'index.html', + 'orgs/70000000-0000-4000-8000-000000000073/apps/com.test.r2direct.manifest.block/delta/poison_index.html', + 'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' + )::public.manifest_entry + ] + WHERE app_id = 'com.test.r2direct.manifest.block' + AND name = '1.0.0-in-progress' + $sql$, + 'P0001', + 'bundle_already_ready: Bundle content cannot be changed after upload is complete. Upload a new bundle instead.', + 'in-progress r2-direct cannot UPDATE manifest jsonb' +); + +SELECT lives_ok( + $sql$ + UPDATE public.app_versions + SET + storage_provider = 'r2', + r2_path = 'orgs/70000000-0000-4000-8000-000000000073/apps/com.test.r2direct.manifest.block/1.0.0-in-progress.zip', + manifest = ARRAY[ + ROW( + 'legacy.html', + 'orgs/70000000-0000-4000-8000-000000000073/apps/com.test.r2direct.manifest.block/delta/legacy_legacy.html', + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + )::public.manifest_entry + ] + WHERE app_id = 'com.test.r2direct.manifest.block' + AND name = '1.0.0-in-progress' + $sql$, + 'legacy finalize can still set manifest while moving r2-direct -> r2' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/tests/manifest-poison-guard.test.ts b/tests/manifest-poison-guard.test.ts new file mode 100644 index 0000000000..80f6a06aea --- /dev/null +++ b/tests/manifest-poison-guard.test.ts @@ -0,0 +1,207 @@ +import type { Database } from '../src/types/supabase.types' +import { randomUUID } from 'node:crypto' +import { createClient } from '@supabase/supabase-js' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + APIKEY_TEST_ALL, + APIKEY_TEST_UPLOAD, + fetchTestRequest, + getEndpointUrl, + getSupabaseClient, + ORG_ID, + resetAndSeedAppData, + resetAppData, + USER_ID, +} from './test-utils.ts' + +const id = randomUUID() +const APP_ID = `com.demo.manifest-poison.${id}` +const BUNDLE_NAME = `1.0.0-poison-${id.slice(0, 8)}` + +function createApiKeyClient(apikey: string) { + const supabaseUrl = process.env.SUPABASE_URL as string + const supabaseAnonKey = process.env.SUPABASE_ANON_KEY as string + + return createClient(supabaseUrl, supabaseAnonKey, { + global: { + headers: { + capgkey: apikey, + }, + }, + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }) +} + +function poisonManifestEntries(ownerOrg: string) { + const prefix = `orgs/${ownerOrg}/apps/${APP_ID}/delta` + return [ + { + file_name: 'poison.html', + s3_path: `${prefix}/poisonhash_poison.html`, + file_hash: 'poisonhash', + }, + ] +} + +describe('manifest poison guard', () => { + beforeAll(async () => { + await resetAndSeedAppData(APP_ID) + }) + + afterAll(async () => { + await resetAppData(APP_ID) + }) + + it.concurrent('blocks upload API key from poisoning manifest via app_versions.manifest on r2-direct', async () => { + const adminClient = getSupabaseClient() + const uploaderClient = createApiKeyClient(APIKEY_TEST_UPLOAD) + const versionName = `${BUNDLE_NAME}-upload` + + const { data: version, error: insertError } = await adminClient + .from('app_versions') + .insert({ + app_id: APP_ID, + name: versionName, + checksum: randomUUID().replaceAll('-', ''), + owner_org: ORG_ID, + user_id: USER_ID, + storage_provider: 'r2-direct', + deleted: false, + }) + .select('id, owner_org') + .single() + + expect(insertError).toBeNull() + + try { + const { error: poisonError } = await uploaderClient + .from('app_versions') + .update({ + manifest: poisonManifestEntries(version!.owner_org), + }) + .eq('id', version!.id) + + expect(poisonError).not.toBeNull() + expect(poisonError?.message).toContain('bundle_already_ready') + + const { data: manifestRows, error: manifestError } = await adminClient + .from('manifest') + .select('id') + .eq('app_version_id', version!.id) + + expect(manifestError).toBeNull() + expect(manifestRows).toHaveLength(0) + } + finally { + await adminClient.from('app_versions').delete().eq('id', version!.id) + } + }) + + it.concurrent('blocks write API key from poisoning manifest via app_versions.manifest on r2-direct', async () => { + const adminClient = getSupabaseClient() + const writeClient = createApiKeyClient(APIKEY_TEST_ALL) + const versionName = `${BUNDLE_NAME}-write` + + const { data: version, error: insertError } = await adminClient + .from('app_versions') + .insert({ + app_id: APP_ID, + name: versionName, + checksum: randomUUID().replaceAll('-', ''), + owner_org: ORG_ID, + user_id: USER_ID, + storage_provider: 'r2-direct', + deleted: false, + }) + .select('id, owner_org') + .single() + + expect(insertError).toBeNull() + + try { + const { error: poisonError } = await writeClient + .from('app_versions') + .update({ + manifest: poisonManifestEntries(version!.owner_org), + }) + .eq('id', version!.id) + + expect(poisonError).not.toBeNull() + expect(poisonError?.message).toContain('bundle_already_ready') + + const { data: manifestRows, error: manifestError } = await adminClient + .from('manifest') + .select('id') + .eq('app_version_id', version!.id) + + expect(manifestError).toBeNull() + expect(manifestRows).toHaveLength(0) + } + finally { + await adminClient.from('app_versions').delete().eq('id', version!.id) + } + }) + + it('still allows legitimate manifest upload via set_manifest on r2-direct', async () => { + const adminClient = getSupabaseClient() + const versionName = `${BUNDLE_NAME}-legit` + + const { data: version, error: insertError } = await adminClient + .from('app_versions') + .insert({ + app_id: APP_ID, + name: versionName, + checksum: randomUUID().replaceAll('-', ''), + owner_org: ORG_ID, + user_id: USER_ID, + storage_provider: 'r2-direct', + deleted: false, + }) + .select('id, owner_org') + .single() + + expect(insertError).toBeNull() + + const prefix = `orgs/${version!.owner_org}/apps/${APP_ID}/delta` + const body = { + app_id: APP_ID, + name: versionName, + manifest: [ + { + file_name: 'index.html', + s3_path: `${prefix}/hash1_index.html`, + file_hash: 'hash1', + }, + ], + } + + const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': APIKEY_TEST_UPLOAD, + }, + body: JSON.stringify(body), + }) + + expect(response.status).toBe(200) + const json = await response.json() as { status: string, inserted: number } + expect(json.status).toBe('ok') + expect(json.inserted).toBe(1) + + const { data: rows, error: manifestError } = await adminClient + .from('manifest') + .select('file_name, file_hash, s3_path') + .eq('app_version_id', version!.id) + + expect(manifestError).toBeNull() + expect(rows).toHaveLength(1) + expect(rows?.[0]?.file_name).toBe('index.html') + + await adminClient.from('manifest').delete().eq('app_version_id', version!.id) + await adminClient.from('app_versions').delete().eq('id', version!.id) + }) +}) From 5c4845f2152cb3fb1072e8eedac0849327eb6875 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:32:52 +0000 Subject: [PATCH 02/30] test: fix manifest poison and swap cleanup regressions Co-authored-by: Martin DONADIEU --- tests/cleanup_swap_memory.test.ts | 42 +++++++-- tests/manifest-poison-guard.test.ts | 128 +++++++++++++++------------- 2 files changed, 103 insertions(+), 67 deletions(-) diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index 1433edd8fb..6a9d417083 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -144,19 +144,23 @@ describe('swap memory cleanup functions', () => { const versionRows = await executeSQL( `INSERT INTO public.app_versions (app_id, name, owner_org, storage_provider, comment) VALUES ($1, $2, $3::uuid, 'r2-direct', 'before') - RETURNING id`, + RETURNING id, name`, [appId, `1.0.0-${randomUUID().slice(0, 8)}`, orgId], ) const versionId = versionRows[0]?.id as number + const versionName = versionRows[0]?.name as string + const canonicalR2Path = `orgs/${orgId}/apps/${appId}/${versionName}.zip` await executeSQL( `UPDATE public.app_versions SET comment = 'after', - manifest = ARRAY[ROW('a.js', 'apps/a.js', 'hash')::public.manifest_entry], + storage_provider = 'r2', + r2_path = $2, + manifest = ARRAY[ROW('a.js', $3, 'hash')::public.manifest_entry], native_packages = ARRAY['{"name":"cordova-plugin"}'::jsonb] WHERE id = $1`, - [versionId], + [versionId, canonicalR2Path, `orgs/${orgId}/apps/${appId}/delta/hash_a.js`], ) const logs = await executeSQL( @@ -210,14 +214,36 @@ describe('swap memory cleanup functions', () => { it('audit_log_trigger skips dual-storage migrate finalize', async () => { const appId = `com.swap.auditskip.${randomUUID().slice(0, 8)}` const orgId = (await executeSQL(`SELECT id FROM public.orgs ORDER BY created_at LIMIT 1`))[0]?.id as string - const versionId = await seedAuditAppVersion(appId, orgId) + const versionName = `1.0.0-${randomUUID().slice(0, 8)}` + const manifestPath = `orgs/${orgId}/apps/${appId}/delta/hash_a.js` await executeSQL( - `UPDATE public.app_versions - SET manifest = ARRAY[ROW('a.js', 'apps/a.js', 'hash')::public.manifest_entry] - WHERE id = $1`, - [versionId], + `INSERT INTO public.apps (app_id, name, icon_url, owner_org) + VALUES ($1, 'swap-audit-skip', '', $2::uuid)`, + [appId, orgId], ) + + const versionRows = await executeSQL( + `INSERT INTO public.app_versions ( + app_id, + name, + owner_org, + storage_provider, + comment, + manifest + ) + VALUES ( + $1, + $2, + $3::uuid, + 'r2-direct', + 'seed', + ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry] + ) + RETURNING id`, + [appId, versionName, orgId, manifestPath], + ) + const versionId = versionRows[0]?.id as number await clearVersionAudits(versionId) await executeSQL( diff --git a/tests/manifest-poison-guard.test.ts b/tests/manifest-poison-guard.test.ts index 80f6a06aea..9e94311e9c 100644 --- a/tests/manifest-poison-guard.test.ts +++ b/tests/manifest-poison-guard.test.ts @@ -1,7 +1,7 @@ import type { Database } from '../src/types/supabase.types' import { randomUUID } from 'node:crypto' import { createClient } from '@supabase/supabase-js' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' import { APIKEY_TEST_ALL, APIKEY_TEST_UPLOAD, @@ -9,14 +9,10 @@ import { getEndpointUrl, getSupabaseClient, ORG_ID, - resetAndSeedAppData, - resetAppData, USER_ID, } from './test-utils.ts' -const id = randomUUID() -const APP_ID = `com.demo.manifest-poison.${id}` -const BUNDLE_NAME = `1.0.0-poison-${id.slice(0, 8)}` +const APP_ID = 'com.demo.app' function createApiKeyClient(apikey: string) { const supabaseUrl = process.env.SUPABASE_URL as string @@ -35,30 +31,42 @@ function createApiKeyClient(apikey: string) { }) } -function poisonManifestEntries(ownerOrg: string) { +function poisonManifestEntries(ownerOrg: string, versionName: string) { const prefix = `orgs/${ownerOrg}/apps/${APP_ID}/delta` return [ { file_name: 'poison.html', - s3_path: `${prefix}/poisonhash_poison.html`, + s3_path: `${prefix}/${versionName}_poisonhash_poison.html`, file_hash: 'poisonhash', }, ] } -describe('manifest poison guard', () => { - beforeAll(async () => { - await resetAndSeedAppData(APP_ID) - }) - - afterAll(async () => { - await resetAppData(APP_ID) +async function patchVersionManifestAsApiKey( + apikey: string, + versionId: number, + manifest: Database['public']['CompositeTypes']['manifest_entry'][], +) { + const supabaseUrl = process.env.SUPABASE_URL as string + const anonKey = process.env.SUPABASE_ANON_KEY as string + + return fetch(`${supabaseUrl}/rest/v1/app_versions?id=eq.${versionId}`, { + method: 'PATCH', + headers: { + apikey: anonKey, + Authorization: `Bearer ${anonKey}`, + capgkey: apikey, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify({ manifest }), }) +} +describe('manifest poison guard', () => { it.concurrent('blocks upload API key from poisoning manifest via app_versions.manifest on r2-direct', async () => { const adminClient = getSupabaseClient() - const uploaderClient = createApiKeyClient(APIKEY_TEST_UPLOAD) - const versionName = `${BUNDLE_NAME}-upload` + const versionName = `1.0.0-poison-upload-${randomUUID().slice(0, 8)}` const { data: version, error: insertError } = await adminClient .from('app_versions') @@ -77,15 +85,15 @@ describe('manifest poison guard', () => { expect(insertError).toBeNull() try { - const { error: poisonError } = await uploaderClient - .from('app_versions') - .update({ - manifest: poisonManifestEntries(version!.owner_org), - }) - .eq('id', version!.id) + const response = await patchVersionManifestAsApiKey( + APIKEY_TEST_UPLOAD, + version!.id, + poisonManifestEntries(version!.owner_org, versionName), + ) - expect(poisonError).not.toBeNull() - expect(poisonError?.message).toContain('bundle_already_ready') + expect(response.status).toBeGreaterThanOrEqual(400) + const body = await response.text() + expect(body).toContain('bundle_already_ready') const { data: manifestRows, error: manifestError } = await adminClient .from('manifest') @@ -102,8 +110,7 @@ describe('manifest poison guard', () => { it.concurrent('blocks write API key from poisoning manifest via app_versions.manifest on r2-direct', async () => { const adminClient = getSupabaseClient() - const writeClient = createApiKeyClient(APIKEY_TEST_ALL) - const versionName = `${BUNDLE_NAME}-write` + const versionName = `1.0.0-poison-write-${randomUUID().slice(0, 8)}` const { data: version, error: insertError } = await adminClient .from('app_versions') @@ -122,15 +129,15 @@ describe('manifest poison guard', () => { expect(insertError).toBeNull() try { - const { error: poisonError } = await writeClient - .from('app_versions') - .update({ - manifest: poisonManifestEntries(version!.owner_org), - }) - .eq('id', version!.id) + const response = await patchVersionManifestAsApiKey( + APIKEY_TEST_ALL, + version!.id, + poisonManifestEntries(version!.owner_org, versionName), + ) - expect(poisonError).not.toBeNull() - expect(poisonError?.message).toContain('bundle_already_ready') + expect(response.status).toBeGreaterThanOrEqual(400) + const body = await response.text() + expect(body).toContain('bundle_already_ready') const { data: manifestRows, error: manifestError } = await adminClient .from('manifest') @@ -145,9 +152,9 @@ describe('manifest poison guard', () => { } }) - it('still allows legitimate manifest upload via set_manifest on r2-direct', async () => { + it.concurrent('still allows legitimate manifest upload via set_manifest on r2-direct', async () => { const adminClient = getSupabaseClient() - const versionName = `${BUNDLE_NAME}-legit` + const versionName = `1.0.0-poison-legit-${randomUUID().slice(0, 8)}` const { data: version, error: insertError } = await adminClient .from('app_versions') @@ -178,30 +185,33 @@ describe('manifest poison guard', () => { ], } - const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': APIKEY_TEST_UPLOAD, - }, - body: JSON.stringify(body), - }) - - expect(response.status).toBe(200) - const json = await response.json() as { status: string, inserted: number } - expect(json.status).toBe('ok') - expect(json.inserted).toBe(1) + try { + const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': APIKEY_TEST_UPLOAD, + }, + body: JSON.stringify(body), + }) - const { data: rows, error: manifestError } = await adminClient - .from('manifest') - .select('file_name, file_hash, s3_path') - .eq('app_version_id', version!.id) + expect(response.status).toBe(200) + const json = await response.json() as { status: string, inserted: number } + expect(json.status).toBe('ok') + expect(json.inserted).toBe(1) - expect(manifestError).toBeNull() - expect(rows).toHaveLength(1) - expect(rows?.[0]?.file_name).toBe('index.html') + const { data: rows, error: manifestError } = await adminClient + .from('manifest') + .select('file_name, file_hash, s3_path') + .eq('app_version_id', version!.id) - await adminClient.from('manifest').delete().eq('app_version_id', version!.id) - await adminClient.from('app_versions').delete().eq('id', version!.id) + expect(manifestError).toBeNull() + expect(rows).toHaveLength(1) + expect(rows?.[0]?.file_name).toBe('index.html') + } + finally { + await adminClient.from('manifest').delete().eq('app_version_id', version!.id) + await adminClient.from('app_versions').delete().eq('id', version!.id) + } }) }) From b1fb82ff87dc78050ed39043916b6c0b55b7a3f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:39:54 +0000 Subject: [PATCH 03/30] fix(security): block r2-direct manifest jsonb smuggle on finalize Broaden trigger guard to reject any non-null manifest jsonb write while OLD.storage_provider is r2-direct, including r2-direct -> r2 finalize requests that tried to poison public.manifest via on_version_update. Update regression tests for the blocked legacy jsonb path and parametrize upload/write API key poison attempts. Co-authored-by: Martin DONADIEU --- ..._block_r2_direct_manifest_jsonb_writes.sql | 4 +- ...73_test_block_r2_direct_manifest_jsonb.sql | 20 ++++- tests/cleanup_swap_memory.test.ts | 41 ++++++---- tests/manifest-poison-guard.test.ts | 79 +++---------------- tests/set-manifest.test.ts | 32 +++++--- 5 files changed, 77 insertions(+), 99 deletions(-) diff --git a/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql b/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql index ffd4e7ac99..12e7e3a648 100644 --- a/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql +++ b/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql @@ -97,8 +97,10 @@ BEGIN || 'after upload is complete. Upload a new bundle instead.'; END IF; + -- In-progress r2-direct uploads must use POST /private/set_manifest. + -- Block any non-null manifest jsonb write, including r2-direct -> r2 finalize + -- requests that try to smuggle manifest rows through on_version_update. IF OLD.storage_provider = 'r2-direct' - AND NEW.storage_provider = 'r2-direct' AND NEW.manifest IS DISTINCT FROM OLD.manifest AND NEW.manifest IS NOT NULL THEN diff --git a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql index 0f12e61fc8..089bd0ba98 100644 --- a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql +++ b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql @@ -1,7 +1,7 @@ -- In-progress r2-direct versions must not accept app_versions.manifest jsonb writes. BEGIN; -SELECT plan(2); +SELECT plan(3); SELECT tests.authenticate_as_service_role(); SELECT tests.create_supabase_user('r2_direct_manifest_block_owner', 'r2_direct_manifest_block_owner@test.local'); @@ -77,7 +77,7 @@ SELECT throws_ok( 'in-progress r2-direct cannot UPDATE manifest jsonb' ); -SELECT lives_ok( +SELECT throws_ok( $sql$ UPDATE public.app_versions SET @@ -93,7 +93,21 @@ SELECT lives_ok( WHERE app_id = 'com.test.r2direct.manifest.block' AND name = '1.0.0-in-progress' $sql$, - 'legacy finalize can still set manifest while moving r2-direct -> r2' + 'P0001', + 'bundle_already_ready: Bundle content cannot be changed after upload is complete. Upload a new bundle instead.', + 'r2-direct cannot set manifest jsonb while finalizing to r2' +); + +SELECT lives_ok( + $sql$ + UPDATE public.app_versions + SET + storage_provider = 'r2', + r2_path = 'orgs/70000000-0000-4000-8000-000000000073/apps/com.test.r2direct.manifest.block/1.0.0-in-progress.zip' + WHERE app_id = 'com.test.r2direct.manifest.block' + AND name = '1.0.0-in-progress' + $sql$, + 'r2-direct can finalize to r2 without manifest jsonb' ); SELECT * FROM finish(); diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index 6a9d417083..c60decf336 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -7,7 +7,6 @@ describe('swap memory cleanup functions', () => { await cleanupPostgresClient() }) - it('cleanup_queue_messages skips queues whose archive tables are missing', async () => { const queueName = `cleanup_missing_${randomUUID().slice(0, 8)}` await executeSQL( @@ -142,25 +141,42 @@ describe('swap memory cleanup functions', () => { ) const versionRows = await executeSQL( - `INSERT INTO public.app_versions (app_id, name, owner_org, storage_provider, comment) - VALUES ($1, $2, $3::uuid, 'r2-direct', 'before') - RETURNING id, name`, - [appId, `1.0.0-${randomUUID().slice(0, 8)}`, orgId], + `INSERT INTO public.app_versions ( + app_id, + name, + owner_org, + storage_provider, + comment, + manifest, + r2_path + ) + VALUES ( + $1, + $2, + $3::uuid, + 'r2', + 'before', + ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry], + $5 + ) + RETURNING id`, + [ + appId, + `1.0.0-${randomUUID().slice(0, 8)}`, + orgId, + `orgs/${orgId}/apps/${appId}/delta/hash_a.js`, + `orgs/${orgId}/apps/${appId}/1.0.0-seed.zip`, + ], ) const versionId = versionRows[0]?.id as number - const versionName = versionRows[0]?.name as string - const canonicalR2Path = `orgs/${orgId}/apps/${appId}/${versionName}.zip` await executeSQL( `UPDATE public.app_versions SET comment = 'after', - storage_provider = 'r2', - r2_path = $2, - manifest = ARRAY[ROW('a.js', $3, 'hash')::public.manifest_entry], native_packages = ARRAY['{"name":"cordova-plugin"}'::jsonb] WHERE id = $1`, - [versionId, canonicalR2Path, `orgs/${orgId}/apps/${appId}/delta/hash_a.js`], + [versionId], ) const logs = await executeSQL( @@ -449,7 +465,6 @@ describe('swap memory cleanup functions', () => { } }) - it('app_versions_manifest_present_idx exists as valid partial index on id', async () => { const rows = await executeSQL( `SELECT @@ -467,6 +482,4 @@ describe('swap memory cleanup functions', () => { expect(String(rows[0]?.indexdef)).toContain('(id)') expect(String(rows[0]?.indexdef).toLowerCase()).toContain('manifest is not null') }) - - }) diff --git a/tests/manifest-poison-guard.test.ts b/tests/manifest-poison-guard.test.ts index 9e94311e9c..23481d6125 100644 --- a/tests/manifest-poison-guard.test.ts +++ b/tests/manifest-poison-guard.test.ts @@ -1,6 +1,5 @@ import type { Database } from '../src/types/supabase.types' import { randomUUID } from 'node:crypto' -import { createClient } from '@supabase/supabase-js' import { describe, expect, it } from 'vitest' import { APIKEY_TEST_ALL, @@ -14,23 +13,6 @@ import { const APP_ID = 'com.demo.app' -function createApiKeyClient(apikey: string) { - const supabaseUrl = process.env.SUPABASE_URL as string - const supabaseAnonKey = process.env.SUPABASE_ANON_KEY as string - - return createClient(supabaseUrl, supabaseAnonKey, { - global: { - headers: { - capgkey: apikey, - }, - }, - auth: { - autoRefreshToken: false, - persistSession: false, - }, - }) -} - function poisonManifestEntries(ownerOrg: string, versionName: string) { const prefix = `orgs/${ownerOrg}/apps/${APP_ID}/delta` return [ @@ -53,64 +35,23 @@ async function patchVersionManifestAsApiKey( return fetch(`${supabaseUrl}/rest/v1/app_versions?id=eq.${versionId}`, { method: 'PATCH', headers: { - apikey: anonKey, - Authorization: `Bearer ${anonKey}`, - capgkey: apikey, + 'apikey': anonKey, + 'Authorization': `Bearer ${anonKey}`, + 'capgkey': apikey, 'Content-Type': 'application/json', - Prefer: 'return=minimal', + 'Prefer': 'return=minimal', }, body: JSON.stringify({ manifest }), }) } describe('manifest poison guard', () => { - it.concurrent('blocks upload API key from poisoning manifest via app_versions.manifest on r2-direct', async () => { - const adminClient = getSupabaseClient() - const versionName = `1.0.0-poison-upload-${randomUUID().slice(0, 8)}` - - const { data: version, error: insertError } = await adminClient - .from('app_versions') - .insert({ - app_id: APP_ID, - name: versionName, - checksum: randomUUID().replaceAll('-', ''), - owner_org: ORG_ID, - user_id: USER_ID, - storage_provider: 'r2-direct', - deleted: false, - }) - .select('id, owner_org') - .single() - - expect(insertError).toBeNull() - - try { - const response = await patchVersionManifestAsApiKey( - APIKEY_TEST_UPLOAD, - version!.id, - poisonManifestEntries(version!.owner_org, versionName), - ) - - expect(response.status).toBeGreaterThanOrEqual(400) - const body = await response.text() - expect(body).toContain('bundle_already_ready') - - const { data: manifestRows, error: manifestError } = await adminClient - .from('manifest') - .select('id') - .eq('app_version_id', version!.id) - - expect(manifestError).toBeNull() - expect(manifestRows).toHaveLength(0) - } - finally { - await adminClient.from('app_versions').delete().eq('id', version!.id) - } - }) - - it.concurrent('blocks write API key from poisoning manifest via app_versions.manifest on r2-direct', async () => { + it.concurrent.each([ + ['upload', APIKEY_TEST_UPLOAD], + ['write', APIKEY_TEST_ALL], + ])('blocks %s API key from poisoning manifest via app_versions.manifest on r2-direct', async (_label, apikey) => { const adminClient = getSupabaseClient() - const versionName = `1.0.0-poison-write-${randomUUID().slice(0, 8)}` + const versionName = `1.0.0-poison-${randomUUID().slice(0, 8)}` const { data: version, error: insertError } = await adminClient .from('app_versions') @@ -130,7 +71,7 @@ describe('manifest poison guard', () => { try { const response = await patchVersionManifestAsApiKey( - APIKEY_TEST_ALL, + apikey, version!.id, poisonManifestEntries(version!.owner_org, versionName), ) diff --git a/tests/set-manifest.test.ts b/tests/set-manifest.test.ts index 8d6ecfa7a4..ac5b142249 100644 --- a/tests/set-manifest.test.ts +++ b/tests/set-manifest.test.ts @@ -121,7 +121,7 @@ describe('[POST] /private/set_manifest', () => { expect(retryJson.inserted).toBe(0) }) - it('rejects paths outside the app prefix and keeps old jsonb upload compatible', async () => { + it('rejects paths outside the app prefix and blocks r2-direct manifest jsonb writes', async () => { const otherBundle = `${BUNDLE_NAME}-legacy` const { data: version, error } = await getSupabaseClient() .from('app_versions') @@ -156,8 +156,7 @@ describe('[POST] /private/set_manifest', () => { }) expect(response.status).toBe(400) - // Legacy CLI path: writing jsonb onto app_versions.manifest still works. - await executeSQL( + await expect(executeSQL( `UPDATE public.app_versions SET storage_provider = 'r2', manifest = ARRAY[ @@ -166,16 +165,25 @@ describe('[POST] /private/set_manifest', () => { updated_at = now() WHERE id = $1`, [version!.id, `orgs/${version!.owner_org}/apps/${APP_ID}/delta/legacy_legacy.html`], - ) + )).rejects.toThrow(/bundle_already_ready/) - const { data: legacyVersion } = await getSupabaseClient() - .from('app_versions') - .select('manifest') - .eq('id', version!.id) - .single() - - expect(Array.isArray(legacyVersion?.manifest)).toBe(true) - expect(legacyVersion?.manifest?.length).toBe(1) + const legit = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': APIKEY_TEST_ALL, + }, + body: JSON.stringify({ + app_id: APP_ID, + name: otherBundle, + manifest: [{ + file_name: 'legacy.html', + s3_path: `orgs/${version!.owner_org}/apps/${APP_ID}/delta/legacy_legacy.html`, + file_hash: 'legacyhash', + }], + }), + }) + expect(legit.status).toBe(200) }) it('rejects finalized versions that have no manifest rows yet', async () => { From b5ecdd49c18b145ec8f58c0dc0039d208057e882 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:40:18 +0000 Subject: [PATCH 04/30] test: align manifest s3_path in audit skip regression Co-authored-by: Martin DONADIEU --- tests/cleanup_swap_memory.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index c60decf336..2448905451 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -264,8 +264,8 @@ describe('swap memory cleanup functions', () => { await executeSQL( `INSERT INTO public.manifest (app_version_id, file_name, s3_path, file_hash) - VALUES ($1, 'a.js', 'apps/a.js', 'hash')`, - [versionId], + VALUES ($1, 'a.js', $2, 'hash')`, + [versionId, manifestPath], ) await executeSQL( `UPDATE public.app_versions From 6d6a5aea0a8a095bfe3569032539783ba9dc8f4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 06:55:18 +0000 Subject: [PATCH 05/30] test: use canonical r2_path in audit fat-fields regression Co-authored-by: Martin DONADIEU --- tests/cleanup_swap_memory.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index 2448905451..ce1a478ab3 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -140,6 +140,10 @@ describe('swap memory cleanup functions', () => { [appId, orgId], ) + const versionName = `1.0.0-${randomUUID().slice(0, 8)}` + const canonicalR2Path = `orgs/${orgId}/apps/${appId}/${versionName}.zip` + const manifestPath = `orgs/${orgId}/apps/${appId}/delta/hash_a.js` + const versionRows = await executeSQL( `INSERT INTO public.app_versions ( app_id, @@ -162,10 +166,10 @@ describe('swap memory cleanup functions', () => { RETURNING id`, [ appId, - `1.0.0-${randomUUID().slice(0, 8)}`, + versionName, orgId, - `orgs/${orgId}/apps/${appId}/delta/hash_a.js`, - `orgs/${orgId}/apps/${appId}/1.0.0-seed.zip`, + manifestPath, + canonicalR2Path, ], ) const versionId = versionRows[0]?.id as number From 26f2ad40b11b6b5ba7ee69e1428f674ea1ede4b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 07:06:49 +0000 Subject: [PATCH 06/30] test: fix audit fat-fields regression for r2-direct manifest lock Co-authored-by: Martin DONADIEU --- tests/cleanup_swap_memory.test.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index ce1a478ab3..c24356b1ae 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -141,7 +141,6 @@ describe('swap memory cleanup functions', () => { ) const versionName = `1.0.0-${randomUUID().slice(0, 8)}` - const canonicalR2Path = `orgs/${orgId}/apps/${appId}/${versionName}.zip` const manifestPath = `orgs/${orgId}/apps/${appId}/delta/hash_a.js` const versionRows = await executeSQL( @@ -151,17 +150,15 @@ describe('swap memory cleanup functions', () => { owner_org, storage_provider, comment, - manifest, - r2_path + manifest ) VALUES ( $1, $2, $3::uuid, - 'r2', + 'r2-direct', 'before', - ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry], - $5 + ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry] ) RETURNING id`, [ @@ -169,15 +166,22 @@ describe('swap memory cleanup functions', () => { versionName, orgId, manifestPath, - canonicalR2Path, ], ) const versionId = versionRows[0]?.id as number + await executeSQL( + `INSERT INTO public.manifest (app_version_id, file_name, s3_path, file_hash) + VALUES ($1, 'a.js', $2, 'hash')`, + [versionId, manifestPath], + ) + await executeSQL( `UPDATE public.app_versions SET comment = 'after', + manifest = NULL, + manifest_count = 1, native_packages = ARRAY['{"name":"cordova-plugin"}'::jsonb] WHERE id = $1`, [versionId], @@ -204,6 +208,7 @@ describe('swap memory cleanup functions', () => { expect(logs[0]?.changed_fields).toContain('native_packages') await executeSQL(`DELETE FROM public.audit_logs WHERE record_id = $1 AND table_name = 'app_versions'`, [String(versionId)]) + await executeSQL(`DELETE FROM public.manifest WHERE app_version_id = $1`, [versionId]) await executeSQL(`DELETE FROM public.app_versions WHERE id = $1`, [versionId]) await executeSQL(`DELETE FROM public.apps WHERE app_id = $1`, [appId]) }) From 58da280d6563a6e0cf4d8c4031a4d4207a789e40 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 07:17:49 +0000 Subject: [PATCH 07/30] test: assert set_manifest inserts rows after jsonb block Co-authored-by: Martin DONADIEU --- tests/set-manifest.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/set-manifest.test.ts b/tests/set-manifest.test.ts index ac5b142249..2d3261ac4e 100644 --- a/tests/set-manifest.test.ts +++ b/tests/set-manifest.test.ts @@ -184,6 +184,17 @@ describe('[POST] /private/set_manifest', () => { }), }) expect(legit.status).toBe(200) + const legitJson = await legit.json() as { status: string, inserted: number } + expect(legitJson.status).toBe('ok') + expect(legitJson.inserted).toBe(1) + + const { data: rows, error: rowsErr } = await getSupabaseClient() + .from('manifest') + .select('file_name') + .eq('app_version_id', version!.id) + + expect(rowsErr).toBeNull() + expect(rows?.map(row => row.file_name)).toEqual(['legacy.html']) }) it('rejects finalized versions that have no manifest rows yet', async () => { From 640dd0db9f299fb16686a2f4a0e338ff0c9835b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 07:31:18 +0000 Subject: [PATCH 08/30] ci: retrigger test workflow Co-authored-by: Martin DONADIEU From cfbc5c54e1f9d745287af1d200a134d22dd2affe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 08:07:59 +0000 Subject: [PATCH 09/30] fix(security): block r2-direct manifest jsonb on INSERT too Reject INSERT when storage_provider is r2-direct and manifest jsonb is set, with an error that points uploaders to POST /private/set_manifest. Extract app_version_manifest_jsonb_unmigrated() for the repeated migration check, skip manifest reload in on_version_update for r2-direct rows, and update regression tests to seed without r2-direct jsonb manifest. Co-authored-by: Martin DONADIEU --- .../_backend/triggers/on_version_update.ts | 9 +- ..._block_r2_direct_manifest_jsonb_writes.sql | 87 +++++++++++-------- ...73_test_block_r2_direct_manifest_jsonb.sql | 40 ++++++++- tests/cleanup_swap_memory.test.ts | 26 ++++-- tests/manifest-poison-guard.test.ts | 26 ++++-- tests/set-manifest.test.ts | 2 +- 6 files changed, 133 insertions(+), 57 deletions(-) diff --git a/supabase/functions/_backend/triggers/on_version_update.ts b/supabase/functions/_backend/triggers/on_version_update.ts index 06a8b35a32..62d731da3a 100644 --- a/supabase/functions/_backend/triggers/on_version_update.ts +++ b/supabase/functions/_backend/triggers/on_version_update.ts @@ -259,11 +259,12 @@ async function updateIt(c: Context, record: Database['public']['Tables']['app_ve } } - // Handle manifest entries (reload when the queue payload omitted the jsonb column). // In-progress r2-direct uploads must use POST /private/set_manifest instead. - const recordWithManifest = await ensureVersionManifest(c, record) - if (recordWithManifest.manifest && recordWithManifest.storage_provider !== 'r2-direct') - await handleManifest(c, recordWithManifest) + if (record.storage_provider !== 'r2-direct') { + const recordWithManifest = await ensureVersionManifest(c, record) + if (recordWithManifest.manifest) + await handleManifest(c, recordWithManifest) + } return c.json(BRES) } diff --git a/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql b/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql index 12e7e3a648..f4bc6e7c73 100644 --- a/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql +++ b/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql @@ -1,7 +1,32 @@ -- Block PostgREST writes to app_versions.manifest while a bundle is still -- in-progress (storage_provider = r2-direct). Legitimate delta uploads use -- POST /private/set_manifest, which inserts into public.manifest directly. --- Legacy CLIs finalize with r2-direct -> r2 in the same UPDATE. + +CREATE OR REPLACE FUNCTION public.app_version_manifest_jsonb_unmigrated( + p_version_id bigint, + p_manifest public.manifest_entry[] +) +RETURNS boolean +LANGUAGE sql +STABLE +SET search_path = '' +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.unnest(p_manifest) AS entry(file_name, s3_path, file_hash) + WHERE NOT EXISTS ( + SELECT 1 + FROM public.manifest AS m + WHERE m.app_version_id = p_version_id + AND m.s3_path = entry.s3_path + AND m.file_hash = entry.file_hash + ) + ); +$$; + +ALTER FUNCTION public.app_version_manifest_jsonb_unmigrated(bigint, public.manifest_entry[]) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.app_version_manifest_jsonb_unmigrated(bigint, public.manifest_entry[]) FROM PUBLIC; +GRANT ALL ON FUNCTION public.app_version_manifest_jsonb_unmigrated(bigint, public.manifest_entry[]) TO service_role; CREATE OR REPLACE FUNCTION "public"."check_encrypted_bundle_on_insert"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER @@ -14,7 +39,27 @@ DECLARE bundle_is_encrypted boolean; bundle_key_id varchar(20); bundle_was_ready boolean; + r2_direct_manifest_err constant text := + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress ' + || 'r2-direct uploads instead of app_versions.manifest jsonb.'; BEGIN + IF TG_OP = 'INSERT' + AND NEW.storage_provider = 'r2-direct' + AND NEW.manifest IS NOT NULL + THEN + PERFORM public.pg_log('deny: BUNDLE_CONTENT_LOCKED_TRIGGER', + pg_catalog.jsonb_build_object( + 'org_id', NEW.owner_org, + 'app_id', NEW.app_id, + 'version_name', NEW.name, + 'user_id', NEW.user_id, + 'old_storage_provider', NULL, + 'new_storage_provider', NEW.storage_provider, + 'reason', 'r2_direct_manifest_jsonb' + )); + RAISE EXCEPTION '%', r2_direct_manifest_err; + END IF; + IF TG_OP = 'UPDATE' THEN IF pg_catalog.current_setting('capgo.reclaim_manifest_null', true) = 'on' AND NEW.manifest IS NULL @@ -34,17 +79,7 @@ BEGIN IF NEW.manifest IS NULL AND OLD.manifest IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) - WHERE NOT EXISTS ( - SELECT 1 - FROM public.manifest AS m - WHERE m.app_version_id = OLD.id - AND m.s3_path = entry.s3_path - AND m.file_hash = entry.file_hash - ) - ) + AND public.app_version_manifest_jsonb_unmigrated(OLD.id, OLD.manifest) THEN RAISE EXCEPTION '%', 'bundle_manifest_not_migrated: Cannot clear app_versions.manifest ' @@ -67,17 +102,7 @@ BEGIN OR ( NEW.manifest IS NULL AND OLD.manifest IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) - WHERE NOT EXISTS ( - SELECT 1 - FROM public.manifest AS m - WHERE m.app_version_id = OLD.id - AND m.s3_path = entry.s3_path - AND m.file_hash = entry.file_hash - ) - ) + AND public.app_version_manifest_jsonb_unmigrated(OLD.id, OLD.manifest) ) OR NEW.native_packages IS DISTINCT FROM OLD.native_packages ) @@ -114,9 +139,7 @@ BEGIN 'new_storage_provider', NEW.storage_provider, 'reason', 'r2_direct_manifest_jsonb' )); - RAISE EXCEPTION '%', - 'bundle_already_ready: Bundle content cannot be changed ' - || 'after upload is complete. Upload a new bundle instead.'; + RAISE EXCEPTION '%', r2_direct_manifest_err; END IF; END IF; @@ -135,17 +158,7 @@ BEGIN OR ( NEW.manifest IS NULL AND OLD.manifest IS NOT NULL - AND NOT EXISTS ( - SELECT 1 - FROM pg_catalog.unnest(OLD.manifest) AS entry(file_name, s3_path, file_hash) - WHERE NOT EXISTS ( - SELECT 1 - FROM public.manifest AS m - WHERE m.app_version_id = OLD.id - AND m.s3_path = entry.s3_path - AND m.file_hash = entry.file_hash - ) - ) + AND NOT public.app_version_manifest_jsonb_unmigrated(OLD.id, OLD.manifest) ) ) THEN diff --git a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql index 089bd0ba98..267bb9a381 100644 --- a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql +++ b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql @@ -1,7 +1,7 @@ -- In-progress r2-direct versions must not accept app_versions.manifest jsonb writes. BEGIN; -SELECT plan(3); +SELECT plan(4); SELECT tests.authenticate_as_service_role(); SELECT tests.create_supabase_user('r2_direct_manifest_block_owner', 'r2_direct_manifest_block_owner@test.local'); @@ -59,6 +59,40 @@ SET manifest = NULL, deleted = false; +SELECT throws_ok( + $sql$ + INSERT INTO public.app_versions ( + app_id, + name, + owner_org, + user_id, + storage_provider, + checksum, + manifest, + deleted + ) + VALUES ( + 'com.test.r2direct.manifest.block', + '1.0.0-insert-poison', + '70000000-0000-4000-8000-000000000073', + tests.get_supabase_uid('r2_direct_manifest_block_owner'), + 'r2-direct', + 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + ARRAY[ + ROW( + 'index.html', + 'orgs/70000000-0000-4000-8000-000000000073/apps/com.test.r2direct.manifest.block/delta/insert_poison_index.html', + 'dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' + )::public.manifest_entry + ], + false + ) + $sql$, + 'P0001', + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress r2-direct uploads instead of app_versions.manifest jsonb.', + 'in-progress r2-direct cannot INSERT manifest jsonb' +); + SELECT throws_ok( $sql$ UPDATE public.app_versions @@ -73,7 +107,7 @@ SELECT throws_ok( AND name = '1.0.0-in-progress' $sql$, 'P0001', - 'bundle_already_ready: Bundle content cannot be changed after upload is complete. Upload a new bundle instead.', + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress r2-direct uploads instead of app_versions.manifest jsonb.', 'in-progress r2-direct cannot UPDATE manifest jsonb' ); @@ -94,7 +128,7 @@ SELECT throws_ok( AND name = '1.0.0-in-progress' $sql$, 'P0001', - 'bundle_already_ready: Bundle content cannot be changed after upload is complete. Upload a new bundle instead.', + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress r2-direct uploads instead of app_versions.manifest jsonb.', 'r2-direct cannot set manifest jsonb while finalizing to r2' ); diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index c24356b1ae..93f4e016c5 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -142,6 +142,7 @@ describe('swap memory cleanup functions', () => { const versionName = `1.0.0-${randomUUID().slice(0, 8)}` const manifestPath = `orgs/${orgId}/apps/${appId}/delta/hash_a.js` + const canonicalR2Path = `orgs/${orgId}/apps/${appId}/${versionName}.zip` const versionRows = await executeSQL( `INSERT INTO public.app_versions ( @@ -150,15 +151,17 @@ describe('swap memory cleanup functions', () => { owner_org, storage_provider, comment, - manifest + manifest, + r2_path ) VALUES ( $1, $2, $3::uuid, - 'r2-direct', + 'r2', 'before', - ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry] + ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry], + $5 ) RETURNING id`, [ @@ -166,6 +169,7 @@ describe('swap memory cleanup functions', () => { versionName, orgId, manifestPath, + canonicalR2Path, ], ) const versionId = versionRows[0]?.id as number @@ -255,18 +259,26 @@ describe('swap memory cleanup functions', () => { owner_org, storage_provider, comment, - manifest + manifest, + r2_path ) VALUES ( $1, $2, $3::uuid, - 'r2-direct', + 'r2', 'seed', - ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry] + ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry], + $5 ) RETURNING id`, - [appId, versionName, orgId, manifestPath], + [ + appId, + versionName, + orgId, + manifestPath, + `orgs/${orgId}/apps/${appId}/${versionName}.zip`, + ], ) const versionId = versionRows[0]?.id as number await clearVersionAudits(versionId) diff --git a/tests/manifest-poison-guard.test.ts b/tests/manifest-poison-guard.test.ts index 23481d6125..cd644afa28 100644 --- a/tests/manifest-poison-guard.test.ts +++ b/tests/manifest-poison-guard.test.ts @@ -13,6 +13,9 @@ import { const APP_ID = 'com.demo.app' +const R2_DIRECT_MANIFEST_ERR = 'r2_direct_manifest_jsonb' +const SET_MANIFEST_PATH = '/private/set_manifest' + function poisonManifestEntries(ownerOrg: string, versionName: string) { const prefix = `orgs/${ownerOrg}/apps/${APP_ID}/delta` return [ @@ -68,28 +71,41 @@ describe('manifest poison guard', () => { .single() expect(insertError).toBeNull() + expect(version).not.toBeNull() + if (!version) + return try { const response = await patchVersionManifestAsApiKey( apikey, - version!.id, - poisonManifestEntries(version!.owner_org, versionName), + version.id, + poisonManifestEntries(version.owner_org, versionName), ) expect(response.status).toBeGreaterThanOrEqual(400) const body = await response.text() - expect(body).toContain('bundle_already_ready') + expect(body).toContain(R2_DIRECT_MANIFEST_ERR) + expect(body).toContain(SET_MANIFEST_PATH) + + const { data: versionRow, error: versionError } = await adminClient + .from('app_versions') + .select('manifest') + .eq('id', version.id) + .single() + + expect(versionError).toBeNull() + expect(versionRow?.manifest).toBeNull() const { data: manifestRows, error: manifestError } = await adminClient .from('manifest') .select('id') - .eq('app_version_id', version!.id) + .eq('app_version_id', version.id) expect(manifestError).toBeNull() expect(manifestRows).toHaveLength(0) } finally { - await adminClient.from('app_versions').delete().eq('id', version!.id) + await adminClient.from('app_versions').delete().eq('id', version.id) } }) diff --git a/tests/set-manifest.test.ts b/tests/set-manifest.test.ts index 2d3261ac4e..3b158d5207 100644 --- a/tests/set-manifest.test.ts +++ b/tests/set-manifest.test.ts @@ -165,7 +165,7 @@ describe('[POST] /private/set_manifest', () => { updated_at = now() WHERE id = $1`, [version!.id, `orgs/${version!.owner_org}/apps/${APP_ID}/delta/legacy_legacy.html`], - )).rejects.toThrow(/bundle_already_ready/) + )).rejects.toThrow(/r2_direct_manifest_jsonb/) const legit = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', From a0b5fee48bde1d17cc5278905ce3e1d9da33cb62 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 08:22:24 +0000 Subject: [PATCH 10/30] test: fix audit fat-fields setup for r2 content lock Co-authored-by: Martin DONADIEU --- tests/cleanup_swap_memory.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/cleanup_swap_memory.test.ts b/tests/cleanup_swap_memory.test.ts index 93f4e016c5..394d5c995f 100644 --- a/tests/cleanup_swap_memory.test.ts +++ b/tests/cleanup_swap_memory.test.ts @@ -152,7 +152,8 @@ describe('swap memory cleanup functions', () => { storage_provider, comment, manifest, - r2_path + r2_path, + native_packages ) VALUES ( $1, @@ -161,7 +162,8 @@ describe('swap memory cleanup functions', () => { 'r2', 'before', ARRAY[ROW('a.js', $4, 'hash')::public.manifest_entry], - $5 + $5, + ARRAY['{"name":"cordova-plugin"}'::jsonb] ) RETURNING id`, [ @@ -185,8 +187,7 @@ describe('swap memory cleanup functions', () => { SET comment = 'after', manifest = NULL, - manifest_count = 1, - native_packages = ARRAY['{"name":"cordova-plugin"}'::jsonb] + manifest_count = 1 WHERE id = $1`, [versionId], ) @@ -209,7 +210,6 @@ describe('swap memory cleanup functions', () => { expect(logs[0]?.changed_fields).toContain('comment') // Fat payloads stay stripped, but field names remain for upload-time history. expect(logs[0]?.changed_fields).toContain('manifest') - expect(logs[0]?.changed_fields).toContain('native_packages') await executeSQL(`DELETE FROM public.audit_logs WHERE record_id = $1 AND table_name = 'app_versions'`, [String(versionId)]) await executeSQL(`DELETE FROM public.manifest WHERE app_version_id = $1`, [versionId]) From 57d6dddd2556809f6cc8851a3b29e6655f425ee8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 08:22:24 +0000 Subject: [PATCH 11/30] ci: retrigger test workflow Co-authored-by: Martin DONADIEU From 64d5c9685a118a0b2a9089d38e8a557c2445023d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 08:36:07 +0000 Subject: [PATCH 12/30] test: guard set_manifest cleanup when version insert fails Co-authored-by: Martin DONADIEU --- tests/manifest-poison-guard.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/manifest-poison-guard.test.ts b/tests/manifest-poison-guard.test.ts index cd644afa28..ed80e89466 100644 --- a/tests/manifest-poison-guard.test.ts +++ b/tests/manifest-poison-guard.test.ts @@ -128,8 +128,11 @@ describe('manifest poison guard', () => { .single() expect(insertError).toBeNull() + expect(version).not.toBeNull() + if (!version) + return - const prefix = `orgs/${version!.owner_org}/apps/${APP_ID}/delta` + const prefix = `orgs/${version.owner_org}/apps/${APP_ID}/delta` const body = { app_id: APP_ID, name: versionName, @@ -160,15 +163,17 @@ describe('manifest poison guard', () => { const { data: rows, error: manifestError } = await adminClient .from('manifest') .select('file_name, file_hash, s3_path') - .eq('app_version_id', version!.id) + .eq('app_version_id', version.id) expect(manifestError).toBeNull() expect(rows).toHaveLength(1) expect(rows?.[0]?.file_name).toBe('index.html') } finally { - await adminClient.from('manifest').delete().eq('app_version_id', version!.id) - await adminClient.from('app_versions').delete().eq('id', version!.id) + if (version) { + await adminClient.from('manifest').delete().eq('app_version_id', version.id) + await adminClient.from('app_versions').delete().eq('id', version.id) + } } }) }) From 0700671c6e8b4410fbb0cf3cc86e025bc1767f3d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 08:43:38 +0000 Subject: [PATCH 13/30] ci: retrigger tests after flaky organization-api shard Co-authored-by: Martin DONADIEU From 01adafae37260035a5ddf198267cf40e5e4110a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 09:35:21 +0000 Subject: [PATCH 14/30] ci: retrigger full test suite Co-authored-by: Martin DONADIEU From 2eb2f074fa3ff318a93a4966bf21ebacfeca98a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 09:41:50 +0000 Subject: [PATCH 15/30] fix(db): re-stamp r2-direct manifest guard migration after main advance Co-authored-by: Martin DONADIEU --- ...l => 20260826094120_block_r2_direct_manifest_jsonb_writes.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260826061748_block_r2_direct_manifest_jsonb_writes.sql => 20260826094120_block_r2_direct_manifest_jsonb_writes.sql} (100%) diff --git a/supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql b/supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql similarity index 100% rename from supabase/migrations/20260826061748_block_r2_direct_manifest_jsonb_writes.sql rename to supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql From ec08b821ae600013abae9e28036dce46e5852cda Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 10:33:46 +0000 Subject: [PATCH 16/30] ci: retrigger tests (no concurrent pushes) Co-authored-by: Martin DONADIEU From 6cbcf992bd1600d20f9a76ee5b4fb21a721e8cc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 10:43:50 +0000 Subject: [PATCH 17/30] ci: retry after flaky cloudflare workers shard Co-authored-by: Martin DONADIEU From 57fd9d562d15f79ca406b1bb0eebcbaa9178cd99 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 10:51:32 +0000 Subject: [PATCH 18/30] ci: final test run Co-authored-by: Martin DONADIEU From f00a2af0a2d3d1f857af075d83886915f73f21db Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:38:51 +0000 Subject: [PATCH 19/30] fix(db): document manifest helper profile and satisfy SQLFluff LT05 Add execution-profile comments for app_version_manifest_jsonb_unmigrated (trigger frequency, roles, cardinality, index path). Wrap long ALTER/REVOKE/ GRANT signatures and pgTAP expected error strings to satisfy SQLFluff. Co-authored-by: Martin DONADIEU --- ..._block_r2_direct_manifest_jsonb_writes.sql | 26 ++++++++++++++++--- ...73_test_block_r2_direct_manifest_jsonb.sql | 9 ++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql b/supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql index f4bc6e7c73..b9f1a0b480 100644 --- a/supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql +++ b/supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql @@ -1,6 +1,20 @@ -- Block PostgREST writes to app_versions.manifest while a bundle is still -- in-progress (storage_provider = r2-direct). Legitimate delta uploads use -- POST /private/set_manifest, which inserts into public.manifest directly. +-- +-- Execution profile for app_version_manifest_jsonb_unmigrated: +-- - Called from check_encrypted_bundle_on_insert on public.app_versions +-- INSERT/UPDATE when manifest jsonb is cleared or compared (at most once +-- per affected row). +-- - Roles: service_role only; not exposed to anon/authenticated PostgREST. +-- - Frequency: console/API app_versions writes; not plugin /updates hot path. +-- - Cardinality: p_manifest is bounded by bundle file count (thousands of +-- entries at most); each entry probes public.manifest via app_version_id. +-- - Indexes: idx_manifest_app_version_id on (app_version_id); per-entry +-- s3_path/file_hash filter on the index-scanned row set. +-- - Worst case: Nested Loop from unnest(p_manifest) to Index Scan on +-- idx_manifest_app_version_id with s3_path/file_hash filters. Bounded by +-- manifest entry count, not table cardinality. CREATE OR REPLACE FUNCTION public.app_version_manifest_jsonb_unmigrated( p_version_id bigint, @@ -24,9 +38,15 @@ AS $$ ); $$; -ALTER FUNCTION public.app_version_manifest_jsonb_unmigrated(bigint, public.manifest_entry[]) OWNER TO postgres; -REVOKE ALL ON FUNCTION public.app_version_manifest_jsonb_unmigrated(bigint, public.manifest_entry[]) FROM PUBLIC; -GRANT ALL ON FUNCTION public.app_version_manifest_jsonb_unmigrated(bigint, public.manifest_entry[]) TO service_role; +ALTER FUNCTION public.app_version_manifest_jsonb_unmigrated( + bigint, public.manifest_entry[] +) OWNER TO postgres; +REVOKE ALL ON FUNCTION public.app_version_manifest_jsonb_unmigrated( + bigint, public.manifest_entry[] +) FROM PUBLIC; +GRANT ALL ON FUNCTION public.app_version_manifest_jsonb_unmigrated( + bigint, public.manifest_entry[] +) TO service_role; CREATE OR REPLACE FUNCTION "public"."check_encrypted_bundle_on_insert"() RETURNS "trigger" LANGUAGE "plpgsql" SECURITY DEFINER diff --git a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql index 267bb9a381..f71971238a 100644 --- a/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql +++ b/supabase/tests/73_test_block_r2_direct_manifest_jsonb.sql @@ -89,7 +89,8 @@ SELECT throws_ok( ) $sql$, 'P0001', - 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress r2-direct uploads instead of app_versions.manifest jsonb.', + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress ' + 'r2-direct uploads instead of app_versions.manifest jsonb.', 'in-progress r2-direct cannot INSERT manifest jsonb' ); @@ -107,7 +108,8 @@ SELECT throws_ok( AND name = '1.0.0-in-progress' $sql$, 'P0001', - 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress r2-direct uploads instead of app_versions.manifest jsonb.', + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress ' + 'r2-direct uploads instead of app_versions.manifest jsonb.', 'in-progress r2-direct cannot UPDATE manifest jsonb' ); @@ -128,7 +130,8 @@ SELECT throws_ok( AND name = '1.0.0-in-progress' $sql$, 'P0001', - 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress r2-direct uploads instead of app_versions.manifest jsonb.', + 'r2_direct_manifest_jsonb: Use POST /private/set_manifest for in-progress ' + 'r2-direct uploads instead of app_versions.manifest jsonb.', 'r2-direct cannot set manifest jsonb while finalizing to r2' ); From 5fd7081fb5a270a38ab6e3a6d8f02bda92e0689b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 11:46:17 +0000 Subject: [PATCH 20/30] fix(db): re-stamp r2-direct manifest guard after sso migration on main Co-authored-by: Martin DONADIEU --- ...l => 20260826101500_block_r2_direct_manifest_jsonb_writes.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260826094120_block_r2_direct_manifest_jsonb_writes.sql => 20260826101500_block_r2_direct_manifest_jsonb_writes.sql} (100%) diff --git a/supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql b/supabase/migrations/20260826101500_block_r2_direct_manifest_jsonb_writes.sql similarity index 100% rename from supabase/migrations/20260826094120_block_r2_direct_manifest_jsonb_writes.sql rename to supabase/migrations/20260826101500_block_r2_direct_manifest_jsonb_writes.sql From 9cf1b2c37c983a0ffe2ee033eee6d7991935af13 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 13:32:40 +0000 Subject: [PATCH 21/30] ci: sync CodeRabbit bot trigger workflow from main Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 77 ++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/coderabbit-bot-trigger.yml diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml new file mode 100644 index 0000000000..771c6a540c --- /dev/null +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -0,0 +1,77 @@ +name: Trigger CodeRabbit review for bot pushes + +on: + push: + branches: + - 'cursor/**' + - 'feat/admin-famous-apps' + workflow_dispatch: + +concurrency: + group: coderabbit-bot-trigger-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + issues: write + pull-requests: write + +jobs: + trigger: + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo + const branch = context.ref.replace('refs/heads/', '') + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + head: `${owner}:${branch}`, + state: 'open', + }) + if (prs.length === 0) { + console.log('No open PR for this branch') + return + } + + for (const pr of prs) { + const headSha = pr.head.sha + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pr.number, + }) + const latestCodeRabbitReview = reviews + .filter(review => review.user?.login === 'coderabbitai[bot]') + .pop() + + if ( + latestCodeRabbitReview?.commit_id === headSha + && latestCodeRabbitReview.state !== 'CHANGES_REQUESTED' + ) { + console.log(`CodeRabbit already reviewed PR #${pr.number} at ${headSha}`) + continue + } + + const marker = `` + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number: pr.number, + }) + if (comments.some(comment => + comment.user?.login === 'github-actions[bot]' + && comment.body?.includes(marker) + )) { + console.log(`CodeRabbit trigger already posted for PR #${pr.number}`) + continue + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pr.number, + body: `${marker}\n@coderabbitai review`, + }) + } From 2a3b8ee3fe0bb876bd522b4f23ea01bc37afdddd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 13:42:49 +0000 Subject: [PATCH 22/30] ci: reduce CodeRabbit trigger workflow pull-request permissions to read Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index 771c6a540c..cba7f08547 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -13,7 +13,7 @@ concurrency: permissions: issues: write - pull-requests: write + pull-requests: read jobs: trigger: From faf2d4a05552653243e4e9f1a24c7d17e343170f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 13:47:22 +0000 Subject: [PATCH 23/30] ci: document why CodeRabbit trigger workflow needs pull-requests write Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index cba7f08547..a76e6e488e 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -11,9 +11,11 @@ concurrency: group: coderabbit-bot-trigger-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +# pull-requests: write is required: GITHUB_TOKEN gets 403 on issues.createComment +# for PRs when only pull-requests: read is granted (see run 32975845301). permissions: issues: write - pull-requests: read + pull-requests: write jobs: trigger: From b8b72b9e0ba020fc009f1244d287d346be17fda8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:04:23 +0000 Subject: [PATCH 24/30] ci: retry CodeRabbit trigger when prior attempt had no review on HEAD Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index a76e6e488e..d073a1f2ca 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -48,6 +48,7 @@ jobs: .filter(review => review.user?.login === 'coderabbitai[bot]') .pop() + // Skip only when CodeRabbit already left a non-blocking review on this SHA. if ( latestCodeRabbitReview?.commit_id === headSha && latestCodeRabbitReview.state !== 'CHANGES_REQUESTED' @@ -62,13 +63,18 @@ jobs: repo, issue_number: pr.number, }) - if (comments.some(comment => + const alreadyTriggered = comments.some(comment => comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) - )) { - console.log(`CodeRabbit trigger already posted for PR #${pr.number}`) + ) + // Allow retry when a prior trigger hit rate limits and no review exists for headSha. + if (alreadyTriggered && latestCodeRabbitReview?.commit_id === headSha) { + console.log(`CodeRabbit trigger already completed for PR #${pr.number} at ${headSha}`) continue } + if (alreadyTriggered) { + console.log(`Retrying CodeRabbit trigger for PR #${pr.number}; prior trigger had no review on ${headSha}`) + } await github.rest.issues.createComment({ owner, From 77a039e97e8be136aaf49b46a05739336f7ca2a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:18:38 +0000 Subject: [PATCH 25/30] ci: use push SHA for CodeRabbit trigger marker Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index d073a1f2ca..448bc5a582 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -38,7 +38,9 @@ jobs: } for (const pr of prs) { - const headSha = pr.head.sha + const headSha = context.eventName === 'push' + ? context.sha + : pr.head.sha const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, From a751277687e2db22217771d26169620eb9a75384 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 14:46:58 +0000 Subject: [PATCH 26/30] ci: allow CodeRabbit trigger retry when review is CHANGES_REQUESTED Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index 448bc5a582..352880c6b0 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -69,8 +69,12 @@ jobs: comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) ) - // Allow retry when a prior trigger hit rate limits and no review exists for headSha. - if (alreadyTriggered && latestCodeRabbitReview?.commit_id === headSha) { + // Skip only when a prior trigger already produced a non-blocking review on headSha. + if ( + alreadyTriggered + && latestCodeRabbitReview?.commit_id === headSha + && latestCodeRabbitReview.state !== 'CHANGES_REQUESTED' + ) { console.log(`CodeRabbit trigger already completed for PR #${pr.number} at ${headSha}`) continue } From fecfb60c45f788456d7e8942d4f834d74c322893 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 15:05:51 +0000 Subject: [PATCH 27/30] test: retry Cloudflare worker-restart 503 in fetchTestRequest Co-authored-by: Martin DONADIEU --- tests/test-utils.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/test-utils.ts b/tests/test-utils.ts index b6851bb401..681f7f7948 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -492,11 +492,20 @@ export const headersInternal = { /** Kong proxy body when the Deno isolate dies mid-request under shard load. */ const KONG_UPSTREAM_INVALID_RESPONSE = 'An invalid response was received from the upstream server' +/** Cloudflare workerd body when the isolate reloads mid-request. */ +const CLOUDFLARE_WORKER_RESTART_RESPONSE = 'Your worker restarted mid-request' + +function isTransientGatewayDeath(status: number, body: string): boolean { + if (status !== 502 && status !== 503) + return false + return body.includes(KONG_UPSTREAM_INVALID_RESPONSE) + || body.includes(CLOUDFLARE_WORKER_RESTART_RESPONSE) +} /** * Send one request. Application 4xx/5xx are test evidence and are not retried. - * Only Kong's upstream-invalid 502/503 (isolate crash/reload) is retried — same - * signal the CI warm step already treats as non-ready. + * Only transient gateway 502/503 (isolate crash/reload) is retried — same signals + * the CI warm step already treats as non-ready. */ export async function fetchTestRequest( url: string, @@ -513,8 +522,7 @@ export async function fetchTestRequest( const body = await response.clone().text().catch(() => '') console.error(`[fetchTestRequest] gateway status=${response.status} attempt=${attempt}/${maxAttempts} url=${url} body=${body.slice(0, 800)}`) - const isKongUpstreamDeath = body.includes(KONG_UPSTREAM_INVALID_RESPONSE) - if (!isKongUpstreamDeath || attempt === maxAttempts) + if (!isTransientGatewayDeath(response.status, body) || attempt === maxAttempts) return response await new Promise(resolve => setTimeout(resolve, 250 * attempt)) From a827a89e63f81c982cdf753430ed431a2aa0c3de Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:47:19 +0000 Subject: [PATCH 28/30] test: restrict fetchTestRequest retries to safe methods by default Only retry transient gateway 502/503 on GET/HEAD/OPTIONS unless the caller opts in with retryUnsafe for idempotent mutations. set_manifest callers in this PR pass retryUnsafe because persistVersionManifestEntries is idempotent. Co-authored-by: Martin DONADIEU --- tests/manifest-poison-guard.test.ts | 1 + tests/set-manifest.test.ts | 8 ++++++++ tests/test-utils.ts | 26 ++++++++++++++++++++++---- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/tests/manifest-poison-guard.test.ts b/tests/manifest-poison-guard.test.ts index ed80e89466..6be61b332e 100644 --- a/tests/manifest-poison-guard.test.ts +++ b/tests/manifest-poison-guard.test.ts @@ -148,6 +148,7 @@ describe('manifest poison guard', () => { try { const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_UPLOAD, diff --git a/tests/set-manifest.test.ts b/tests/set-manifest.test.ts index 3b158d5207..849a03e093 100644 --- a/tests/set-manifest.test.ts +++ b/tests/set-manifest.test.ts @@ -73,6 +73,7 @@ describe('[POST] /private/set_manifest', () => { const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -109,6 +110,7 @@ describe('[POST] /private/set_manifest', () => { // Idempotent retry const retry = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -140,6 +142,7 @@ describe('[POST] /private/set_manifest', () => { const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -169,6 +172,7 @@ describe('[POST] /private/set_manifest', () => { const legit = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -215,6 +219,7 @@ describe('[POST] /private/set_manifest', () => { const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -257,6 +262,7 @@ describe('[POST] /private/set_manifest', () => { const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -276,6 +282,7 @@ describe('[POST] /private/set_manifest', () => { it('rejects missing versions and non-uploadable storage providers', async () => { const missing = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, @@ -308,6 +315,7 @@ describe('[POST] /private/set_manifest', () => { const response = await fetchTestRequest(getEndpointUrl('/private/set_manifest'), { method: 'POST', + retryUnsafe: true, headers: { 'Content-Type': 'application/json', 'Authorization': APIKEY_TEST_ALL, diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 681f7f7948..619ba6988e 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -502,19 +502,37 @@ function isTransientGatewayDeath(status: number, body: string): boolean { || body.includes(CLOUDFLARE_WORKER_RESTART_RESPONSE) } +export interface FetchTestRequestOptions extends RequestInit { + /** Retry transient gateway 502/503 on mutating methods when the endpoint is idempotent. */ + retryUnsafe?: boolean +} + +function isReplaySafeHttpMethod(method: string | undefined): boolean { + switch ((method ?? 'GET').toUpperCase()) { + case 'GET': + case 'HEAD': + case 'OPTIONS': + return true + default: + return false + } +} + /** * Send one request. Application 4xx/5xx are test evidence and are not retried. * Only transient gateway 502/503 (isolate crash/reload) is retried — same signals - * the CI warm step already treats as non-ready. + * the CI warm step already treats as non-ready. Mutating methods are not retried + * unless retryUnsafe is set (caller asserts idempotency). */ export async function fetchTestRequest( url: string, - options?: RequestInit, + options?: FetchTestRequestOptions, ): Promise { - const maxAttempts = 3 + const { retryUnsafe = false, ...fetchOptions } = options ?? {} + const maxAttempts = isReplaySafeHttpMethod(fetchOptions.method) || retryUnsafe ? 3 : 1 let lastResponse: Response | undefined for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const response = await fetch(url, options) + const response = await fetch(url, fetchOptions) lastResponse = response if (response.status !== 502 && response.status !== 503) return response From a7263a92e5740feb88a49f41c269de2a635ab5b8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 16:52:04 +0000 Subject: [PATCH 29/30] ci: retry CodeRabbit trigger until review is APPROVED COMMENTED reviews do not clear a prior CHANGES_REQUESTED decision. Only skip the bot trigger when CodeRabbit has approved the HEAD SHA. Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index 352880c6b0..afc0b1e52f 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -50,12 +50,12 @@ jobs: .filter(review => review.user?.login === 'coderabbitai[bot]') .pop() - // Skip only when CodeRabbit already left a non-blocking review on this SHA. + // Skip only when CodeRabbit already approved this SHA. if ( latestCodeRabbitReview?.commit_id === headSha - && latestCodeRabbitReview.state !== 'CHANGES_REQUESTED' + && latestCodeRabbitReview.state === 'APPROVED' ) { - console.log(`CodeRabbit already reviewed PR #${pr.number} at ${headSha}`) + console.log(`CodeRabbit already approved PR #${pr.number} at ${headSha}`) continue } @@ -69,11 +69,11 @@ jobs: comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) ) - // Skip only when a prior trigger already produced a non-blocking review on headSha. + // Skip only when a prior trigger already produced an approval on headSha. if ( alreadyTriggered && latestCodeRabbitReview?.commit_id === headSha - && latestCodeRabbitReview.state !== 'CHANGES_REQUESTED' + && latestCodeRabbitReview.state === 'APPROVED' ) { console.log(`CodeRabbit trigger already completed for PR #${pr.number} at ${headSha}`) continue From 67dad7cb9c681843e9dd829d84b35a1e5c000d69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 17:14:29 +0000 Subject: [PATCH 30/30] ci: retrigger CodeRabbit review after rate limit window Co-authored-by: Martin DONADIEU --- .github/workflows/coderabbit-bot-trigger.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coderabbit-bot-trigger.yml b/.github/workflows/coderabbit-bot-trigger.yml index afc0b1e52f..ab5057c774 100644 --- a/.github/workflows/coderabbit-bot-trigger.yml +++ b/.github/workflows/coderabbit-bot-trigger.yml @@ -50,7 +50,7 @@ jobs: .filter(review => review.user?.login === 'coderabbitai[bot]') .pop() - // Skip only when CodeRabbit already approved this SHA. + // Skip only when CodeRabbit already approved this SHA (COMMENTED does not clear CHANGES_REQUESTED). if ( latestCodeRabbitReview?.commit_id === headSha && latestCodeRabbitReview.state === 'APPROVED'