Skip to content
Open
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
22 changes: 21 additions & 1 deletion .github/workflows/build_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,27 @@ jobs:
- name: Update functions
env:
SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_TOKEN }}
run: supabase functions deploy
# Capgo cloud publishes allowlisted functions only (pg_net + functions.invoke).
# Self-hosting keeps deploying all functions via `supabase functions deploy`.
run: |
FUNCTIONS=($(bun scripts/supabase-cloud-functions.ts list))
if [ "${#FUNCTIONS[@]}" -eq 0 ]; then
echo "Capgo cloud function allowlist is empty; refusing to deploy all functions" >&2
exit 1
fi
supabase functions deploy "${FUNCTIONS[@]}"
# Remove Capgo-cloud-only functions that are no longer allowlisted.
# Self-host installs are unaffected (they deploy every local function).
SKIPPED=($(bun scripts/supabase-cloud-functions.ts skip-list))
for fn in "${SKIPPED[@]}"; do
if ! out=$(supabase functions delete "$fn" 2>&1); then
if echo "$out" | grep -qiE 'not found|does not exist|404|Function not found'; then
continue
fi
echo "$out" >&2
exit 1
fi
done

read_replica_schema:
needs: changes
Expand Down
31 changes: 28 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,12 @@ We support both deployments for practical reasons:

In production, we route most traffic through Cloudflare Workers for cost and
scale, while Supabase remains the reference backend and the default for
self-hosted deployments. Private endpoints and trigger/CRON workloads still run
on Supabase in production.
self-hosted deployments. Capgo cloud console and current CLI calls go to the
Cloudflare API hosts (`VITE_API_HOST` / `hostApi`). Capgo cloud still publishes
a small Supabase function allowlist for Postgres `pg_net` (`triggers`) and for
legacy CLI compatibility via `/functions/v1` through 2026-10-28. Self-hosted
deployments keep using `/functions/v1`. Plugin hot paths and other unused Capgo
cloud endpoints are not published on Supabase.

## Project structure (self-hosting map)

Expand Down Expand Up @@ -448,7 +452,28 @@ Seed the secret for functions:
supabase secrets set --env-file supabase/functions/.env
```

Push the functions to the cloud:
### Deploy Capgo Cloud Supabase functions

Capgo Cloud (prod / preprod / alpha) only publishes allowlisted Supabase
functions from `scripts/supabase-cloud-functions.ts`:

- `triggers` forever (`pg_net` queue consumer)
- `bundle`, `channel`, `files`, `private` until **2026-10-28** (old Capgo CLI
still called these via `supabase.functions.invoke` on `sb.capgo.app`; new
CLI uses `api.capgo.app` / `files.capgo.app`)

Console Capgo-cloud traffic uses Cloudflare (`VITE_API_HOST`); self-host / local
consoles keep `supabase.functions.invoke`. Capgo cloud deploy also deletes
functions outside the allowlist (`skip-list` / `delete-args`).

```bash
bun run deploy:supabase:prod
# or: bunx supabase functions deploy $(bun scripts/supabase-cloud-functions.ts deploy-args) --project-ref <ref>
```

### Deploy self-hosted Supabase functions

Self-hosted installs should keep deploying every function:

```bash
supabase functions deploy
Expand Down
54 changes: 32 additions & 22 deletions cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { confirmWithRememberedChoice } from '../promptPreferences'
import { showReplicationProgress } from '../replicationProgress'
import { formatTable } from '../terminal-table'
import { usesAlwaysDirectUpdate } from '../updaterConfig'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { baseKeyV2, BROTLI_MIN_UPDATER_VERSION_V5, BROTLI_MIN_UPDATER_VERSION_V6, BROTLI_MIN_UPDATER_VERSION_V7, canPromptInteractively, checkCompatibilityCloud, checkPlanValidUpload, checkRemoteCliMessages, createSupabaseClient, deletedFailedVersion, deltaManifestTooLargeMessage, findRoot, findSavedKey, formatError, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, getInstalledVersion, getLocalConfig, getLocalDependencies, getOrganizationId, getPMAndCommand, getRemoteChecksums, getRemoteFileConfig, hasCliPermission, invokeCapgoCliApi, isCompatible, isDeprecatedPluginVersion, MAX_MANIFEST_ENTRIES, regexSemver, resolveUserIdFromApiKey, sendEvent, setVersionManifest, updateConfigUpdater, updateOrCreateChannel, updateOrCreateVersion, UPLOAD_TIMEOUT, uploadTUS, uploadUrl, zipFile } from '../utils'
import { getVersionSuggestions, interactiveVersionBump } from '../versionHelpers'
import { maybePromptBuilderCta, shouldBlockIncompatibleUpload } from './builder-cta'
import { checkIndexPosition, searchInDirectory } from './check'
Expand Down Expand Up @@ -596,7 +596,7 @@ async function uploadBundleToCapgoCloud(apikey: string, supabase: SupabaseType,
if (options.verbose)
log.info(`[Verbose] Using standard upload (non-TUS), getting presigned URL...`)

const url = await uploadUrl(supabase, appid, bundle)
const url = await uploadUrl(apikey, appid, bundle, options)
if (!url) {
log.error(`Cannot get upload url`)
if (options.verbose)
Expand Down Expand Up @@ -672,7 +672,7 @@ async function uploadBundleToCapgoCloud(apikey: string, supabase: SupabaseType,
log.info(`[Verbose] Cleaning up failed version from database...`)

try {
await deletedFailedVersion(supabase, appid, bundle)
await deletedFailedVersion(apikey, appid, bundle, options)
if (options.verbose)
log.info(`[Verbose] Failed version cleaned up`)
}
Expand Down Expand Up @@ -874,20 +874,24 @@ async function formatFunctionInvokeError(error: unknown): Promise<string> {
}

async function promoteExistingChannel(
supabase: SupabaseType,
apikey: string,
appid: string,
versionId: number,
targetChannel: UploadTargetChannel,
localConfig: localConfigType,
displayBundleUrl: boolean,
options?: { supaHost?: string, supaAnon?: string },
): Promise<boolean> {
const { error } = await supabase.functions.invoke('bundle', {
const { error } = await invokeCapgoCliApi('bundle', {
apikey,
method: 'PUT',
body: JSON.stringify({
body: {
app_id: appid,
version_id: versionId,
channel_id: targetChannel.id,
}),
},
supaHost: options?.supaHost,
supaAnon: options?.supaAnon,
})

if (error)
Expand Down Expand Up @@ -917,8 +921,8 @@ async function setVersionInChannel(
targetChannel: UploadTargetChannel | null,
requireChannelAssignment = false,
selfAssign?: boolean,
cliHost?: { supaHost?: string, supaAnon?: string },
): Promise<boolean> {

const canPromoteTargetChannel = targetChannel !== null
&& await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id })
const canCreateChannel = targetChannel === null
Expand All @@ -938,12 +942,12 @@ async function setVersionInChannel(
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)
return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost)
}
}

if (!selfAssign)
return promoteExistingChannel(supabase, appid, versionId, targetChannel, localConfig, displayBundleUrl)
return promoteExistingChannel(apikey, appid, versionId, targetChannel, localConfig, displayBundleUrl, cliHost)

const { error: dbError3, data } = await updateOrCreateChannel(supabase, {
name: channel,
Expand All @@ -969,14 +973,17 @@ async function setVersionInChannel(
// 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', {
const { error, data } = await invokeCapgoCliApi('channel', {
apikey,
method: 'POST',
body: JSON.stringify({
body: {
app_id: appid,
channel,
version: bundle,
...(selfAssign ? { allow_device_self_set: true } : {}),
}),
},
supaHost: cliHost?.supaHost,
supaAnon: cliHost?.supaAnon,
})
if (error) {
uploadFail(`Cannot create channel and set its bundle because this API key does not have the required RBAC permission. ${await formatFunctionInvokeError(error)}`)
Expand Down Expand Up @@ -1031,6 +1038,7 @@ async function setRolloutVersionInChannel(
rolloutPercentageBps: number,
rolloutCacheTtlSeconds?: number,
selfAssign?: boolean,
cliHost?: { supaHost?: string, supaAnon?: string },
): Promise<boolean> {
if (!targetChannel)
uploadFail(`Cannot set rollout, channel ${channel} must already exist with a stable bundle`)
Expand All @@ -1048,9 +1056,10 @@ async function setRolloutVersionInChannel(
uploadFail('Cannot set rollout because this API key lacks channel.update_settings for the target channel')

const shouldResumeSameRollout = targetChannel.rollout_version === versionId && rolloutPercentageBps > 0
const { error: rolloutError } = await supabase.functions.invoke('channel', {
const { error: rolloutError } = await invokeCapgoCliApi('channel', {
apikey,
method: 'POST',
body: JSON.stringify({
body: {
app_id: appid,
channel,
rolloutVersion: bundle,
Expand All @@ -1059,7 +1068,9 @@ async function setRolloutVersionInChannel(
...(shouldResumeSameRollout ? { rolloutPaused: false } : {}),
...(rolloutCacheTtlSeconds != null ? { rolloutCacheTtlSeconds } : {}),
...(selfAssign ? { allow_device_self_set: true } : {}),
}),
},
supaHost: cliHost?.supaHost,
supaAnon: cliHost?.supaAnon,
})

if (rolloutError)
Expand Down Expand Up @@ -1514,7 +1525,6 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
if (options.delta && manifest.length > MAX_MANIFEST_ENTRIES)
uploadFail(deltaManifestTooLargeMessage(manifest.length))


if (options.verbose)
log.info(`[Verbose] Creating version record in database...`)

Expand Down Expand Up @@ -1589,7 +1599,7 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
}
catch (error) {
try {
await deletedFailedVersion(supabase, appid, bundle)
await deletedFailedVersion(apikey, appid, bundle, options)
}
catch (cleanupError) {
uploadFail(`Cannot upload bundle to S3 ${formatError(error)}. Cleanup of the incomplete version also failed (${formatError(cleanupError)}); delete bundle ${bundle} manually before retrying.`)
Expand Down Expand Up @@ -1665,11 +1675,11 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
log.info(`[Verbose] Writing ${finalManifest.length} manifest rows via set_manifest...`)

try {
await setVersionManifest(supabase, appid, bundle, finalManifest)
await setVersionManifest(apikey, appid, bundle, finalManifest, options)
}
catch (error) {
try {
await deletedFailedVersion(supabase, appid, bundle)
await deletedFailedVersion(apikey, appid, bundle, options)
}
catch (cleanupError) {
uploadFail(`Cannot set bundle manifest ${formatError(error)}. Cleanup of the incomplete version also failed (${formatError(cleanupError)}); delete bundle ${bundle} manually before retrying.`)
Expand Down Expand Up @@ -1729,8 +1739,8 @@ export async function uploadBundleInternal(preAppid: string, options: OptionsUpl
? uploadTargetChannels.get(targetChannel) ?? null
: await findUploadTargetChannel(supabase, appid, targetChannel)
const targetChannelVersionSet = rolloutPercentageBps != null
? await setRolloutVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, appid, localConfig, uploadTargetChannel, rolloutPercentageBps, options.rolloutCacheTtlSeconds, options.selfAssign)
: await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, userId, orgId, appid, localConfig, uploadTargetChannel, channelAssignmentRequired, options.selfAssign)
? await setRolloutVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, appid, localConfig, uploadTargetChannel, rolloutPercentageBps, options.rolloutCacheTtlSeconds, options.selfAssign, options)
: await setVersionInChannel(supabase, apikey, !!options.bundleUrl, bundle, targetChannel, userId, orgId, appid, localConfig, uploadTargetChannel, channelAssignmentRequired, options.selfAssign, options)
if (targetChannelVersionSet)
channelVersionSet.add(targetChannel)
if (options.verbose)
Expand Down
19 changes: 7 additions & 12 deletions cli/src/channel/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,7 @@ import { intro, log, outro } from '@clack/prompts'
import { check2FAComplianceForApp, checkAppExistsAndHasPermissionOrgErr } from '../api/app'
import { delChannel, findBundleIdByChannelName, findChannel } from '../api/channels'
import { deleteAppVersion } from '../api/versions'
import {
createSupabaseClient,
findSavedKey,
formatError,
getAppId,
getConfig,
hasCliPermission,
sendEvent,
} from '../utils'
import { createSupabaseClient, findSavedKey, formatError, getAppId, getConfig, hasCliPermission, invokeCapgoCliApi, sendEvent } from '../utils'

export async function deleteChannelInternal(channelId: string, appId: string, options: ChannelDeleteOptions, silent = false) {
if (!silent)
Expand Down Expand Up @@ -65,13 +57,16 @@ export async function deleteChannelInternal(channelId: string, appId: string, op
if (!silent)
log.info(`Deleting preview channel ${appId}#${channelId} and its bundle from Capgo`)

const { error } = await supabase.functions.invoke('channel', {
const { error } = await invokeCapgoCliApi('channel', {
apikey: options.apikey!,
method: 'DELETE',
body: JSON.stringify({
body: {
app_id: appId,
channel: channelId,
delete_bundle: true,
}),
},
supaHost: options.supaHost,
supaAnon: options.supaAnon,
Comment thread
riderx marked this conversation as resolved.
})
if (error) {
const message = `Cannot delete preview channel and bundle: ${formatError(error)}`
Expand Down
24 changes: 7 additions & 17 deletions cli/src/channel/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,7 @@ import { findChannel } from '../api/channels'
import { sendUpdateNotificationsForChannels } from '../notifications/send-update'
import { printPreviewQrForResolvedTarget, resolveChannelPreviewTarget } from '../preview/qr'
import { formatTable } from '../terminal-table'
import {
checkCompatibilityNativePackages,
checkPlanValid,
createSupabaseClient,
findSavedKey,
getAppId,
getBundleVersion,
getCompatibilityDetails,
getConfig,
isCompatible,
resolveUserIdFromApiKey,
sendEvent,
updateOrCreateChannel,
} from '../utils'
import { checkCompatibilityNativePackages, checkPlanValid, createSupabaseClient, findSavedKey, getAppId, getBundleVersion, getCompatibilityDetails, getConfig, invokeCapgoCliApi, isCompatible, resolveUserIdFromApiKey, sendEvent, updateOrCreateChannel } from '../utils'

/**
* Display a compatibility table for the given packages
Expand Down Expand Up @@ -609,13 +596,16 @@ export async function setChannelInternal(channel: string, appId: string, options
}

if (hasStableBundlePromotion && !hasSettingsUpdate) {
const { error } = await supabase.functions.invoke('bundle', {
const { error } = await invokeCapgoCliApi('bundle', {
apikey: options.apikey!,
method: 'PUT',
body: JSON.stringify({
body: {
app_id: appId,
version_id: channelPayload.version,
channel_id: existingChannel.id,
}),
},
supaHost: options.supaHost,
Comment thread
riderx marked this conversation as resolved.
supaAnon: options.supaAnon,
})
if (error) {
if (!silent)
Expand Down
Loading
Loading