Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion cli/src/bundle/unlink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion cli/src/channel/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
WcaleNieWolny marked this conversation as resolved.

const channelPayload: Database['public']['Tables']['channels']['Insert'] = {
created_by: userId,
Expand Down
55 changes: 30 additions & 25 deletions cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,18 +837,35 @@ export function shouldWarnTrialExpiry(options: {
}

export async function isAllowedActionOrg(supabase: SupabaseClient<Database>, orgId: string): Promise<boolean> {
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
}

export async function isAllowedActionAppIdApiKey(supabase: SupabaseClient<Database>, appId: string, apikey: string): Promise<boolean> {
const { data } = await supabase
.rpc('is_allowed_action', { apikey, appid: appId })
.single()
/** Validate metered plan actions while preserving app-scoped RBAC context when available. */
export async function isAllowedPlanActions(
supabase: SupabaseClient<Database>,
orgId: string,
actions: Database['public']['Enums']['action_type'][],
appId?: string,
): Promise<boolean> {
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 })
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
return data === true
}

export async function checkRemoteCliMessages(supabase: SupabaseClient<Database>, orgId: string, cliVersion: string) {
Expand Down Expand Up @@ -882,11 +899,12 @@ export async function checkRemoteCliMessages(supabase: SupabaseClient<Database>,
}
}

export async function checkPlanValid(supabase: SupabaseClient<Database>, orgId: string, apikey: string, appId?: string, warning = true) {
export async function checkPlanValid(supabase: SupabaseClient<Database>, 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
? 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)
Expand All @@ -906,23 +924,10 @@ export async function checkPlanValid(supabase: SupabaseClient<Database>, orgId:
log.warn(`WARNING !!\nTrial expires in ${trialDays} days, upgrade here: ${config.hostWeb}/settings/organization/plans\n`)
}

export async function checkPlanValidUpload(supabase: SupabaseClient<Database>, orgId: string, apikey: string, appId?: string, warning = true) {
export async function checkPlanValidUpload(supabase: SupabaseClient<Database>, 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)
Expand Down
154 changes: 154 additions & 0 deletions cli/test/test-plan-validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
#!/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.isAllowedPlanActions === 'function', 'Expected isAllowedPlanActions to be exported')

const calls = []
const supabase = {
rpc: async (name, args) => {
calls.push({ name, args })
return { data: true, error: null }
},
}

const allowed = await utils.isAllowedPlanActions(
supabase,
'org-id',
['mau', 'storage', 'bandwidth', 'build_time'],
'com.example.app',
)

assertEquals(allowed, true)
assertEquals(calls, [{
name: 'is_allowed_action_org_action',
args: {
orgid: 'org-id',
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.isAllowedPlanActions === 'function', 'Expected isAllowedPlanActions to be exported')

const supabase = {
rpc: async () => ({
data: null,
error: { message: 'permission lookup failed' },
}),
}

let thrown
try {
await utils.isAllowedPlanActions(supabase, 'org-id', ['storage'], '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}`)
})

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: () => ({
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)
Loading