From 9be34415bb2d4c8f0d22159a49d2f7277dda3f1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:08:27 +0000 Subject: [PATCH 1/5] Initial plan From 4082e6ac72b34c8849b877396a75a022a7da9dbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:17:16 +0000 Subject: [PATCH 2/5] Add default channel validation and tests Co-authored-by: riderx <4084527+riderx@users.noreply.github.com> --- .../functions/_backend/public/channel/post.ts | 2 + supabase/functions/_backend/utils/supabase.ts | 109 +++++ tests/channel_default_validation.test.ts | 447 ++++++++++++++++++ 3 files changed, 558 insertions(+) create mode 100644 tests/channel_default_validation.test.ts diff --git a/supabase/functions/_backend/public/channel/post.ts b/supabase/functions/_backend/public/channel/post.ts index b01fa24087..005b216708 100644 --- a/supabase/functions/_backend/public/channel/post.ts +++ b/supabase/functions/_backend/public/channel/post.ts @@ -14,6 +14,7 @@ interface ChannelSet { disableAutoUpdate?: Database['public']['Enums']['disable_update'] ios?: boolean android?: boolean + electron?: boolean allow_device_self_set?: boolean allow_emulator?: boolean allow_device?: boolean @@ -65,6 +66,7 @@ export async function post(c: Context, body: ChannelSet, apikey: Database['publi ...(body.allow_prod == null ? {} : { allow_prod: body.allow_prod }), ...(body.ios == null ? {} : { ios: body.ios }), ...(body.android == null ? {} : { android: body.android }), + ...(body.electron == null ? {} : { electron: body.electron }), version: -1, owner_org: org.owner_org, } diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 37ab902754..9c5ab5169f 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -128,6 +128,104 @@ export async function getAppsFromSB(c: Context): Promise { return apps } +/** + * Validates public channel constraints: + * - Maximum 3 public channels per app + * - Maximum 1 public channel per platform (iOS, Android, Electron) + */ +async function validatePublicChannels( + c: Context, + appId: string, + channelName: string, + isPublic: boolean | undefined, + ios: boolean | undefined, + android: boolean | undefined, + electron: boolean | undefined, +) { + // Only validate if the channel is being set to public + if (!isPublic) { + return + } + + // Get all existing public channels for this app (excluding the current channel being updated) + const { data: publicChannels, error } = await supabaseAdmin(c) + .from('channels') + .select('id, name, ios, android, electron') + .eq('app_id', appId) + .eq('public', true) + .neq('name', channelName) + + if (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'Error fetching public channels', error }) + throw simpleError('db_error', 'Failed to validate public channels') + } + + const existingPublicChannels = publicChannels || [] + + // Rule 1: Maximum 3 public channels per app + if (existingPublicChannels.length >= 3) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Maximum 3 public channels allowed per app', + appId, + existingCount: existingPublicChannels.length, + }) + throw simpleError( + 'max_public_channels', + 'Maximum 3 public channels allowed per app. You can have one default channel for all platforms or up to three (one per platform: iOS, Android, Electron).', + ) + } + + // Rule 2: Maximum 1 public channel per platform + // Determine which platforms this channel will support + const newChannelIos = ios !== false // Default to true if not specified + const newChannelAndroid = android !== false // Default to true if not specified + const newChannelElectron = electron !== false // Default to true if not specified + + // Check for platform conflicts + for (const existingChannel of existingPublicChannels) { + if (newChannelIos && existingChannel.ios) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Platform conflict: iOS', + appId, + existingChannel: existingChannel.name, + newChannel: channelName, + }) + throw simpleError( + 'duplicate_platform_ios', + `Another public channel "${existingChannel.name}" already supports iOS platform. Only one public channel per platform is allowed.`, + ) + } + if (newChannelAndroid && existingChannel.android) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Platform conflict: Android', + appId, + existingChannel: existingChannel.name, + newChannel: channelName, + }) + throw simpleError( + 'duplicate_platform_android', + `Another public channel "${existingChannel.name}" already supports Android platform. Only one public channel per platform is allowed.`, + ) + } + if (newChannelElectron && existingChannel.electron) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Platform conflict: Electron', + appId, + existingChannel: existingChannel.name, + newChannel: channelName, + }) + throw simpleError( + 'duplicate_platform_electron', + `Another public channel "${existingChannel.name}" already supports Electron platform. Only one public channel per platform is allowed.`, + ) + } + } +} + export async function updateOrCreateChannel(c: Context, update: Database['public']['Tables']['channels']['Insert']) { cloudlog({ requestId: c.get('requestId'), message: 'updateOrCreateChannel', update }) if (!update.app_id || !update.name || !update.created_by) { @@ -153,6 +251,17 @@ export async function updateOrCreateChannel(c: Context, update: Database['public } } + // Validate public channel constraints before upserting + await validatePublicChannels( + c, + update.app_id, + update.name, + update.public, + update.ios, + update.android, + update.electron, + ) + return supabaseAdmin(c) .from('channels') .upsert(update, { onConflict: 'app_id, name' }) diff --git a/tests/channel_default_validation.test.ts b/tests/channel_default_validation.test.ts new file mode 100644 index 0000000000..0f8aaa4c50 --- /dev/null +++ b/tests/channel_default_validation.test.ts @@ -0,0 +1,447 @@ +import { randomUUID } from 'node:crypto' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { BASE_URL, headers, resetAndSeedAppData, resetAppData, getSupabaseClient, ORG_ID, USER_ID } from './test-utils.ts' + +const id = randomUUID() +const APPNAME = `com.app.default.channel.${id}` + +beforeAll(async () => { + await resetAndSeedAppData(APPNAME) +}) + +afterAll(async () => { + await resetAppData(APPNAME) +}) + +describe('Default Channel Validation Tests', () => { + describe('Valid configurations', () => { + it('should allow one public channel with all platforms enabled', async () => { + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME, + channel: 'default_all_platforms', + public: true, + ios: true, + android: true, + electron: true, + }), + }) + + const data = await response.json<{ status: string }>() + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + + // Verify the channel was created + const { data: channel } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('app_id', APPNAME) + .eq('name', 'default_all_platforms') + .single() + + expect(channel).toBeTruthy() + expect(channel?.public).toBe(true) + expect(channel?.ios).toBe(true) + expect(channel?.android).toBe(true) + expect(channel?.electron).toBe(true) + }) + + it('should allow three public channels with one platform each', async () => { + const id2 = randomUUID() + const APPNAME2 = `com.app.three.channels.${id2}` + await resetAndSeedAppData(APPNAME2) + + // Create iOS-only public channel + const response1 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME2, + channel: 'ios_only', + public: true, + ios: true, + android: false, + electron: false, + }), + }) + expect(response1.status).toBe(200) + + // Create Android-only public channel + const response2 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME2, + channel: 'android_only', + public: true, + ios: false, + android: true, + electron: false, + }), + }) + expect(response2.status).toBe(200) + + // Create Electron-only public channel + const response3 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME2, + channel: 'electron_only', + public: true, + ios: false, + android: false, + electron: true, + }), + }) + expect(response3.status).toBe(200) + + // Verify all three channels exist + const { data: channels } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('app_id', APPNAME2) + .eq('public', true) + + expect(channels?.length).toBeGreaterThanOrEqual(3) + + await resetAppData(APPNAME2) + }) + + it('should allow combination of platform-specific public channels', async () => { + const id3 = randomUUID() + const APPNAME3 = `com.app.combo.${id3}` + await resetAndSeedAppData(APPNAME3) + + // Create iOS + Android public channel + const response1 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME3, + channel: 'ios_android', + public: true, + ios: true, + android: true, + electron: false, + }), + }) + expect(response1.status).toBe(200) + + // Create Electron-only public channel + const response2 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME3, + channel: 'electron_only', + public: true, + ios: false, + android: false, + electron: true, + }), + }) + expect(response2.status).toBe(200) + + // Verify both channels exist + const { data: channels } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('app_id', APPNAME3) + .eq('public', true) + + expect(channels?.length).toBeGreaterThanOrEqual(2) + + await resetAppData(APPNAME3) + }) + }) + + describe('Invalid configurations - Maximum channel limit', () => { + it('should reject creating a 4th public channel', async () => { + const id4 = randomUUID() + const APPNAME4 = `com.app.four.channels.${id4}` + await resetAndSeedAppData(APPNAME4) + + // Create 3 public channels first + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME4, + channel: 'channel1', + public: true, + ios: true, + android: false, + electron: false, + }), + }) + + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME4, + channel: 'channel2', + public: true, + ios: false, + android: true, + electron: false, + }), + }) + + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME4, + channel: 'channel3', + public: true, + ios: false, + android: false, + electron: true, + }), + }) + + // Attempt to create a 4th public channel should fail + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME4, + channel: 'channel4', + public: true, + ios: true, + android: false, + electron: false, + }), + }) + + expect(response.status).toBe(400) + const errorData = await response.json<{ error: string, message: string }>() + expect(errorData.error).toBe('max_public_channels') + expect(errorData.message).toContain('Maximum 3 public channels') + + await resetAppData(APPNAME4) + }) + }) + + describe('Invalid configurations - Platform duplicates', () => { + it('should reject creating 2 public channels both with iOS enabled', async () => { + const id5 = randomUUID() + const APPNAME5 = `com.app.duplicate.ios.${id5}` + await resetAndSeedAppData(APPNAME5) + + // Create first iOS public channel + const response1 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME5, + channel: 'ios_channel1', + public: true, + ios: true, + android: false, + electron: false, + }), + }) + expect(response1.status).toBe(200) + + // Attempt to create second iOS public channel should fail + const response2 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME5, + channel: 'ios_channel2', + public: true, + ios: true, + android: false, + electron: false, + }), + }) + + expect(response2.status).toBe(400) + const errorData = await response2.json<{ error: string, message: string }>() + expect(errorData.error).toBe('duplicate_platform_ios') + expect(errorData.message).toContain('already supports iOS platform') + + await resetAppData(APPNAME5) + }) + + it('should reject creating 2 public channels both with Android enabled', async () => { + const id6 = randomUUID() + const APPNAME6 = `com.app.duplicate.android.${id6}` + await resetAndSeedAppData(APPNAME6) + + // Create first Android public channel + const response1 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME6, + channel: 'android_channel1', + public: true, + ios: false, + android: true, + electron: false, + }), + }) + expect(response1.status).toBe(200) + + // Attempt to create second Android public channel should fail + const response2 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME6, + channel: 'android_channel2', + public: true, + ios: false, + android: true, + electron: false, + }), + }) + + expect(response2.status).toBe(400) + const errorData = await response2.json<{ error: string, message: string }>() + expect(errorData.error).toBe('duplicate_platform_android') + expect(errorData.message).toContain('already supports Android platform') + + await resetAppData(APPNAME6) + }) + + it('should reject creating 2 public channels both with Electron enabled', async () => { + const id7 = randomUUID() + const APPNAME7 = `com.app.duplicate.electron.${id7}` + await resetAndSeedAppData(APPNAME7) + + // Create first Electron public channel + const response1 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME7, + channel: 'electron_channel1', + public: true, + ios: false, + android: false, + electron: true, + }), + }) + expect(response1.status).toBe(200) + + // Attempt to create second Electron public channel should fail + const response2 = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME7, + channel: 'electron_channel2', + public: true, + ios: false, + android: false, + electron: true, + }), + }) + + expect(response2.status).toBe(400) + const errorData = await response2.json<{ error: string, message: string }>() + expect(errorData.error).toBe('duplicate_platform_electron') + expect(errorData.message).toContain('already supports Electron platform') + + await resetAppData(APPNAME7) + }) + + it('should reject updating an existing public channel to conflict with another', async () => { + const id8 = randomUUID() + const APPNAME8 = `com.app.update.conflict.${id8}` + await resetAndSeedAppData(APPNAME8) + + // Create iOS-only public channel + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME8, + channel: 'ios_only', + public: true, + ios: true, + android: false, + electron: false, + }), + }) + + // Create Android-only public channel + await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME8, + channel: 'android_only', + public: true, + ios: false, + android: true, + electron: false, + }), + }) + + // Attempt to update Android channel to also enable iOS (conflict) + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME8, + channel: 'android_only', + public: true, + ios: true, // This would conflict + android: true, + electron: false, + }), + }) + + expect(response.status).toBe(400) + const errorData = await response.json<{ error: string, message: string }>() + expect(errorData.error).toBe('duplicate_platform_ios') + + await resetAppData(APPNAME8) + }) + }) + + describe('Private channels should not be affected', () => { + it('should allow multiple private channels regardless of platform', async () => { + const id9 = randomUUID() + const APPNAME9 = `com.app.private.${id9}` + await resetAndSeedAppData(APPNAME9) + + // Create multiple private channels with same platforms - should all succeed + for (let i = 0; i < 5; i++) { + const response = await fetch(`${BASE_URL}/channel`, { + method: 'POST', + headers, + body: JSON.stringify({ + app_id: APPNAME9, + channel: `private_channel_${i}`, + public: false, + ios: true, + android: true, + electron: true, + }), + }) + expect(response.status).toBe(200) + } + + // Verify all 5+ private channels exist (including seeded ones) + const { data: channels } = await getSupabaseClient() + .from('channels') + .select('*') + .eq('app_id', APPNAME9) + .eq('public', false) + + expect(channels?.length).toBeGreaterThanOrEqual(5) + + await resetAppData(APPNAME9) + }) + }) +}) From 7a9c730d3d57f5014a907d94c9ccb113cf4dee04 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:19:19 +0000 Subject: [PATCH 3/5] Fix validation to handle channel updates correctly Co-authored-by: riderx <4084527+riderx@users.noreply.github.com> --- supabase/functions/_backend/utils/supabase.ts | 34 ++++++++++--------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 9c5ab5169f..8345f8566d 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -137,10 +137,10 @@ async function validatePublicChannels( c: Context, appId: string, channelName: string, - isPublic: boolean | undefined, - ios: boolean | undefined, - android: boolean | undefined, - electron: boolean | undefined, + isPublic: boolean, + ios: boolean, + android: boolean, + electron: boolean, ) { // Only validate if the channel is being set to public if (!isPublic) { @@ -177,14 +177,9 @@ async function validatePublicChannels( } // Rule 2: Maximum 1 public channel per platform - // Determine which platforms this channel will support - const newChannelIos = ios !== false // Default to true if not specified - const newChannelAndroid = android !== false // Default to true if not specified - const newChannelElectron = electron !== false // Default to true if not specified - // Check for platform conflicts for (const existingChannel of existingPublicChannels) { - if (newChannelIos && existingChannel.ios) { + if (ios && existingChannel.ios) { cloudlogErr({ requestId: c.get('requestId'), message: 'Platform conflict: iOS', @@ -197,7 +192,7 @@ async function validatePublicChannels( `Another public channel "${existingChannel.name}" already supports iOS platform. Only one public channel per platform is allowed.`, ) } - if (newChannelAndroid && existingChannel.android) { + if (android && existingChannel.android) { cloudlogErr({ requestId: c.get('requestId'), message: 'Platform conflict: Android', @@ -210,7 +205,7 @@ async function validatePublicChannels( `Another public channel "${existingChannel.name}" already supports Android platform. Only one public channel per platform is allowed.`, ) } - if (newChannelElectron && existingChannel.electron) { + if (electron && existingChannel.electron) { cloudlogErr({ requestId: c.get('requestId'), message: 'Platform conflict: Electron', @@ -252,14 +247,21 @@ export async function updateOrCreateChannel(c: Context, update: Database['public } // Validate public channel constraints before upserting + // For validation, use the final values that will be in the database after the update + // If a field is not provided in the update, use the existing value (or default for new channels) + const finalPublic = update.public !== undefined ? update.public : (existingChannel?.public ?? false) + const finalIos = update.ios !== undefined ? update.ios : (existingChannel?.ios ?? true) + const finalAndroid = update.android !== undefined ? update.android : (existingChannel?.android ?? true) + const finalElectron = update.electron !== undefined ? update.electron : (existingChannel?.electron ?? true) + await validatePublicChannels( c, update.app_id, update.name, - update.public, - update.ios, - update.android, - update.electron, + finalPublic, + finalIos, + finalAndroid, + finalElectron, ) return supabaseAdmin(c) From bf1a057cc55a8a30b3b442f10da7af40811142a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:23:22 +0000 Subject: [PATCH 4/5] Add documentation for default channel validation rules Co-authored-by: riderx <4084527+riderx@users.noreply.github.com> --- docs/DEFAULT_CHANNEL_VALIDATION.md | 207 +++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 docs/DEFAULT_CHANNEL_VALIDATION.md diff --git a/docs/DEFAULT_CHANNEL_VALIDATION.md b/docs/DEFAULT_CHANNEL_VALIDATION.md new file mode 100644 index 0000000000..cb914f24d5 --- /dev/null +++ b/docs/DEFAULT_CHANNEL_VALIDATION.md @@ -0,0 +1,207 @@ +# Default Channel Validation Rules + +## Overview + +Default channels (public channels) in Capgo have specific validation rules to ensure proper platform distribution and prevent conflicts. + +## Rules + +### 1. Maximum 3 Public Channels Per App + +An application can have a maximum of **3 public (default) channels**. This allows for: +- One default channel serving all platforms (iOS, Android, Electron) +- OR up to three platform-specific default channels (one per platform) +- OR any combination in between (e.g., one for iOS+Android, another for Electron) + +### 2. One Public Channel Per Platform + +Each platform (iOS, Android, Electron) can only be enabled in **one public channel** at a time. This prevents ambiguity when devices query for their default channel. + +## Valid Configurations + +### Single Default Channel (All Platforms) +```json +{ + "name": "production", + "public": true, + "ios": true, + "android": true, + "electron": true +} +``` + +### Three Platform-Specific Channels +```json +[ + { + "name": "ios-production", + "public": true, + "ios": true, + "android": false, + "electron": false + }, + { + "name": "android-production", + "public": true, + "ios": false, + "android": true, + "electron": false + }, + { + "name": "electron-production", + "public": true, + "ios": false, + "android": false, + "electron": true + } +] +``` + +### Mixed Configuration +```json +[ + { + "name": "mobile-production", + "public": true, + "ios": true, + "android": true, + "electron": false + }, + { + "name": "desktop-production", + "public": true, + "ios": false, + "android": false, + "electron": true + } +] +``` + +## Invalid Configurations + +### ❌ More Than 3 Public Channels +```json +// This will fail - 4 public channels +[ + {"name": "channel1", "public": true, "ios": true, ...}, + {"name": "channel2", "public": true, "android": true, ...}, + {"name": "channel3", "public": true, "electron": true, ...}, + {"name": "channel4", "public": true, ...} // ❌ Exceeds limit +] +``` + +### ❌ Duplicate Platform in Public Channels +```json +// This will fail - iOS enabled in two public channels +[ + { + "name": "ios-prod", + "public": true, + "ios": true, // ✓ + "android": false, + "electron": false + }, + { + "name": "ios-beta", + "public": true, + "ios": true, // ❌ Conflict + "android": false, + "electron": false + } +] +``` + +## Error Messages + +### Max Public Channels Error +```json +{ + "error": "max_public_channels", + "message": "Maximum 3 public channels allowed per app. You can have one default channel for all platforms or up to three (one per platform: iOS, Android, Electron)." +} +``` + +### Platform Duplicate Errors +```json +{ + "error": "duplicate_platform_ios", + "message": "Another public channel \"ios-prod\" already supports iOS platform. Only one public channel per platform is allowed." +} +``` + +```json +{ + "error": "duplicate_platform_android", + "message": "Another public channel \"android-prod\" already supports Android platform. Only one public channel per platform is allowed." +} +``` + +```json +{ + "error": "duplicate_platform_electron", + "message": "Another public channel \"electron-prod\" already supports Electron platform. Only one public channel per platform is allowed." +} +``` + +## Implementation Details + +### Backend Validation + +Validation is implemented in `supabase/functions/_backend/utils/supabase.ts` in the `updateOrCreateChannel` function. It runs before any channel creation or update operation. + +### When Validation Runs + +- Creating a new public channel +- Updating an existing channel to be public +- Updating an existing public channel's platform settings + +### Private Channels + +**Important:** These validation rules only apply to public channels (`public: true`). Private channels can be created without these restrictions. + +## Testing + +Comprehensive test coverage is provided in `tests/channel_default_validation.test.ts`, including: + +- ✓ Valid configurations (single all-platform, three platform-specific) +- ✓ Invalid configurations (exceeding limits, platform conflicts) +- ✓ Update scenarios (changing existing channels) +- ✓ Private channel independence (not affected by rules) + +## API Usage + +### Creating a Public Channel + +```bash +POST /channel +{ + "app_id": "com.example.app", + "channel": "production", + "public": true, + "ios": true, + "android": true, + "electron": true +} +``` + +### Setting Platform-Specific Channels + +```bash +# iOS-only public channel +POST /channel +{ + "app_id": "com.example.app", + "channel": "ios-production", + "public": true, + "ios": true, + "android": false, + "electron": false +} +``` + +## Migration Notes + +For existing applications with multiple public channels that may violate these rules: +1. The validation is enforced on channel creation/update only +2. Existing channels are grandfathered until modified +3. Update channels to comply with the rules before making changes From e78e10f5c80cda2ef7f561eefc30b04fd6e97a38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 04:24:58 +0000 Subject: [PATCH 5/5] Extract default channel values to named constants Co-authored-by: riderx <4084527+riderx@users.noreply.github.com> --- supabase/functions/_backend/utils/supabase.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/supabase/functions/_backend/utils/supabase.ts b/supabase/functions/_backend/utils/supabase.ts index 8345f8566d..8aa353aee2 100644 --- a/supabase/functions/_backend/utils/supabase.ts +++ b/supabase/functions/_backend/utils/supabase.ts @@ -249,10 +249,16 @@ export async function updateOrCreateChannel(c: Context, update: Database['public // Validate public channel constraints before upserting // For validation, use the final values that will be in the database after the update // If a field is not provided in the update, use the existing value (or default for new channels) - const finalPublic = update.public !== undefined ? update.public : (existingChannel?.public ?? false) - const finalIos = update.ios !== undefined ? update.ios : (existingChannel?.ios ?? true) - const finalAndroid = update.android !== undefined ? update.android : (existingChannel?.android ?? true) - const finalElectron = update.electron !== undefined ? update.electron : (existingChannel?.electron ?? true) + // Default values for new channels match the database schema defaults + const DEFAULT_CHANNEL_PUBLIC = false + const DEFAULT_CHANNEL_IOS = true + const DEFAULT_CHANNEL_ANDROID = true + const DEFAULT_CHANNEL_ELECTRON = true + + const finalPublic = update.public !== undefined ? update.public : (existingChannel?.public ?? DEFAULT_CHANNEL_PUBLIC) + const finalIos = update.ios !== undefined ? update.ios : (existingChannel?.ios ?? DEFAULT_CHANNEL_IOS) + const finalAndroid = update.android !== undefined ? update.android : (existingChannel?.android ?? DEFAULT_CHANNEL_ANDROID) + const finalElectron = update.electron !== undefined ? update.electron : (existingChannel?.electron ?? DEFAULT_CHANNEL_ELECTRON) await validatePublicChannels( c,