Skip to content
Draft
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
207 changes: 207 additions & 0 deletions docs/DEFAULT_CHANNEL_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions supabase/functions/_backend/public/channel/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down
117 changes: 117 additions & 0 deletions supabase/functions/_backend/utils/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,99 @@ export async function getAppsFromSB(c: Context): Promise<string[]> {
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,
ios: boolean,
android: boolean,
electron: boolean,
) {
// 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
// Check for platform conflicts
for (const existingChannel of existingPublicChannels) {
if (ios && 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 (android && 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 (electron && 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) {
Expand All @@ -153,6 +246,30 @@ 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)
// 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,
update.app_id,
update.name,
finalPublic,
finalIos,
finalAndroid,
finalElectron,
)

return supabaseAdmin(c)
.from('channels')
.upsert(update, { onConflict: 'app_id, name' })
Expand Down
Loading
Loading