Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
79502d9
fix(cli): unblock app add when pending onboarding app exists
cursoragent Jul 23, 2026
91094cf
fix(test): stabilize app-create CI after storage RLS fix
cursoragent Jul 23, 2026
dac18ab
fix(test): correct images insert policy pgTAP assertion
cursoragent Jul 23, 2026
a91956d
fix(test): match quoted apps.app_id in images policy pgTAP
cursoragent Jul 23, 2026
f66d5f0
fix(test): diagnose images insert storage policy pgTAP mismatch
cursoragent Jul 23, 2026
ac4f09b
fix(db): qualify storage.objects.name in images RLS policies
cursoragent Jul 23, 2026
13995ea
chore(ci): retrigger backend tests after port bind flake
cursoragent Jul 23, 2026
989c780
fix(cli,db): address review on onboarding icon create path
cursoragent Jul 23, 2026
c5a3a8c
fix(cli,api): authenticate app create via Capgo API key host path
cursoragent Jul 23, 2026
649da26
chore(ci): retrigger tests after cancelled run
cursoragent Jul 23, 2026
5badc38
fix(cli,api): honor self-host API host and serialize onboarding compl…
cursoragent Jul 23, 2026
ff31a0b
fix(test): restore compatible images storage policy pgTAP checks
cursoragent Jul 23, 2026
a3a42e0
refactor(cli): share Capgo public API host resolver across org and app
cursoragent Jul 23, 2026
41abc29
revert(api): drop anon-JWT fallback in middlewareAuth
cursoragent Jul 23, 2026
bbca8cd
merge: resolve main conflict in cleanup_queue migration
cursoragent Jul 23, 2026
9039517
Merge branch 'main' into cursor/fix-app-add-rls-5c96
riderx Jul 24, 2026
24c393c
fix(db): remove leftover merge conflict markers in cleanup_queue migr…
cursoragent Jul 24, 2026
6a8cf47
chore(ci): retrigger tests after conflict-marker fix
cursoragent Jul 24, 2026
1ac4b72
fix(ci): restore private/cli-mcp-tests submodule gitlink
cursoragent Jul 25, 2026
356a414
chore(ci): retrigger tests after flaky Cloudflare shard failure
cursoragent Jul 25, 2026
f26bdde
fix(db): limit org.create_app image RLS to icon path
cursoragent Jul 25, 2026
09b50bb
test(db): assert icon-only org.create_app image update policy
cursoragent Jul 25, 2026
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
17 changes: 9 additions & 8 deletions cli/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,20 @@ export async function completePendingOnboardingApp(
orgId: string,
appId: string,
): Promise<void> {
const { data, error } = await supabase
.from('apps')
.update({ need_onboarding: false })
.select('app_id')
.eq('owner_org', orgId)
.eq('app_id', appId)
.eq('need_onboarding', true)
// Prefer the authorized API path so keys with org.create_app can finish a
// pending web-onboarding app even when they lack app.update_settings.
const { data, error } = await supabase.functions.invoke(`app/${appId}`, {
method: 'PUT',
body: {
need_onboarding: false,
},
})

if (error) {
throw new Error(`Could not complete onboarding for app ${appId}: ${error.message}`)
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

if (!data?.length) {
if (!(data as { app_id?: string } | null)?.app_id) {
throw new Error(`Could not complete onboarding for app ${appId} in org ${orgId}: app was not found or is no longer pending onboarding`)
}
}
Expand Down
124 changes: 82 additions & 42 deletions cli/src/app/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Database } from '../types/supabase.types'
import type { Organization } from '../utils'
import { existsSync, readFileSync } from 'node:fs'
import { intro, log, outro } from '@clack/prompts'
import { FunctionsHttpError } from '@supabase/supabase-js'
import { getInvocationSource } from '../analytics/track'
import { checkAppExists, defaultAppIconPath, getAppIconStoragePath, newIconPath } from '../api/app'
import { checkAlerts } from '../api/update'
Expand Down Expand Up @@ -75,25 +76,73 @@ async function ensureAppDoesNotExist(

export type AppCreateSource = 'cli-direct' | 'onboarding' | 'mcp'

function isMissingAppBuildOnboardingSchemaError(error: { message?: string, details?: string, hint?: string, code?: string | null } | null) {
if (!error)
return false

const text = [error.message, error.details, error.hint, error.code]
.filter(Boolean)
.join(' ')
.toLowerCase()

return text.includes('created_from_onboarding')
|| text.includes('onboarding_completed_at')
}

export function resolveAppCreateSource(explicit?: AppCreateSource): AppCreateSource {
if (explicit)
return explicit
return getInvocationSource() === 'mcp' ? 'mcp' : 'cli-direct'
}

async function formatAppCreateError(error: unknown): Promise<string> {
if (error instanceof FunctionsHttpError) {
try {
const body = await error.context.json() as { error?: string, message?: string, status?: string }
const details = [body.error, body.message, body.status].filter(Boolean).join(' | ')
if (details)
return details
}
catch {
// Fall through to generic formatter.
}
}

const context = (error as { context?: { json?: () => Promise<unknown> } } | null)?.context
if (context?.json) {
try {
return JSON.stringify(await context.json())
}
catch {
// Fall through to generic formatter.
}
}

return formatError(error)
}

async function createAppViaApi(
supabase: SupabaseClient<Database>,
params: {
ownerOrg: string
appId: string
name: string
iconUrl: string
createdFromOnboarding: boolean
},
) {
const { data, error } = await supabase.functions.invoke('app', {
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
method: 'POST',
body: {
owner_org: params.ownerOrg,
app_id: params.appId,
name: params.name,
icon: params.iconUrl,
need_onboarding: false,
created_from_onboarding: params.createdFromOnboarding,
},
})

if (error) {
const message = await formatAppCreateError(error)
throw new Error(message)
}

const createdAppId = (data as { app_id?: string } | null)?.app_id
if (!createdAppId) {
throw new Error('App create API returned no app_id')
}

return data as { app_id: string, icon_url?: string, name?: string }
}

export async function addAppInternal(
initialAppId: string,
options: AppOptions,
Expand Down Expand Up @@ -165,6 +214,8 @@ export async function addAppInternal(
const iconPath = getAppIconStoragePath(organizationUid, appId)
let iconUrl = defaultAppIconPath

// Icon upload is best-effort. Storage RLS issues must not block app creation;
// the web onboarding path already continues without an icon on upload failure.
if (iconBuff && iconType) {
const { error } = await supabase.storage
.from('images')
Expand All @@ -175,42 +226,31 @@ export async function addAppInternal(

if (error) {
if (!silent)
log.error(`Could not add app ${formatError(error)}`)
throw new Error(`Could not add app ${formatError(error)}`)
log.warn(`Could not upload app icon (${formatError(error)}). Continuing with the default icon.`)
}
else {
iconUrl = iconPath
}

iconUrl = iconPath
}

const appCreateSource = resolveAppCreateSource(source)
const baseAppInsert = {
icon_url: iconUrl,
owner_org: organizationUid,
user_id: userId,
name,
app_id: appId,
}
const insertApp = (withOnboardingMetrics: boolean) => supabase
.from('apps')
.insert({
...baseAppInsert,
...(withOnboardingMetrics
? {
created_from_onboarding: true,
}
: {}),
})

const insertResult = await insertApp(appCreateSource === 'onboarding')
let dbError = insertResult.error
if (dbError && appCreateSource === 'onboarding' && isMissingAppBuildOnboardingSchemaError(dbError)) {
const fallbackInsertResult = await insertApp(false)
dbError = fallbackInsertResult.error
try {
// Use the same authorized API path as the web console. Direct PostgREST inserts
// hit apps/storage RLS and fail for common API-key + pending-onboarding setups.
await createAppViaApi(supabase, {
ownerOrg: organizationUid,
appId,
name,
iconUrl,
createdFromOnboarding: appCreateSource === 'onboarding',
})
}
if (dbError) {
catch (error) {
const message = await formatAppCreateError(error)
if (!silent)
log.error(`Could not add app ${formatError(dbError)}`)
throw new Error(`Could not add app ${formatError(dbError)}`)
log.error(`Could not add app ${message}`)
throw new Error(`Could not add app ${message}`)
}

await sendEvent(options.apikey!, {
Expand Down
4 changes: 3 additions & 1 deletion supabase/functions/_backend/public/app/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface CreateApp {
owner_org: string
icon?: string
need_onboarding?: boolean
created_from_onboarding?: boolean
existing_app?: boolean
ios_store_url?: string
android_store_url?: string
Expand Down Expand Up @@ -45,7 +46,8 @@ export async function post(c: Context<MiddlewareKeyVariables>, body: CreateApp):
retention: 2592000,
default_upload_channel: 'dev',
need_onboarding: body.need_onboarding ?? false,
created_from_onboarding: body.need_onboarding ?? false,
// Keep CLI init metrics independent from pending web-onboarding apps.
created_from_onboarding: body.created_from_onboarding ?? body.need_onboarding ?? false,
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
existing_app: body.existing_app ?? false,
ios_store_url: body.ios_store_url ?? null,
android_store_url: body.android_store_url ?? null,
Expand Down
91 changes: 76 additions & 15 deletions supabase/functions/_backend/public/app/put.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from '../../utils/hono.ts'
import type { Database } from '../../utils/supabase.types.ts'
import { trackBentoEvent } from '../../utils/bento.ts'
import { deleteAppStatus } from '../../utils/appStatus.ts'
import { trackBentoEvent } from '../../utils/bento.ts'
import { createIfNotExistStoreInfo } from '../../utils/cloudflare.ts'
import { lockOnboardingApp, unlockOnboardingApp } from '../../utils/demo.ts'
import { quickError, simpleError } from '../../utils/hono.ts'
import { cloudlog } from '../../utils/logging.ts'
import { closeClient, getPgClient } from '../../utils/pg.ts'
import { checkPermission } from '../../utils/rbac.ts'
import { createSignedImageUrl, normalizeImagePath } from '../../utils/storage.ts'
import { supabaseAdmin, supabaseApikey } from '../../utils/supabase.ts'
Expand All @@ -32,10 +33,6 @@ export async function put(c: Context<MiddlewareKeyVariables>, appId: string, bod
if (!isValidAppId(appId)) {
throw quickError(400, 'invalid_app_id', 'App ID must be a reverse domain string', { app_id: appId })
}
// Auth context is already set by middlewareKey
if (!(await checkPermission(c, 'app.update_settings', { appId }))) {
throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId })
}

if (body.retention && body.retention >= 63113904) {
throw quickError(400, 'retention_to_big', 'Retention cannot be bigger than 63113903 (2 years)', { retention: body.retention })
Expand All @@ -44,25 +41,87 @@ export async function put(c: Context<MiddlewareKeyVariables>, appId: string, bod
throw quickError(400, 'retention_to_small', 'Retention cannot be smaller than 0', { retention: body.retention })
}

const { data: previousApp, error: previousAppError } = await supabaseApikey(c, apikey.key)
const canUpdateSettings = await checkPermission(c, 'app.update_settings', { appId })

// Service-role load is only for the pending-onboarding completion path so keys
// with org.create_app can finish apps they cannot yet read via RLS.
const previousAppClient = canUpdateSettings || body.need_onboarding !== false
? supabaseApikey(c, apikey.key)
: supabaseAdmin(c)
const { data: previousApp, error: previousAppError } = await previousAppClient
.from('apps')
.select('need_onboarding, owner_org, name, app_id')
.eq('app_id', appId)
.single()

if (previousAppError || !previousApp) {
if (!canUpdateSettings) {
throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId })
}
throw simpleError('cannot_load_app', 'Cannot load app before update', { supabaseError: previousAppError })
}

const normalizedIcon = normalizeImagePath(body.icon)
const shouldSerializeOnboardingCompletion = previousApp.need_onboarding === true && body.need_onboarding === false
const canCompleteOnboarding = !canUpdateSettings
&& shouldSerializeOnboardingCompletion
&& await checkPermission(c, 'org.create_app', { orgId: previousApp.owner_org })

if (!canUpdateSettings && !canCompleteOnboarding) {
throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId })
}

// Completing pending onboarding with only org.create_app must not allow arbitrary
// settings changes. Restrict the writable fields in that case.
if (canCompleteOnboarding) {
const disallowedFields = [
body.name,
body.icon,
body.retention,
body.expose_metadata,
body.allow_device_custom_id,
body.existing_app,
body.block_provider_infra_requests,
body.ios_store_url,
body.android_store_url,
].some(value => value !== undefined)
if (disallowedFields) {
throw quickError(401, 'cannot_access_app', 'You can\'t access this app', { app_id: appId })
}
}

const normalizedIcon = normalizeImagePath(body.icon)
const onboardingLock = shouldSerializeOnboardingCompletion
? await lockOnboardingApp(c, appId)
: null

const { data, error: dbError } = await (async () => {
try {
return await supabaseApikey(c, apikey.key)
let data: Record<string, any> | undefined
let dbError: { message?: string } | null = null

try {
if (!canUpdateSettings && canCompleteOnboarding) {
// Bypass RLS for the narrow onboarding-completion path after explicit authz.
const pgClient = getPgClient(c)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
try {
const result = await pgClient.query(
`UPDATE public.apps
SET need_onboarding = false
WHERE app_id = $1
RETURNING *`,
[appId],
)
data = result.rows[0]
if (!data)
dbError = { message: 'App not found during onboarding completion' }
}
catch (error) {
dbError = { message: (error as Error)?.message }
}
finally {
await closeClient(c, pgClient)
}
}
else {
const updateResult = await supabaseApikey(c, apikey.key)
.from('apps')
.update({
name: body.name,
Expand All @@ -79,13 +138,15 @@ export async function put(c: Context<MiddlewareKeyVariables>, appId: string, bod
.eq('app_id', appId)
.select()
.single()
data = updateResult.data ?? undefined
dbError = updateResult.error
}
finally {
if (onboardingLock) {
await unlockOnboardingApp(c, onboardingLock, appId)
}
}
finally {
if (onboardingLock) {
await unlockOnboardingApp(c, onboardingLock, appId)
}
})()
}

if (dbError || !data) {
throw simpleError('cannot_update_app', 'Cannot update app', { supabaseError: dbError })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,13 @@ REVOKE ALL ON FUNCTION "public"."cleanup_queue_messages"() FROM PUBLIC;
GRANT ALL ON FUNCTION "public"."cleanup_queue_messages"() TO "service_role";

-- Drop obsolete meta row with no q_/a_ tables (documented as omitted in prod baseline).
DELETE FROM pgmq.meta
WHERE queue_name = 'replicate_data'
AND to_regclass('pgmq.q_replicate_data') IS NULL
AND to_regclass('pgmq.a_replicate_data') IS NULL;
-- Skip when pgmq.meta is absent (Tinbase/PGlite emulate pgmq without that table).
DO $$
BEGIN
IF to_regclass('pgmq.meta') IS NOT NULL THEN
DELETE FROM pgmq.meta
WHERE queue_name = 'replicate_data'
AND to_regclass('pgmq.q_replicate_data') IS NULL
AND to_regclass('pgmq.a_replicate_data') IS NULL;
END IF;
END $$;
Loading
Loading