From deeae10b220f34f2f0f36cb96bb02f48985b8a1d Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 1 Aug 2026 14:31:31 +0200 Subject: [PATCH 1/4] fix(cli): validate app-scoped plans with app context --- cli/package.json | 5 +- cli/src/bundle/unlink.ts | 2 +- cli/src/bundle/upload.ts | 2 +- cli/src/channel/set.ts | 2 +- cli/src/utils.ts | 24 +++++---- cli/test/test-plan-validation.mjs | 87 +++++++++++++++++++++++++++++++ 6 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 cli/test/test-plan-validation.mjs diff --git a/cli/package.json b/cli/package.json index ff461751fb..8b8c8cde83 100644 --- a/cli/package.json +++ b/cli/package.json @@ -160,7 +160,7 @@ "test:ios-marketing-version": "bun test/test-ios-marketing-version.mjs", "test:platform-flow-contract": "bun test/test-platform-flow-contract.mjs", "test:tail-engine-shared": "bun test/test-tail-engine-shared.mjs", - "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:init-replay && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:organization-set-api-host && bun run test:trial-warning", + "test": "bun run build && bun run test:helper-dce && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:native-dependencies && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:preview-qr && bun run test:app-set-options && bun run test:mcp-analytics && bun run test:mcp-instructions && bun run test:mcp-live-update-onboarding && bun run test:mcp-stdout-guard && bun run test:mcp-platform-select && bun run test:mcp-explain-scopes && bun run test:mcp-oauth-reopen && bun run test:mcp-broker-oauth && bun run test:mcp-broker-session && bun run test:mcp-credentials-manage && bun run test:mcp-resume-prompt && bun run test:mcp-build-job && bun run test:mcp-build-tools && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-monorepo-targeting && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:init-replay && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:mcp-no-key-handshake && bun run test:auth-session && bun run test:version-detection && bun run test:platform-paths && bun run test:project-type-detection && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:asc-key-protocol && bun run test:apple-api-import-helpers && bun run test:apple-api-verify-key && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:cicd-failure-help && bun run test:ai-sse-parser && bun run test:ai-render-markdown && bun run test:ai-stream-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:ai-analyze-stream && bun run test:support-mailto && bun run test:support-redact && bun run test:support-internal-log && bun run test:support-help-menu && bun run test:support-contact && bun run test:support-upload-prompt && bun run test:support-bundle-files && bun run test:self-update && bun run test:update-prompt && bun run test:apple-api-cert-create && bun run test:android-tail-engine && bun run test:android-tail-render && bun run test:android-tail-routing && bun run test:dev-gate-stripped && bun run test:frame-fit-ios-shared && bun run test:ios-confirm-app-id && bun run test:ios-create-new && bun run test:ios-e2e && bun run test:ios-flow-contract && bun run test:ios-import-discovery && bun run test:ios-import-export && bun run test:ios-import-pickers && bun run test:ios-import-recovery && bun run test:ios-recovery && bun run test:ios-resume && bun run test:ios-tail-handoff && bun run test:ios-tui-render && bun run test:p8-error && bun run test:ios-tui-routing && bun run test:ios-updater-sync-validation && bun run test:ios-verify-app && bun run test:ios-marketing-version && bun run test:platform-flow-contract && bun run test:tail-engine-shared && bun run test:prescan && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename && bun run test:appflow-auth && bun run test:appflow-api-map && bun run test:appflow-validate && bun run test:appflow-flow && bun run test:appflow-gapfill && bun run test:appflow-engine && bun run test:appflow-tail && bun run test:appflow-fetch && bun run test:appflow-sa-decode && bun run test:app-permission-helper && bun run test:organization-set-api-host && bun run test:trial-warning && bun run test:plan-validation", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", @@ -203,7 +203,8 @@ "test:appflow-sa-decode": "bun test/test-appflow-sa-decode.mjs", "test:app-permission-helper": "bun test/test-app-permission-helper.mjs", "test:organization-set-api-host": "bun test/test-organization-set-api-host.mjs", - "test:trial-warning": "bun test/test-trial-warning.mjs" + "test:trial-warning": "bun test/test-trial-warning.mjs", + "test:plan-validation": "bun test/test-plan-validation.mjs" }, "dependencies": { "@inkjs/ui": "^2.0.0", diff --git a/cli/src/bundle/unlink.ts b/cli/src/bundle/unlink.ts index ca1b26f24d..80161cfdf2 100644 --- a/cli/src/bundle/unlink.ts +++ b/cli/src/bundle/unlink.ts @@ -87,7 +87,7 @@ export async function unlinkDeviceInternal( true, ) - await checkPlanValid(supabase, orgId, enrichedOptions.apikey, resolvedAppId) + await checkPlanValid(supabase, orgId, resolvedAppId) const versionData = await getVersionData(supabase, resolvedAppId, bundle, { silent }) await checkVersionNotUsedInChannel(supabase, resolvedAppId, versionData, { diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index e3c71a9b76..96cd5e65c4 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -1221,7 +1221,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl if (options.verbose) log.info(`[Verbose] Remote CLI messages checked`) - await checkPlanValidUpload(supabase, orgId, apikey, appid, true) + await checkPlanValidUpload(supabase, orgId, appid, true) if (options.verbose) log.info(`[Verbose] Plan validation passed`) if (options.verbose) diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts index 2991122b59..f47320ad94 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -239,7 +239,7 @@ export async function setChannelInternal(channel: string, appId: string, options throw new Error(`Cannot get organization id for channel ${channel}`) } - await checkPlanValid(supabase, orgId, options.apikey, appId) + await checkPlanValid(supabase, orgId, appId) const channelPayload: Database['public']['Tables']['channels']['Insert'] = { created_by: userId, diff --git a/cli/src/utils.ts b/cli/src/utils.ts index a0a0a9ec1f..39ff93dc01 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -843,12 +843,19 @@ export async function isAllowedActionOrg(supabase: SupabaseClient, org return !!data } -export async function isAllowedActionAppIdApiKey(supabase: SupabaseClient, appId: string, apikey: string): Promise { - const { data } = await supabase - .rpc('is_allowed_action', { apikey, appid: appId }) - .single() +export async function isAllowedActionForApp(supabase: SupabaseClient, orgId: string, appId: string): Promise { + // Check every metered action to preserve the general "good plan" gate while + // giving app-scoped API keys the app context required by RBAC. + const args = { + orgid: orgId, + appid: appId, + actions: ['mau', 'storage', 'bandwidth', 'build_time'], + } as never + const { data, error } = await supabase.rpc('is_allowed_action_org_action', args) + if (error) + throw new Error(`Cannot validate plan: ${formatError(error)}`) - return !!data + return data === true } export async function checkRemoteCliMessages(supabase: SupabaseClient, orgId: string, cliVersion: string) { @@ -882,11 +889,10 @@ export async function checkRemoteCliMessages(supabase: SupabaseClient, } } -export async function checkPlanValid(supabase: SupabaseClient, orgId: string, apikey: string, appId?: string, warning = true) { +export async function checkPlanValid(supabase: SupabaseClient, orgId: string, appId?: string, warning = true) { const config = await getRemoteConfig() - // isAllowedActionAppIdApiKey was updated in the orgs_v3 migration to work with the new system - const validPlan = await (appId ? isAllowedActionAppIdApiKey(supabase, appId, apikey) : isAllowedActionOrg(supabase, orgId)) + const validPlan = await (appId ? isAllowedActionForApp(supabase, orgId, appId) : isAllowedActionOrg(supabase, orgId)) if (!validPlan) { log.error(`You need to upgrade your plan to continue to use capgo.\n Upgrade here: ${config.hostWeb}/settings/organization/plans\n`) wait(100) @@ -906,7 +912,7 @@ export async function checkPlanValid(supabase: SupabaseClient, orgId: log.warn(`WARNING !!\nTrial expires in ${trialDays} days, upgrade here: ${config.hostWeb}/settings/organization/plans\n`) } -export async function checkPlanValidUpload(supabase: SupabaseClient, orgId: string, apikey: string, appId?: string, warning = true) { +export async function checkPlanValidUpload(supabase: SupabaseClient, orgId: string, appId?: string, warning = true) { const config = await getRemoteConfig() // Pass appid so RBAC evaluates the app scope. Without it, diff --git a/cli/test/test-plan-validation.mjs b/cli/test/test-plan-validation.mjs new file mode 100644 index 0000000000..d88da346d1 --- /dev/null +++ b/cli/test/test-plan-validation.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import process from 'node:process' +import * as utils from '../src/utils.ts' + +console.log('๐Ÿงช Testing app-aware plan validation...\n') + +let testsPassed = 0 +let testsFailed = 0 + +async function test(name, fn) { + try { + console.log(`\n๐Ÿ” ${name}`) + await fn() + console.log(`โœ… PASSED: ${name}`) + testsPassed++ + } + catch (error) { + console.error(`โŒ FAILED: ${name}`) + console.error(` Error: ${error.message}`) + testsFailed++ + } +} + +function assert(condition, message) { + if (!condition) + throw new Error(message) +} + +function assertEquals(actual, expected, message) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`) +} + +await test('checks an app-scoped key through the app-aware plan RPC', async () => { + assert(typeof utils.isAllowedActionForApp === 'function', 'Expected isAllowedActionForApp to be exported') + + const calls = [] + const supabase = { + rpc: async (name, args) => { + calls.push({ name, args }) + return { data: true, error: null } + }, + } + + const allowed = await utils.isAllowedActionForApp( + supabase, + 'org-id', + 'com.example.app', + ) + + assertEquals(allowed, true) + assertEquals(calls, [{ + name: 'is_allowed_action_org_action', + args: { + orgid: 'org-id', + appid: 'com.example.app', + actions: ['mau', 'storage', 'bandwidth', 'build_time'], + }, + }]) +}) + +await test('surfaces plan RPC errors instead of reporting an invalid plan', async () => { + assert(typeof utils.isAllowedActionForApp === 'function', 'Expected isAllowedActionForApp to be exported') + + const supabase = { + rpc: async () => ({ + data: null, + error: { message: 'permission lookup failed' }, + }), + } + + let thrown + try { + await utils.isAllowedActionForApp(supabase, 'org-id', 'com.example.app') + } + catch (error) { + thrown = error + } + + assert(thrown instanceof Error, 'Expected the RPC error to be thrown') + assert(thrown.message.includes('Cannot validate plan'), `Unexpected error: ${thrown.message}`) +}) + +console.log(`\n๐Ÿ“Š Results: ${testsPassed} passed, ${testsFailed} failed`) +if (testsFailed > 0) + process.exit(1) From 3782d6679665fb6d0529cb2c89ee043933f1bbb3 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 1 Aug 2026 14:46:22 +0200 Subject: [PATCH 2/4] docs(cli): describe app-aware plan validation --- cli/src/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 39ff93dc01..1e4f388b15 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -843,6 +843,7 @@ export async function isAllowedActionOrg(supabase: SupabaseClient, org return !!data } +/** Validate every metered plan action while preserving app-scoped RBAC context. */ export async function isAllowedActionForApp(supabase: SupabaseClient, orgId: string, appId: string): Promise { // Check every metered action to preserve the general "good plan" gate while // giving app-scoped API keys the app context required by RBAC. From 375e896b987e41de75d7103af55faf42f505eba7 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 1 Aug 2026 14:56:14 +0200 Subject: [PATCH 3/4] fix(cli): normalize plan validation errors --- cli/src/utils.ts | 46 +++++++++++++------------------ cli/test/test-plan-validation.mjs | 33 ++++++++++++++++++---- 2 files changed, 47 insertions(+), 32 deletions(-) diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 1e4f388b15..f20ba03aab 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -837,22 +837,25 @@ export function shouldWarnTrialExpiry(options: { } export async function isAllowedActionOrg(supabase: SupabaseClient, orgId: string): Promise { - const { data } = await supabase + const { data, error } = await supabase .rpc('is_allowed_action_org', { orgid: orgId }) .single() - return !!data + if (error) + throw new Error(`Cannot validate plan: ${formatError(error)}`) + + return data === true } -/** Validate every metered plan action while preserving app-scoped RBAC context. */ -export async function isAllowedActionForApp(supabase: SupabaseClient, orgId: string, appId: string): Promise { - // Check every metered action to preserve the general "good plan" gate while - // giving app-scoped API keys the app context required by RBAC. - const args = { - orgid: orgId, - appid: appId, - actions: ['mau', 'storage', 'bandwidth', 'build_time'], - } as never - const { data, error } = await supabase.rpc('is_allowed_action_org_action', args) +/** Validate metered plan actions while preserving app-scoped RBAC context when available. */ +export async function isAllowedPlanActions( + supabase: SupabaseClient, + orgId: string, + actions: Database['public']['Enums']['action_type'][], + appId?: string, +): Promise { + const { data, error } = appId + ? await supabase.rpc('is_allowed_action_org_action', { orgid: orgId, actions, appid: appId }) + : await supabase.rpc('is_allowed_action_org_action', { orgid: orgId, actions }) if (error) throw new Error(`Cannot validate plan: ${formatError(error)}`) @@ -893,7 +896,9 @@ export async function checkRemoteCliMessages(supabase: SupabaseClient, export async function checkPlanValid(supabase: SupabaseClient, orgId: string, appId?: string, warning = true) { const config = await getRemoteConfig() - const validPlan = await (appId ? isAllowedActionForApp(supabase, orgId, appId) : isAllowedActionOrg(supabase, orgId)) + const validPlan = await (appId + ? isAllowedPlanActions(supabase, orgId, ['mau', 'storage', 'bandwidth', 'build_time'], appId) + : isAllowedActionOrg(supabase, orgId)) if (!validPlan) { log.error(`You need to upgrade your plan to continue to use capgo.\n Upgrade here: ${config.hostWeb}/settings/organization/plans\n`) wait(100) @@ -916,20 +921,7 @@ export async function checkPlanValid(supabase: SupabaseClient, orgId: export async function checkPlanValidUpload(supabase: SupabaseClient, orgId: string, appId?: string, warning = true) { const config = await getRemoteConfig() - // Pass appid so RBAC evaluates the app scope. Without it, - // API keys with app-scoped bindings can be rejected and the org-scope - // plan check returns false even when the plan is healthy. PostgREST - // routes to the 3-arg overload at runtime; the `as never` cast bypasses - // a `supabase gen types` quirk that collapses overloads sharing the - // same name (the 3-arg signature exists in the DB but is not emitted - // by the generator). - const args = { orgid: orgId, actions: ['storage'], appid: appId } as never - const { data: validPlan, error: validPlanError } = await supabase.rpc('is_allowed_action_org_action', args) - if (validPlanError) { - const message = `Cannot validate upload plan: ${formatError(validPlanError)}` - log.error(message) - throw new Error(message) - } + const validPlan = await isAllowedPlanActions(supabase, orgId, ['storage'], appId) if (!validPlan) { log.error(`You need to upgrade your plan to continue to use capgo.\n Upgrade here: ${config.hostWeb}/settings/organization/plans\n`) wait(100) diff --git a/cli/test/test-plan-validation.mjs b/cli/test/test-plan-validation.mjs index d88da346d1..60b41fb415 100644 --- a/cli/test/test-plan-validation.mjs +++ b/cli/test/test-plan-validation.mjs @@ -33,7 +33,7 @@ function assertEquals(actual, expected, message) { } await test('checks an app-scoped key through the app-aware plan RPC', async () => { - assert(typeof utils.isAllowedActionForApp === 'function', 'Expected isAllowedActionForApp to be exported') + assert(typeof utils.isAllowedPlanActions === 'function', 'Expected isAllowedPlanActions to be exported') const calls = [] const supabase = { @@ -43,9 +43,10 @@ await test('checks an app-scoped key through the app-aware plan RPC', async () = }, } - const allowed = await utils.isAllowedActionForApp( + const allowed = await utils.isAllowedPlanActions( supabase, 'org-id', + ['mau', 'storage', 'bandwidth', 'build_time'], 'com.example.app', ) @@ -54,14 +55,14 @@ await test('checks an app-scoped key through the app-aware plan RPC', async () = name: 'is_allowed_action_org_action', args: { orgid: 'org-id', - appid: 'com.example.app', actions: ['mau', 'storage', 'bandwidth', 'build_time'], + appid: 'com.example.app', }, }]) }) await test('surfaces plan RPC errors instead of reporting an invalid plan', async () => { - assert(typeof utils.isAllowedActionForApp === 'function', 'Expected isAllowedActionForApp to be exported') + assert(typeof utils.isAllowedPlanActions === 'function', 'Expected isAllowedPlanActions to be exported') const supabase = { rpc: async () => ({ @@ -72,7 +73,7 @@ await test('surfaces plan RPC errors instead of reporting an invalid plan', asyn let thrown try { - await utils.isAllowedActionForApp(supabase, 'org-id', 'com.example.app') + await utils.isAllowedPlanActions(supabase, 'org-id', ['storage'], 'com.example.app') } catch (error) { thrown = error @@ -82,6 +83,28 @@ await test('surfaces plan RPC errors instead of reporting an invalid plan', asyn assert(thrown.message.includes('Cannot validate plan'), `Unexpected error: ${thrown.message}`) }) +await test('surfaces organization plan RPC errors instead of reporting an invalid plan', async () => { + const supabase = { + rpc: () => ({ + single: async () => ({ + data: null, + error: { message: 'organization lookup failed' }, + }), + }), + } + + let thrown + try { + await utils.isAllowedActionOrg(supabase, 'org-id') + } + catch (error) { + thrown = error + } + + assert(thrown instanceof Error, 'Expected the organization RPC error to be thrown') + assert(thrown.message.includes('Cannot validate plan'), `Unexpected error: ${thrown.message}`) +}) + console.log(`\n๐Ÿ“Š Results: ${testsPassed} passed, ${testsFailed} failed`) if (testsFailed > 0) process.exit(1) From 4d2ca6fb1d9129b47dc3db5bd29022f630eec173 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 1 Aug 2026 15:02:15 +0200 Subject: [PATCH 4/4] fix(cli): fall back for legacy plan RPCs --- cli/src/utils.ts | 8 +++++- cli/test/test-plan-validation.mjs | 44 +++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/cli/src/utils.ts b/cli/src/utils.ts index f20ba03aab..b0da15a3ec 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -856,8 +856,14 @@ export async function isAllowedPlanActions( const { data, error } = appId ? await supabase.rpc('is_allowed_action_org_action', { orgid: orgId, actions, appid: appId }) : await supabase.rpc('is_allowed_action_org_action', { orgid: orgId, actions }) - if (error) + if (error) { + // Older servers may not expose the app-aware overload in PostgREST's + // schema cache. Preserve their org-scoped behavior without hiding any + // permission, transport, or database errors from supported servers. + if (appId && error.code === 'PGRST202') + return isAllowedActionOrg(supabase, orgId) throw new Error(`Cannot validate plan: ${formatError(error)}`) + } return data === true } diff --git a/cli/test/test-plan-validation.mjs b/cli/test/test-plan-validation.mjs index 60b41fb415..48e01b7013 100644 --- a/cli/test/test-plan-validation.mjs +++ b/cli/test/test-plan-validation.mjs @@ -83,6 +83,50 @@ await test('surfaces plan RPC errors instead of reporting an invalid plan', asyn assert(thrown.message.includes('Cannot validate plan'), `Unexpected error: ${thrown.message}`) }) +await test('falls back to organization validation when the app-aware RPC is unavailable', async () => { + const calls = [] + const supabase = { + rpc: (name, args) => { + calls.push({ name, args }) + if (name === 'is_allowed_action_org_action') { + return Promise.resolve({ + data: null, + error: { + code: 'PGRST202', + message: 'Could not find the function in the schema cache', + }, + }) + } + return { + single: async () => ({ data: true, error: null }), + } + }, + } + + const allowed = await utils.isAllowedPlanActions( + supabase, + 'org-id', + ['storage'], + 'com.example.app', + ) + + assertEquals(allowed, true) + assertEquals(calls, [ + { + name: 'is_allowed_action_org_action', + args: { + orgid: 'org-id', + actions: ['storage'], + appid: 'com.example.app', + }, + }, + { + name: 'is_allowed_action_org', + args: { orgid: 'org-id' }, + }, + ]) +}) + await test('surfaces organization plan RPC errors instead of reporting an invalid plan', async () => { const supabase = { rpc: () => ({