diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index cae6dcce41..568400ce2b 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -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 diff --git a/README.md b/README.md index f562511515..3174de25fe 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 +``` + +### Deploy self-hosted Supabase functions + +Self-hosted installs should keep deploying every function: ```bash supabase functions deploy diff --git a/cli/src/bundle/upload.ts b/cli/src/bundle/upload.ts index e3c71a9b76..50dfda36fe 100644 --- a/cli/src/bundle/upload.ts +++ b/cli/src/bundle/upload.ts @@ -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' @@ -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) @@ -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`) } @@ -874,20 +874,24 @@ async function formatFunctionInvokeError(error: unknown): Promise { } async function promoteExistingChannel( - supabase: SupabaseType, + apikey: string, appid: string, versionId: number, targetChannel: UploadTargetChannel, localConfig: localConfigType, displayBundleUrl: boolean, + options?: { supaHost?: string, supaAnon?: string }, ): Promise { - 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) @@ -917,8 +921,8 @@ async function setVersionInChannel( targetChannel: UploadTargetChannel | null, requireChannelAssignment = false, selfAssign?: boolean, + cliHost?: { supaHost?: string, supaAnon?: string }, ): Promise { - const canPromoteTargetChannel = targetChannel !== null && await hasCliPermission(supabase, apikey, 'channel.promote_bundle', { appId: appid, channelId: targetChannel.id }) const canCreateChannel = targetChannel === null @@ -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, @@ -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)}`) @@ -1031,6 +1038,7 @@ async function setRolloutVersionInChannel( rolloutPercentageBps: number, rolloutCacheTtlSeconds?: number, selfAssign?: boolean, + cliHost?: { supaHost?: string, supaAnon?: string }, ): Promise { if (!targetChannel) uploadFail(`Cannot set rollout, channel ${channel} must already exist with a stable bundle`) @@ -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, @@ -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) @@ -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...`) @@ -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.`) @@ -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.`) @@ -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) diff --git a/cli/src/channel/delete.ts b/cli/src/channel/delete.ts index 3041d7ad66..1f8d4f7f78 100644 --- a/cli/src/channel/delete.ts +++ b/cli/src/channel/delete.ts @@ -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) @@ -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, }) if (error) { const message = `Cannot delete preview channel and bundle: ${formatError(error)}` diff --git a/cli/src/channel/set.ts b/cli/src/channel/set.ts index 2991122b59..b3b94f803b 100644 --- a/cli/src/channel/set.ts +++ b/cli/src/channel/set.ts @@ -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 @@ -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, + supaAnon: options.supaAnon, }) if (error) { if (!silent) diff --git a/cli/src/utils.ts b/cli/src/utils.ts index db41319f7b..a52fdea7a2 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -28,8 +28,8 @@ import { findMonorepoRoot, findNXMonorepoRoot, isMonorepo, isNXMonorepo } from ' import { getChecksum } from './checksum' import { loadConfig, loadConfigForWrite, writeConfig } from './config' import { isTruthyEnvValue } from './posthog' -import { safeParseSchema } from './schemas/schema_validation' import { nativePackageSchema } from './schemas/common' +import { safeParseSchema } from './schemas/schema_validation' import { formatApiErrorForCli, parseSecurityPolicyError } from './utils/security_policy_errors' export const baseKey = '.capgo_key' @@ -674,22 +674,44 @@ interface CapgoConfig { hostFilesApi: string hostApi: string } +let cachedRemoteConfig: CapgoConfig | null = null +let remoteConfigInFlight: Promise | null = null + export async function getRemoteConfig(silent = false, signal?: AbortSignal) { // call host + /api/get_config and parse the result as json using fetch - const localConfig = await getLocalConfig(silent) - try { - const response = await fetch(`${localConfig.hostApi}/private/config`, signal ? { signal } : {}) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) + // Always return a shallow copy so callers cannot mutate the process-wide cache. + if (!signal && cachedRemoteConfig) + return { ...cachedRemoteConfig } + if (!signal && remoteConfigInFlight) + return remoteConfigInFlight.then(config => ({ ...config })) + + const run = (async () => { + const localConfig = await getLocalConfig(silent) + try { + const response = await fetch(`${localConfig.hostApi}/private/config`, signal ? { signal } : {}) + if (!response.ok) + throw new Error(`HTTP error! status: ${response.status}`) + const data = await response.json() as CapgoConfig + const merged = { ...data, ...localConfig } as CapgoConfig + if (!signal) + cachedRemoteConfig = merged + return { ...merged } } - const data = await response.json() as CapgoConfig - return { ...data, ...localConfig } as CapgoConfig - } - catch { - if (!silent) - log.info(`Local config ${formatError(localConfig)}`) - return localConfig - } + catch { + if (!silent) + log.info(`Local config ${formatError(localConfig)}`) + // Do not cache fallbacks — a later call can recover after a transient failure. + return { ...localConfig } + } + finally { + if (!signal) + remoteConfigInFlight = null + } + })() + + if (!signal) + remoteConfigInFlight = run + return run } interface CapgoFilesConfig { @@ -745,24 +767,138 @@ export function formatCapgoApiErrorBody(body: unknown): string { return [record.error, record.message, record.status].filter(Boolean).join(' | ') } -/** Resolve Capgo public API base URL for CLI mutations (app create/update, etc.). */ +/** Capgo-managed Supabase hosts (cloud). Match hostname exactly. */ +export function isCapgoManagedSupabaseHost(supaHost?: string): boolean { + if (!supaHost) + return false + try { + const hostname = new URL(normalizeSupabaseHost(supaHost)).hostname.toLowerCase() + return hostname === 'sb.capgo.app' + || hostname === 'xvwzpoazmxkqosrdewyv.supabase.co' + || hostname === 'ibwjdnhknbkcqfbabwei.supabase.co' + || hostname === 'aucsybvnhavogdmzwtcw.supabase.co' + } + catch { + return false + } +} + +/** + * Resolve Capgo public API base URL for CLI mutations (app create/update, etc.). + * Capgo cloud uses api.capgo.app. Self-host with only localSupa (default localApi) + * keeps /functions/v1 on that Supabase host. + */ export function resolveConfiguredCapgoPublicApiHost(config: { hostApi: string + hostFilesApi?: string supaHost?: string supaKey?: string }): string { - if (config.supaHost && config.supaKey && config.hostApi === defaultApiHost) + if ( + config.supaHost + && config.supaKey + && config.hostApi === defaultApiHost + && !isCapgoManagedSupabaseHost(config.supaHost) + ) { return `${normalizeSupabaseHost(config.supaHost)}/functions/v1` + } return config.hostApi } +export interface CapgoCliInvokeOptions { + apikey: string + method?: string + body?: unknown + /** Capgo cloud files worker; ignored when resolving to self-host /functions/v1 */ + useFilesHost?: boolean + supaHost?: string + supaAnon?: string +} + +/** + * Invoke Capgo HTTP APIs formerly reached via supabase.functions.invoke. + * Capgo cloud -> hostApi / hostFilesApi. Self-host -> /functions/v1. + */ +export async function invokeCapgoCliApi( + path: string, + options: CapgoCliInvokeOptions, +): Promise<{ data: T | null, error: Error | null }> { + const method = (options.method ?? 'POST').toUpperCase() + let base: string + let anonKey: string | undefined = options.supaAnon + if (options.supaHost && options.supaAnon && !isCapgoManagedSupabaseHost(options.supaHost)) { + base = `${normalizeSupabaseHost(options.supaHost)}/functions/v1` + } + else { + const localConfig = await getRemoteConfig(true) + anonKey = options.supaAnon ?? localConfig.supaKey + if ( + localConfig.supaHost + && localConfig.supaKey + && localConfig.hostApi === defaultApiHost + && !isCapgoManagedSupabaseHost(localConfig.supaHost) + && !(options.supaHost && isCapgoManagedSupabaseHost(options.supaHost)) + ) { + base = `${normalizeSupabaseHost(localConfig.supaHost)}/functions/v1` + } + else if (options.useFilesHost) { + base = localConfig.hostFilesApi + } + else { + base = localConfig.hostApi + } + } + + const usesFunctionsV1 = base.includes('/functions/v1') + const url = `${base.replace(/\/+$/, '')}/${path.replace(/^\//, '')}` + try { + const response = await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + // Self-host Edge Functions validate the Supabase anon JWT; Capgo cloud uses the API key. + 'Authorization': usesFunctionsV1 && anonKey ? `Bearer ${anonKey}` : options.apikey, + 'capgkey': options.apikey, + }, + body: method === 'GET' || method === 'HEAD' + ? undefined + : (typeof options.body === 'string' ? options.body : JSON.stringify(options.body ?? {})), + }) + + if (!response.ok) { + return { + data: null, + error: new FunctionsHttpError(response), + } + } + + const contentType = response.headers.get('content-type') ?? '' + const payload = contentType.includes('application/json') + ? await response.json().catch(() => null) + : await response.text().catch(() => null) + + return { data: payload as T, error: null } + } + catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error(String(error)), + } + } +} + export async function resolveCapgoPublicApiHost( options?: { supaHost?: string, supaAnon?: string }, silent = true, ): Promise { - if (options?.supaHost && options?.supaAnon) + if (options?.supaHost && options?.supaAnon) { + if (isCapgoManagedSupabaseHost(options.supaHost)) { + const localConfig = await getLocalConfig(silent) + return localConfig.hostApi + } return `${normalizeSupabaseHost(options.supaHost)}/functions/v1` + } const localConfig = await getLocalConfig(silent) if (localConfig.supaHost && localConfig.supaKey) @@ -777,6 +913,7 @@ export async function createSupabaseClient(apikey: string, supaHost?: string, su if (supaHost && supaKey) { if (!silent) log.info('Using custom supabase instance from provided options') + // Mutate only this call's copy — getRemoteConfig returns a shallow clone. config.supaHost = supaHost config.supaKey = supaKey } @@ -1287,20 +1424,24 @@ export async function updateOrCreateVersion(supabase: SupabaseClient, .eq('name', update.name) } -export async function uploadUrl(supabase: SupabaseClient, appId: string, name: string): Promise { +export async function uploadUrl(apikey: string, appId: string, name: string, options?: { supaHost?: string, supaAnon?: string }): Promise { const data = { app_id: appId, name, version: 0, } try { - const pathUploadLink = 'files/upload_link' - const res = await supabase.functions.invoke(pathUploadLink, { body: JSON.stringify(data) }) + const res = await invokeCapgoCliApi<{ url?: string }>('files/upload_link', { + apikey, + body: data, + useFilesHost: true, + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }) if (res.error) { - // Handle error case if (res.error instanceof FunctionsHttpError) { - const errorBody = await res.error.context.json() + const errorBody = await res.error.context.json().catch(() => ({})) log.error(`Upload URL error: ${errorBody.status || JSON.stringify(errorBody)}`) } else { @@ -1309,7 +1450,7 @@ export async function uploadUrl(supabase: SupabaseClient, appId: strin return '' } - return res.data.url + return res.data?.url ?? '' } catch (error) { log.error(`Cannot get upload url ${formatError(error)}`) @@ -1486,13 +1627,18 @@ export async function uploadTUS(apikey: string, data: Buffer, orgId: string, app }) } -export async function deletedFailedVersion(supabase: SupabaseClient, appId: string, name: string): Promise { +export async function deletedFailedVersion(apikey: string, appId: string, name: string, options?: { supaHost?: string, supaAnon?: string }): Promise { const data = { app_id: appId, name, } - const pathFailed = 'private/delete_failed_version' - const res = await supabase.functions.invoke(pathFailed, { body: JSON.stringify(data), method: 'DELETE' }) + const res = await invokeCapgoCliApi('private/delete_failed_version', { + apikey, + method: 'DELETE', + body: data, + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }) if (res.error) { if (res.error instanceof FunctionsHttpError) { @@ -1514,18 +1660,23 @@ export interface VersionManifestEntry { * Prefer this over writing app_versions.manifest jsonb — old CLIs still use that legacy path. */ export async function setVersionManifest( - supabase: SupabaseClient, + apikey: string, appId: string, name: string, manifest: VersionManifestEntry[], + options?: { supaHost?: string, supaAnon?: string }, ): Promise { const data = { app_id: appId, name, manifest, } - const pathSetManifest = 'private/set_manifest' - const res = await supabase.functions.invoke(pathSetManifest, { body: JSON.stringify(data) }) + const res = await invokeCapgoCliApi('private/set_manifest', { + apikey, + body: data, + supaHost: options?.supaHost, + supaAnon: options?.supaAnon, + }) if (res.error) { if (res.error instanceof FunctionsHttpError) { diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index 6a233350ed..6fe6c7d9b6 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -21,6 +21,7 @@ import { app as native_observe_stats } from '../../supabase/functions/_backend/p import { app as plans } from '../../supabase/functions/_backend/private/plans.ts' import { app as publicStats } from '../../supabase/functions/_backend/private/public_stats.ts' import { app as replay } from '../../supabase/functions/_backend/private/replay.ts' +import { app as role_bindings } from '../../supabase/functions/_backend/private/role_bindings.ts' import { app as set_manifest } from '../../supabase/functions/_backend/private/set_manifest.ts' import { app as set_org_email } from '../../supabase/functions/_backend/private/set_org_email.ts' import { app as sso_check_domain } from '../../supabase/functions/_backend/private/sso/check-domain.ts' @@ -38,6 +39,7 @@ import { app as stripe_portal } from '../../supabase/functions/_backend/private/ import { app as update_delivery_stats } from '../../supabase/functions/_backend/private/update_delivery_stats.ts' import { app as validate_password_compliance } from '../../supabase/functions/_backend/private/validate_password_compliance.ts' import { app as verify_email_otp } from '../../supabase/functions/_backend/private/verify_email_otp.ts' +import { app as website_preview } from '../../supabase/functions/_backend/private/website_preview.ts' import { app as apikey } from '../../supabase/functions/_backend/public/apikey/index.ts' import { app as appEndpoint } from '../../supabase/functions/_backend/public/app/index.ts' import { app as build } from '../../supabase/functions/_backend/public/build/index.ts' @@ -144,6 +146,8 @@ appPrivate.route('/latency', latency) appPrivate.route('/replay', replay) appPrivate.route('/events', events) appPrivate.route('/groups', groups) +appPrivate.route('/role_bindings', role_bindings) +appPrivate.route('/website_preview', website_preview) appPrivate.route('/sso/check-domain', sso_check_domain) appPrivate.route('/sso/check-enforcement', sso_check_enforcement) appPrivate.route('/sso/providers', sso_providers) diff --git a/package.json b/package.json index aca9b28052..bef9e8e98d 100644 --- a/package.json +++ b/package.json @@ -184,8 +184,8 @@ "deploy:cloudflare_env:plugin_me:prod": "bunx wrangler secret bulk internal/cloudflare/.env.prod --config cloudflare_workers/plugin/wrangler.jsonc --env=prod_me", "deploy:cloudflare_env:plugin_hk:prod": "bunx wrangler secret bulk internal/cloudflare/.env.prod --config cloudflare_workers/plugin/wrangler.jsonc --env=prod_hk", "deploy:cloudflare_env:plugin_jp:prod": "bunx wrangler secret bulk internal/cloudflare/.env.prod --config cloudflare_workers/plugin/wrangler.jsonc --env=prod_jp", - "deploy:supabase:prod": "bunx supabase functions deploy --project-ref xvwzpoazmxkqosrdewyv", - "deploy:supabase:preprod": "bunx supabase functions deploy --project-ref ibwjdnhknbkcqfbabwei", + "deploy:supabase:prod": "FUNCTIONS=$(bun scripts/supabase-cloud-functions.ts deploy-args) && test -n \"$FUNCTIONS\" && bunx supabase functions deploy $FUNCTIONS --project-ref xvwzpoazmxkqosrdewyv && for fn in $(bun scripts/supabase-cloud-functions.ts skip-list); do out=$(bunx supabase functions delete $fn --project-ref xvwzpoazmxkqosrdewyv 2>&1) || { echo \"$out\" | grep -qiE \"not found|does not exist|404|Function not found\" || { echo \"$out\" >&2; exit 1; }; }; done", + "deploy:supabase:preprod": "FUNCTIONS=$(bun scripts/supabase-cloud-functions.ts deploy-args) && test -n \"$FUNCTIONS\" && bunx supabase functions deploy $FUNCTIONS --project-ref ibwjdnhknbkcqfbabwei && for fn in $(bun scripts/supabase-cloud-functions.ts skip-list); do out=$(bunx supabase functions delete $fn --project-ref ibwjdnhknbkcqfbabwei 2>&1) || { echo \"$out\" | grep -qiE \"not found|does not exist|404|Function not found\" || { echo \"$out\" >&2; exit 1; }; }; done", "deploy:supabase_env:prod": "bunx supabase secrets set --project-ref xvwzpoazmxkqosrdewyv --env-file internal/cloudflare/.env.prod", "deploy:supabase_env:preprod": "bunx supabase secrets set --project-ref ibwjdnhknbkcqfbabwei --env-file internal/cloudflare/.env.preprod", "deploy:supabase_env:dev": "bunx supabase secrets set --project-ref aucsybvnhavogdmzwtcw --env-file internal/cloudflare/.env.alpha", diff --git a/scripts/supabase-cloud-functions.ts b/scripts/supabase-cloud-functions.ts new file mode 100644 index 0000000000..ffeafb2f2f --- /dev/null +++ b/scripts/supabase-cloud-functions.ts @@ -0,0 +1,89 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' + +/** + * Capgo cloud (prod/preprod/alpha) Supabase Edge Functions that must stay + * published on sb.capgo.app. + * + * Forever: + * - `triggers` — Postgres pg_net → /functions/v1/triggers/queue_consumer/sync + * + * Deprecated for legacy Capgo CLIs that still call supabase.functions.invoke + * on sb.capgo.app. Keep Capgo-cloud publish until 2026-10-28; new CLI uses + * api.capgo.app / files.capgo.app. Capgo cloud console uses VITE_API_HOST. + * Self-host / local consoles keep supabase.functions.invoke → /functions/v1. + * - `bundle`, `channel`, `files`, `private` + * + * Capgo cloud deploy deletes every other local function name (skip-list). + * Self-hosted installs still deploy every function under supabase/functions/. + */ +export const CAPGO_CLOUD_SUPABASE_FUNCTIONS_CLI_DEPRECATION_UNTIL = '2026-10-28' + +export const CAPGO_CLOUD_SUPABASE_FUNCTIONS_FOREVER = [ + 'triggers', +] as const + +export const CAPGO_CLOUD_SUPABASE_FUNCTIONS_CLI_DEPRECATION = [ + 'bundle', + 'channel', + 'files', + 'private', +] as const + +export const CAPGO_CLOUD_SUPABASE_FUNCTIONS = [ + ...CAPGO_CLOUD_SUPABASE_FUNCTIONS_FOREVER, + ...CAPGO_CLOUD_SUPABASE_FUNCTIONS_CLI_DEPRECATION, +] as const + +export type CapgoCloudSupabaseFunction = typeof CAPGO_CLOUD_SUPABASE_FUNCTIONS[number] + +const SKIP_FUNCTION_DIRS = new Set([ + '_backend', + 'shared', + 'plugin_runtime', +]) + +export function listLocalSupabaseFunctions(functionsDir = join(process.cwd(), 'supabase', 'functions')): string[] { + return readdirSync(functionsDir, { withFileTypes: true }) + .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !SKIP_FUNCTION_DIRS.has(entry.name)) + .map(entry => entry.name) + .sort() +} + +export function listCapgoCloudSkippedSupabaseFunctions(localFunctions = listLocalSupabaseFunctions()): string[] { + const keep = new Set(CAPGO_CLOUD_SUPABASE_FUNCTIONS) + return localFunctions.filter(name => !keep.has(name)) +} + +export function buildCapgoCloudSupabaseDeployArgs( + functions: readonly string[] = CAPGO_CLOUD_SUPABASE_FUNCTIONS, +): string[] { + if (functions.length === 0) + throw new Error('CAPGO_CLOUD_SUPABASE_FUNCTIONS must not be empty') + return [...functions] +} + +if (import.meta.main) { + const mode = process.argv[2] ?? 'deploy-args' + if (mode === 'list') { + for (const name of CAPGO_CLOUD_SUPABASE_FUNCTIONS) + console.log(name) + process.exit(0) + } + if (mode === 'skip-list') { + for (const name of listCapgoCloudSkippedSupabaseFunctions()) + console.log(name) + process.exit(0) + } + if (mode === 'deploy-args') { + console.log(buildCapgoCloudSupabaseDeployArgs().join(' ')) + process.exit(0) + } + if (mode === 'delete-args') { + console.log(listCapgoCloudSkippedSupabaseFunctions().join(' ')) + process.exit(0) + } + console.error(`Unknown mode: ${mode}. Use deploy-args | list | skip-list | delete-args`) + process.exit(1) +} diff --git a/src/components/dashboard/AppAccess.vue b/src/components/dashboard/AppAccess.vue index d2cb57553c..296a226986 100644 --- a/src/components/dashboard/AppAccess.vue +++ b/src/components/dashboard/AppAccess.vue @@ -13,6 +13,7 @@ import DataTable from '~/components/DataTable.vue' import RoleSelect from '~/components/forms/RoleSelect.vue' import SearchInput from '~/components/forms/SearchInput.vue' import RoleSelectionModal from '~/components/modals/RoleSelectionModal.vue' +import { invokeCapgoApi } from '~/services/capgoApi' import { formatLocalDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { useSupabase } from '~/services/supabase' @@ -151,7 +152,7 @@ async function fetchAppRoleBindings() { isLoading.value = true try { - const { data, error } = await supabase.functions.invoke(`private/role_bindings/${ownerOrg.value}`, { + const { data, error } = await invokeCapgoApi(`private/role_bindings/${ownerOrg.value}`, { method: 'GET', }) @@ -295,7 +296,7 @@ async function fetchAvailableGroups() { return try { - const { data, error } = await supabase.functions.invoke(`private/groups/${ownerOrg.value}`, { + const { data, error } = await invokeCapgoApi(`private/groups/${ownerOrg.value}`, { method: 'GET', }) @@ -331,7 +332,7 @@ async function assignRole() { isLoading.value = true try { - const { error } = await supabase.functions.invoke('private/role_bindings', { + const { error } = await invokeCapgoApi('private/role_bindings', { method: 'POST', body: { principal_type: assignRoleForm.value.principal_type, @@ -377,7 +378,7 @@ async function handleEditRoleConfirm(newRoleName: string) { isEditingRole.value = true try { - const { error: updateError } = await supabase.functions.invoke(`private/role_bindings/${editRoleBinding.value.id}`, { + const { error: updateError } = await invokeCapgoApi(`private/role_bindings/${editRoleBinding.value.id}`, { method: 'PATCH', body: { role_name: newRoleName }, }) @@ -412,7 +413,7 @@ async function removeRoleBinding(bindingId: string) { isLoading.value = true try { - const { error } = await supabase.functions.invoke(`private/role_bindings/${bindingId}`, { + const { error } = await invokeCapgoApi(`private/role_bindings/${bindingId}`, { method: 'DELETE', }) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index b2ec358d54..a71df14420 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -23,6 +23,7 @@ import IconStore from '~icons/lucide/store' import IconTerminal from '~icons/lucide/terminal' import IconUsers from '~icons/lucide/users-round' import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys' +import { getCapgoApiErrorCode, invokeCapgoApi } from '~/services/capgoApi' import { pushEvent } from '~/services/posthog' import { createSignedImageUrl, getImmediateImageUrl } from '~/services/storage' import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase' @@ -412,7 +413,7 @@ async function importStoreMetadata() { const requestedRun = ++storeImportRun isImportingStore.value = true try { - const { data, error } = await supabase.functions.invoke('app/store-metadata', { + const { data, error } = await invokeCapgoApi('app/store-metadata', { method: 'POST', body: { url: requestedUrl }, }) @@ -630,7 +631,7 @@ async function createOrganizationAndApp() { isSubmitting.value = true try { - const { data, error } = await supabase.functions.invoke('organization', { + const { data, error } = await invokeCapgoApi('organization', { method: 'POST', body: { name: orgName, @@ -642,7 +643,8 @@ async function createOrganizationAndApp() { if (error || !data?.id) { console.error('Error creating organization during unified onboarding', error) - toast.error(error?.code === '23505' + const errorCode = await getCapgoApiErrorCode(error) + toast.error(errorCode === '23505' ? t('org-with-this-name-exists') : t('cannot-create-org')) return @@ -796,7 +798,7 @@ async function seedDemoData() { isSeedingDemo.value = true try { - const { data, error } = await supabase.functions.invoke('app/demo', { + const { data, error } = await invokeCapgoApi('app/demo', { method: 'POST', body: { owner_org: currentOrg.value.gid, diff --git a/src/components/dashboard/AppSetting.vue b/src/components/dashboard/AppSetting.vue index 78a5510aab..24076f782e 100644 --- a/src/components/dashboard/AppSetting.vue +++ b/src/components/dashboard/AppSetting.vue @@ -13,6 +13,7 @@ import transfer from '~icons/mingcute/transfer-horizontal-line?raw&width=36&heig import gearSix from '~icons/ph/gear-six?raw' import iconName from '~icons/ph/user?raw' import Toggle from '~/components/Toggle.vue' +import { invokeCapgoApi } from '~/services/capgoApi' import { checkPermissions } from '~/services/permissions' import { createSignedImageUrl, getImmediateImageUrl } from '~/services/storage' import { useSupabase } from '~/services/supabase' @@ -412,7 +413,7 @@ async function importIconFromStore() { isImportingStoreIcon.value = true try { - const { data, error } = await supabase.functions.invoke('app/store-metadata', { + const { data, error } = await invokeCapgoApi('app/store-metadata', { method: 'POST', body: { url: storeImportUrl.value }, }) diff --git a/src/components/dashboard/InviteTeammateModal.vue b/src/components/dashboard/InviteTeammateModal.vue index 79d8b7544d..194b7195ca 100644 --- a/src/components/dashboard/InviteTeammateModal.vue +++ b/src/components/dashboard/InviteTeammateModal.vue @@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' import VueTurnstile from 'vue-turnstile' +import { invokeCapgoApi } from '~/services/capgoApi' import { useSupabase } from '~/services/supabase' import { sendEvent } from '~/services/tracking' import { useDialogV2Store } from '~/stores/dialogv2' @@ -315,7 +316,7 @@ async function handleFullDetailsSubmit() { isInviting.value = true try { - const { error } = await supabase.functions.invoke('private/invite_new_user_to_org', { + const { error } = await invokeCapgoApi('private/invite_new_user_to_org', { body: { email, org_id: orgId, diff --git a/src/components/dashboard/StepsApp.vue b/src/components/dashboard/StepsApp.vue index 36f12a7351..4d0bceea4f 100644 --- a/src/components/dashboard/StepsApp.vue +++ b/src/components/dashboard/StepsApp.vue @@ -8,6 +8,7 @@ import IconChevronDown from '~icons/lucide/chevron-down' import IconLoader from '~icons/lucide/loader-2' import InviteTeammateModal from '~/components/dashboard/InviteTeammateModal.vue' import { createDefaultApiKey, findUsablePlainApiKey } from '~/services/apikeys' +import { invokeCapgoApi } from '~/services/capgoApi' import { pushEvent } from '~/services/posthog' import { getLocalConfig, isLocal, useSupabase } from '~/services/supabase' import { sendEvent } from '~/services/tracking' @@ -167,7 +168,7 @@ async function createDemoApp() { }).catch() pushEvent('user:onboarding-create-demo-app', config.supaHost, { org_id: orgId }) - const { data, error } = await supabase.functions.invoke('app/demo', { + const { data, error } = await invokeCapgoApi('app/demo', { method: 'POST', body: { owner_org: orgId, diff --git a/src/components/dashboard/Usage.vue b/src/components/dashboard/Usage.vue index fa0cf0e222..ddf6d36cb7 100644 --- a/src/components/dashboard/Usage.vue +++ b/src/components/dashboard/Usage.vue @@ -10,6 +10,7 @@ import BanknotesIcon from '~icons/heroicons/banknotes' import CalendarDaysIcon from '~icons/heroicons/calendar-days' import ChartBarIcon from '~icons/heroicons/chart-bar' import InformationInfo from '~icons/heroicons/information-circle' +import { invokeCapgoApi } from '~/services/capgoApi' import { bytesToGb, getDaysBetweenDates } from '~/services/conversion' import { CHART_REFRESH_POLL_MS, @@ -25,7 +26,7 @@ import { } from '~/services/dashboardRefresh' import { formatLocalDate, formatLocalDateTime, formatUtcDateTimeAsLocal } from '~/services/date' import { DEMO_APP_NAMES, generateDemoBandwidthData, generateDemoMauData, generateDemoStorageData } from '~/services/demoChartData' -import { getPlans, useSupabase } from '~/services/supabase' +import { getPlans } from '~/services/supabase' import { useDashboardAppsStore } from '~/stores/dashboardApps' import { useDialogV2Store } from '~/stores/dialogv2' import { useMainStore } from '~/stores/main' @@ -479,9 +480,8 @@ async function getAppStats(rangeStart: Date, rangeEnd: Date) { } } - const supabase = useSupabase() const dateRange = `?from=${rangeStart.toISOString()}&to=${rangeEnd.toISOString()}&noAccumulate=true` - const response = await supabase.functions.invoke(`statistics/app/${props.appId}/${dateRange}`, { + const response = await invokeCapgoApi(`statistics/app/${props.appId}${dateRange}`, { method: 'GET', }) diff --git a/src/components/permissions/ChannelAccessPanel.vue b/src/components/permissions/ChannelAccessPanel.vue index 4dcef10bc2..8010eb43ce 100644 --- a/src/components/permissions/ChannelAccessPanel.vue +++ b/src/components/permissions/ChannelAccessPanel.vue @@ -5,6 +5,7 @@ import { toast } from 'vue-sonner' import IconPlus from '~icons/heroicons/plus' import IconTrash from '~icons/heroicons/trash' import ChannelPermissionOverridesPanel from '~/components/permissions/ChannelPermissionOverridesPanel.vue' +import { invokeCapgoApi } from '~/services/capgoApi' import { useSupabase } from '~/services/supabase' import { getRbacRoleI18nKey } from '~/stores/organization' import { getErrorCode, getErrorMessage } from '~/utils/errors' @@ -144,7 +145,7 @@ async function loadChannelAccess() { channels.value = (channelsResult.data || []) as ChannelSummary[] channelRoles.value = (rolesResult.data || []) as Role[] - const { data, error } = await supabase.functions.invoke(`private/role_bindings/app/${props.appUuid}/channel`, { + const { data, error } = await invokeCapgoApi(`private/role_bindings/app/${props.appUuid}/channel`, { method: 'GET', }) @@ -180,7 +181,7 @@ async function addChannelRole() { isSaving.value = true try { - const { error } = await supabase.functions.invoke('private/role_bindings', { + const { error } = await invokeCapgoApi('private/role_bindings', { method: 'POST', body: { principal_type: props.principalType, @@ -224,7 +225,7 @@ async function updateChannelRole(binding: ChannelRoleBinding, event: Event) { isSaving.value = true try { - const { error } = await supabase.functions.invoke(`private/role_bindings/${binding.id}`, { + const { error } = await invokeCapgoApi(`private/role_bindings/${binding.id}`, { method: 'PATCH', body: { role_name: role.name }, }) @@ -251,7 +252,7 @@ async function removeChannelRole(binding: ChannelRoleBinding) { isSaving.value = true try { - const { error } = await supabase.functions.invoke(`private/role_bindings/${binding.id}`, { + const { error } = await invokeCapgoApi(`private/role_bindings/${binding.id}`, { method: 'DELETE', }) diff --git a/src/components/tables/AccessTable.vue b/src/components/tables/AccessTable.vue index 457bb7f958..b3c19e2805 100644 --- a/src/components/tables/AccessTable.vue +++ b/src/components/tables/AccessTable.vue @@ -9,6 +9,7 @@ import IconShield from '~icons/heroicons/shield-check' import IconTrash from '~icons/heroicons/trash' import IconWrench from '~icons/heroicons/wrench' import ChannelAccessPanel from '~/components/permissions/ChannelAccessPanel.vue' +import { invokeCapgoApi } from '~/services/capgoApi' import { formatDate } from '~/services/date' import { checkPermissions } from '~/services/permissions' import { useSupabase } from '~/services/supabase' @@ -185,7 +186,7 @@ async function loadAccessReferenceData() { const nextPrincipalOptions: PrincipalOption[] = [] if (canUpdateUserRoles.value) { - const { data: principals, error: principalsError } = await supabase.functions.invoke(`private/role_bindings/app/${app.value.id}/principals`, { method: 'GET' }) + const { data: principals, error: principalsError } = await invokeCapgoApi(`private/role_bindings/app/${app.value.id}/principals`, { method: 'GET' }) if (principalsError) { console.error('Error loading assignable principals for app access:', principalsError) @@ -265,9 +266,7 @@ async function fetchData() { if (error) throw error - const { data: channelBindings, error: channelBindingsError } = await supabase - .functions - .invoke(`private/role_bindings/app/${app.value.id}/channel`, { method: 'GET' }) + const { data: channelBindings, error: channelBindingsError } = await invokeCapgoApi(`private/role_bindings/app/${app.value.id}/channel`, { method: 'GET' }) if (channelBindingsError) throw channelBindingsError @@ -367,7 +366,7 @@ async function assignAccessRole() { return false } - const { error } = await supabase.functions.invoke('private/role_bindings', { + const { error } = await invokeCapgoApi('private/role_bindings', { method: 'POST', body: { principal_type: assignAccessForm.value.principal_type, @@ -457,7 +456,7 @@ async function changeUserRole(element: Element) { isLoading.value = true try { - const { error: updateError } = await supabase.functions.invoke(`private/role_bindings/${element.id}`, { + const { error: updateError } = await invokeCapgoApi(`private/role_bindings/${element.id}`, { method: 'PATCH', body: { role_name: newRoleName }, }) @@ -499,7 +498,7 @@ async function deleteElement(element: Element) { isLoading.value = true try { - const { error } = await supabase.functions.invoke(`private/role_bindings/${element.id}`, { + const { error } = await invokeCapgoApi(`private/role_bindings/${element.id}`, { method: 'DELETE', }) diff --git a/src/pages/ApiKeys.vue b/src/pages/ApiKeys.vue index 3dfd75ff39..b3a3ac35dc 100644 --- a/src/pages/ApiKeys.vue +++ b/src/pages/ApiKeys.vue @@ -23,6 +23,7 @@ import { showApiKeySecretModal, sortApiKeyRows, } from '~/services/apikeys' +import { invokeCapgoApi } from '~/services/capgoApi' import { formatLocalDate } from '~/services/date' import { isNativeAppStoreContext } from '~/services/nativeCompliance' import { checkPermissions } from '~/services/permissions' @@ -881,7 +882,7 @@ async function refreshData() { async function getKeys(retry = true): Promise { isLoading.value = true - const { data, error } = await supabase.functions.invoke('apikey', { + const { data, error } = await invokeCapgoApi('apikey', { method: 'GET', }) if (error) { @@ -1042,7 +1043,7 @@ async function createApiKey() { let plainKeyForDisplay: string | null = null - const { data, error } = await supabase.functions.invoke('apikey', { + const { data, error } = await invokeCapgoApi('apikey', { method: 'POST', body: { name: newApiKeyName.value.trim(), @@ -1252,7 +1253,7 @@ async function updateApiKey() { if (setExpirationCheckbox.value && expirationDate.value) expiresAt = dayjs(expirationDate.value).toISOString() - const { data, error } = await supabase.functions.invoke('apikey', { + const { data, error } = await invokeCapgoApi('apikey', { method: 'PUT', body: { id: key.id, @@ -1298,7 +1299,7 @@ async function regenrateKey(apikey: Database['public']['Tables']['apikeys']['Row const wasHashed = isHashedKey(apikey) - const { data, error } = await supabase.functions.invoke('apikey', { + const { data, error } = await invokeCapgoApi('apikey', { method: 'PUT', body: { id: apikey.id, diff --git a/src/pages/app/[app].channel.[channel].devices.vue b/src/pages/app/[app].channel.[channel].devices.vue index f8bb627983..ca0ebebb0f 100644 --- a/src/pages/app/[app].channel.[channel].devices.vue +++ b/src/pages/app/[app].channel.[channel].devices.vue @@ -8,6 +8,7 @@ import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' import plusOutline from '~icons/ion/add-outline' import IconAlertCircle from '~icons/lucide/alert-circle' +import { invokeCapgoApi } from '~/services/capgoApi' import { defaultApiHost, useSupabase } from '~/services/supabase' import { withBuiltinChannelVersion } from '~/services/versions' import { useAppDetailStore } from '~/stores/appDetail' @@ -147,7 +148,7 @@ async function customDeviceOverwritePart5( return } - const { error: addDeviceError } = await supabase.functions.invoke('private/create_device', { + const { error: addDeviceError } = await invokeCapgoApi('private/create_device', { body: { device_id: deviceId, app_id: route.params.app as string, diff --git a/src/pages/invitation.vue b/src/pages/invitation.vue index ef460a01ae..92a8221249 100644 --- a/src/pages/invitation.vue +++ b/src/pages/invitation.vue @@ -9,6 +9,7 @@ import IconShield from '~icons/lucide/shield-check' import IconX from '~icons/lucide/x' import { authGhostButtonClass, authInsetCardClass, authPrimaryButtonClass, authSecondaryButtonClass } from '~/components/auth/pageStyles' import Toggle from '~/components/Toggle.vue' +import { invokeCapgoApi } from '~/services/capgoApi' import { useSupabase } from '~/services/supabase' import { openSupport } from '~/services/support' @@ -26,8 +27,6 @@ const inviteRow = ref -const supabase = useSupabase() - // Terms and marketing acceptance const acceptTerms = ref(false) const acceptMarketing = ref(true) @@ -123,7 +122,8 @@ async function submitForm() { isLoading.value = true // Call the backend API to accept the invitation using Supabase Functions - const { data, error } = await supabase.functions.invoke('private/accept_invitation', { + const { data, error } = await invokeCapgoApi('private/accept_invitation', { + allowAnonymous: true, body: { password: password.value, magic_invite_string: inviteMagicString.value, diff --git a/src/pages/onboarding/organization.vue b/src/pages/onboarding/organization.vue index 101286abd4..6463ce8aeb 100644 --- a/src/pages/onboarding/organization.vue +++ b/src/pages/onboarding/organization.vue @@ -19,6 +19,7 @@ import IconUserPlus from '~icons/lucide/user-plus' import IconUsers from '~icons/lucide/users-round' import IconBack from '~icons/material-symbols/arrow-back-ios-rounded' import InviteTeammateModal from '~/components/dashboard/InviteTeammateModal.vue' +import { getCapgoApiErrorCode, invokeCapgoApi } from '~/services/capgoApi' import { formatNumberValue } from '~/services/formatLocale' import { createOnboardingAppFromDraft } from '~/services/onboardingAppCreate' import { uploadOrgLogoFile } from '~/services/photos' @@ -358,7 +359,7 @@ async function fetchWebsitePreview() { isLoadingWebsitePreview.value = true try { - const { data, error } = await supabase.functions.invoke('private/website_preview', { + const { data, error } = await invokeCapgoApi('private/website_preview', { body: { website: websiteInput.value.trim(), }, @@ -415,7 +416,7 @@ async function createOrganization() { ? websitePreview.value?.website : undefined - const { data, error } = await supabase.functions.invoke('organization', { + const { data, error } = await invokeCapgoApi('organization', { method: 'POST', body: { name: orgName, @@ -428,7 +429,8 @@ async function createOrganization() { if (error || !data?.id) { console.error('Error creating organization during onboarding', error) - toast.error(error?.code === '23505' + const errorCode = await getCapgoApiErrorCode(error) + toast.error(errorCode === '23505' ? t('org-with-this-name-exists') : t('cannot-create-org')) return diff --git a/src/pages/settings/account/ChangePassword.vue b/src/pages/settings/account/ChangePassword.vue index 6b55561d02..f76a4e2aac 100644 --- a/src/pages/settings/account/ChangePassword.vue +++ b/src/pages/settings/account/ChangePassword.vue @@ -1,11 +1,13 @@