Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
b6ede5c
fix(rbac): add app preview API key role
riderx Jul 15, 2026
b8b68e6
fix(rbac): order app preview migration
riderx Jul 15, 2026
c4013d2
fix(rbac): seed app preview permissions
riderx Jul 15, 2026
0fbea38
test: capture app preview key dialog
riderx Jul 15, 2026
b63df61
test: fix app preview visual capture
riderx Jul 15, 2026
4170b15
fix(ci): restore scheduler baseline
riderx Jul 15, 2026
1b4958c
docs: add app preview key screenshot
riderx Jul 15, 2026
5835a6b
fix(rbac): restore helper RPC grants
riderx Jul 15, 2026
de80b6a
fix(rbac): harden service helper grants
riderx Jul 15, 2026
7bd40c6
fix(rbac): address api key review findings
riderx Jul 15, 2026
1a13ec6
fix(ci): keep scheduler repair forward-only
riderx Jul 15, 2026
d15f7c9
fix(db): consolidate app preview migration
riderx Jul 15, 2026
1c52d9a
fix(rbac): isolate preview API keys by created channel
riderx Jul 16, 2026
906efdb
fix(rbac): preserve preview channel trigger paths
riderx Jul 16, 2026
ba53026
fix(rbac): address preview key review findings
riderx Jul 16, 2026
0252fa0
ci: upgrade supabase migration runner
riderx Jul 16, 2026
b219f03
fix(rbac): use compatible preview binding index
riderx Jul 16, 2026
bd476f9
fix(rbac): harden preview lifecycle
riderx Jul 16, 2026
7eafd84
fix(rbac): make preview channel promotion atomic
riderx Jul 16, 2026
a706680
fix(rbac): make preview channel upload atomic
riderx Jul 16, 2026
8221520
fix(rbac): prevent preview lifecycle lock cycles
riderx Jul 16, 2026
24e8faf
fix(cli): complete preview channel creation
riderx Jul 16, 2026
2044b9e
fix(cli): handle missing preview metadata
riderx Jul 16, 2026
334d83b
fix(ci): bound visual diff capture
riderx Jul 16, 2026
59c70bc
fix(cli): preserve legacy channel output
riderx Jul 16, 2026
8c79a02
fix(cli): retain legacy channel id
riderx Jul 16, 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
Binary file added .github/pr-screenshots/app-preview-api-key.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion .github/workflows/visual-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ jobs:
uses: actions/upload-artifact@v6
with:
name: visual-diff-report-${{ github.event.pull_request.head.sha }}
path: .context/visual-diff/report/
path: |
.context/visual-diff/report/
.context/visual-diff/logs/
if-no-files-found: ignore
retention-days: 14

Expand Down
84 changes: 63 additions & 21 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -789,15 +789,20 @@ async function deleteLinkedBundleOnUpload(supabase: SupabaseType, version: Linke
log.info(`Linked bundle ${version.name} deleted`)
}

async function findUploadTargetChannel(supabase: SupabaseType, appid: string, channel: string): Promise<UploadTargetChannel | null> {
async function findUploadTargetChannel(
supabase: SupabaseType,
appid: string,
channel: string,
failOnError = true,
): Promise<UploadTargetChannel | null> {
const { data, error } = await supabase
.from('channels')
.select('id, public, version, rollout_version')
.eq('app_id', appid)
.eq('name', channel)
.maybeSingle()

if (error)
if (error && failOnError)
uploadFail(`Cannot check channel ${channel}: ${formatError(error)}`)

return data
Expand Down Expand Up @@ -845,10 +850,6 @@ async function preflightRequiredChannelAssignments(
if (!canCreateChannel)
uploadFail('Cannot create target channel because this API key lacks app.create_channel')

const canPromoteCreatedChannel = await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid })
if (!canPromoteCreatedChannel)
uploadFail('Cannot create target channel with a bundle because this API key lacks channel.promote_bundle')

uploadTargetChannels.set(channel, null)
}

Expand Down Expand Up @@ -914,12 +915,6 @@ async function setVersionInChannel(
requireChannelAssignment = false,
selfAssign?: boolean,
): Promise<boolean> {
const { data: versionId } = await supabase
.rpc('get_app_versions', { apikey, name_version: bundle, appid })
.single()

if (!versionId)
uploadFail('Cannot get version id, cannot set channel')

const canPromoteTargetChannel = targetChannel !== null
&& await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id })
Expand All @@ -934,18 +929,19 @@ async function setVersionInChannel(
return false
}

if (targetChannel && canPromoteTargetChannel && selfAssign) {
const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id })
if (!canUpdateChannelSettings) {
log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings')
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)
if (targetChannel && canPromoteTargetChannel) {
const versionId = await getVersionIdForChannelUpdate(supabase, apikey, appid, bundle)
if (selfAssign) {
const canUpdateChannelSettings = await hasCliPermission(supabase, apikey, 'channel.update_settings', { appId: appid, channelId: targetChannel.id })
if (!canUpdateChannelSettings) {
log.warn('Cannot enable device self-assign because this API key lacks channel.update_settings')
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)
}
}
}

if (targetChannel && canPromoteTargetChannel && !selfAssign)
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)
if (!selfAssign)
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)

if ((targetChannel && canPromoteTargetChannel) || canCreateChannel) {
const { error: dbError3, data } = await updateOrCreateChannel(supabase, {
name: channel,
app_id: appid,
Expand All @@ -967,6 +963,52 @@ async function setVersionInChannel(
return true
}

// The channel endpoint creates the preview channel, receives its scoped
// lifecycle binding, and promotes this bundle in one transaction.
if (canCreateChannel) {
const { error, data } = await supabase.functions.invoke('channel', {
method: 'POST',
body: JSON.stringify({
app_id: appid,
channel,
version: bundle,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
...(selfAssign ? { allow_device_self_set: true } : {}),
}),
})
if (error) {
uploadFail(`Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`)
}

const createdChannel = data as { id?: unknown, public?: unknown } | null
let createdChannelId = Number(createdChannel?.id)
let createdChannelPublic = createdChannel?.public === true
if (!Number.isSafeInteger(createdChannelId) || typeof createdChannel?.public !== 'boolean') {
// Older channel endpoints do not return metadata, so only their fallback reads the new channel.
const fallbackChannel = await findUploadTargetChannel(supabase, appid, channel, false)
const fallbackChannelId = Number(fallbackChannel?.id)
if (!Number.isSafeInteger(fallbackChannelId)) {
if (!Number.isSafeInteger(createdChannelId)) {
log.info('Your update is now available 🎉')
return true
}
}
else {
createdChannelId = fallbackChannelId
createdChannelPublic = fallbackChannel?.public === true
}
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

const bundleUrl = `${localConfig.hostWeb}/app/${appid}/channel/${createdChannelId}`
if (createdChannelPublic)
log.info('Your update is now available in your public channel 🎉')
else
log.info(`Link device to this bundle to try it: ${bundleUrl}`)

if (displayBundleUrl)
log.info(`Bundle url: ${bundleUrl}`)
return true
}

const message = 'Cannot create target channel because this API key lacks app.create_channel'
if (requireChannelAssignment)
uploadFail(message)
Expand Down
60 changes: 41 additions & 19 deletions cli/src/channel/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
formatError,
getAppId,
getConfig,
hasCliPermission,
sendEvent,
} from '../utils'

Expand Down Expand Up @@ -48,10 +49,10 @@ export async function deleteChannelInternal(channelId: string, appId: string, op

throw new Error(`Channel ${channelId} not found`)
}

await checkAppExistsAndHasPermissionOrgErr(supabase, options.apikey, appId, 'channel.delete', silent, true, channel.id)
if (options.deleteBundle)
await checkAppExistsAndHasPermissionOrgErr(supabase, options.apikey, appId, 'bundle.delete', silent, true)
const canDeleteBundle = options.deleteBundle
? await hasCliPermission(supabase, options.apikey, 'bundle.delete', { appId })
: false

const orgId = channel.owner_org
if (!orgId) {
Expand All @@ -60,25 +61,46 @@ export async function deleteChannelInternal(channelId: string, appId: string, op
throw new Error(`Channel ${channelId} has no owner organization`)
}

if (options.deleteBundle && !silent)
log.info(`Deleting bundle ${appId}#${channelId} from Capgo`)

if (options.deleteBundle) {
const bundle = await findBundleIdByChannelName(supabase, appId, channelId)
if (bundle?.name && !silent)
log.info(`Deleting bundle ${bundle.name} from Capgo`)
if (bundle?.name)
await deleteAppVersion(supabase, appId, bundle.name)
if (options.deleteBundle && !canDeleteBundle) {
if (!silent)
log.info(`Deleting preview channel ${appId}#${channelId} and its bundle from Capgo`)

const { error } = await supabase.functions.invoke('channel', {
method: 'DELETE',
body: JSON.stringify({
app_id: appId,
channel: channelId,
delete_bundle: true,
}),
})
if (error) {
const message = `Cannot delete preview channel and bundle: ${formatError(error)}`
if (!silent)
log.error(message)
throw new Error(message)
}
}
else {
if (options.deleteBundle && !silent)
log.info(`Deleting bundle ${appId}#${channelId} from Capgo`)

if (options.deleteBundle) {
const bundle = await findBundleIdByChannelName(supabase, appId, channelId)
if (bundle?.name && !silent)
log.info(`Deleting bundle ${bundle.name} from Capgo`)
if (bundle?.name)
await deleteAppVersion(supabase, appId, bundle.name)
}

if (!silent)
log.info(`Deleting channel ${appId}#${channelId} from Capgo`)

const deleteStatus = await delChannel(supabase, channelId, appId)
if (deleteStatus.error) {
if (!silent)
log.error(`Cannot delete Channel 🙀 ${formatError(deleteStatus.error)}`)
throw new Error(`Cannot delete channel: ${formatError(deleteStatus.error)}`)
log.info(`Deleting channel ${appId}#${channelId} from Capgo`)

const deleteStatus = await delChannel(supabase, channelId, appId)
if (deleteStatus.error) {
if (!silent)
log.error(`Cannot delete Channel 🙀 ${formatError(deleteStatus.error)}`)
throw new Error(`Cannot delete channel: ${formatError(deleteStatus.error)}`)
}
}

await sendEvent(options.apikey, {
Expand Down
19 changes: 19 additions & 0 deletions cli/src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export type Database = {
cli_version: string | null
comment: string | null
created_at: string | null
created_by_apikey_rbac_id: string | null
deleted: boolean
deleted_at: string | null
external_url: string | null
Expand All @@ -193,6 +194,7 @@ export type Database = {
cli_version?: string | null
comment?: string | null
created_at?: string | null
created_by_apikey_rbac_id?: string | null
deleted?: boolean
deleted_at?: string | null
external_url?: string | null
Expand All @@ -219,6 +221,7 @@ export type Database = {
cli_version?: string | null
comment?: string | null
created_at?: string | null
created_by_apikey_rbac_id?: string | null
deleted?: boolean
deleted_at?: string | null
external_url?: string | null
Expand Down Expand Up @@ -2323,6 +2326,7 @@ export type Database = {
id: string
is_direct: boolean
org_id: string | null
parent_binding_id: string | null
principal_id: string
principal_type: string
reason: string | null
Expand All @@ -2339,6 +2343,7 @@ export type Database = {
id?: string
is_direct?: boolean
org_id?: string | null
parent_binding_id?: string | null
principal_id: string
principal_type: string
reason?: string | null
Expand All @@ -2355,6 +2360,7 @@ export type Database = {
id?: string
is_direct?: boolean
org_id?: string | null
parent_binding_id?: string | null
principal_id?: string
principal_type?: string
reason?: string | null
Expand Down Expand Up @@ -2390,6 +2396,13 @@ export type Database = {
referencedRelation: "orgs"
referencedColumns: ["id"]
},
{
foreignKeyName: "role_bindings_parent_binding_id_fkey"
columns: ["parent_binding_id"]
isOneToOne: false
referencedRelation: "role_bindings"
referencedColumns: ["id"]
},
{
foreignKeyName: "role_bindings_role_id_fkey"
columns: ["role_id"]
Expand Down Expand Up @@ -3087,10 +3100,12 @@ export type Database = {
country: string | null
created_at: string | null
created_via_invite: boolean
discord_username: string | null
email: string
email_preferences: Json
enable_notifications: boolean
first_name: string | null
github_username: string | null
id: string
image_url: string | null
last_name: string | null
Expand All @@ -3102,10 +3117,12 @@ export type Database = {
country?: string | null
created_at?: string | null
created_via_invite?: boolean
discord_username?: string | null
email: string
email_preferences?: Json
enable_notifications?: boolean
first_name?: string | null
github_username?: string | null
id: string
image_url?: string | null
last_name?: string | null
Expand All @@ -3117,10 +3134,12 @@ export type Database = {
country?: string | null
created_at?: string | null
created_via_invite?: boolean
discord_username?: string | null
email?: string
email_preferences?: Json
enable_notifications?: boolean
first_name?: string | null
github_username?: string | null
id?: string
image_url?: string | null
last_name?: string | null
Expand Down
7 changes: 7 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,11 @@
"api-key-not-found": "API key not found",
"api-key-policy": "API Key Policy",
"api-key-policy-description": "Configure policies for API keys used with this organization.",
"api-key-selected-apps-only": "Limit this key to selected apps",
"api-key-selected-apps-only-app-access": "Choose one role for each app. Each app binding is limited to that app and its owning organization.",
"api-key-selected-apps-only-description": "Each selected app is bound to its owning organization. This key can access only the selected apps and does not receive organization-wide permissions.",
"api-key-selected-apps-only-org-filter": "Organizations for selected apps",
"api-key-selected-apps-only-org-filter-description": "Each selected app stays bound to its owning organization. Choosing an organization does not grant organization-wide permissions.",
"api-key-updated": "API key updated",
"api-key-policy-updated": "API key policy updated successfully",
"api-keys": "API Keys",
Expand Down Expand Up @@ -2029,6 +2034,7 @@
"role": "Role",
"role-per-org": "Role per organization",
"role-app-admin": "App Admin",
"role-app-preview": "App Preview",
"role-app-developer": "App Developer",
"role-app-notifications": "App Notifications",
"role-app-reader": "App Reader",
Expand Down Expand Up @@ -2105,6 +2111,7 @@
"select-user-perms": "Select user's permissions",
"select-user-perms-expanded": "Select which permission should the invited user have",
"select-user-role": "Select a role",
"select-at-least-one-app": "Select at least one app",
"select-at-least-one-role": "Select at least one org role or app role",
"select-role-for-each-app": "Select a role for each app",
"select-user-role-expanded": "Choose the RBAC role to assign. Legacy roles remain visible during migration.",
Expand Down
Loading
Loading