Channel Connections & OAuth Scopes
Channel connections are the mechanism by which Lumio accesses platform APIs on behalf of a user's channel. Each connection stores encrypted OAuth tokens and the scopes that were granted during authorization.
One Connection Per Platform
Each account can have one channel connection per platform. This is enforced by a UNIQUE (account_id, platform) constraint on the channel_connections table. app_credentials and bot_connections carry the same constraint.
Platforms with a channel-connection OAuth config (get_oauth_config()): Twitch, YouTube, Kick, Trovo, Spotify. Discord and Shopify are known platforms too, but they have no channel_connections surface -- Discord is login + bot + integration, Shopify is integration only (PLATFORMS in apps/api/src/platforms.rs).
Credential Source by Platform
Channel connections are not all issued by the same OAuth app source. CredentialResolver chooses the source for a new connect, and the resulting value is stored on channel_connections.credential_source:
| Platform | Channel default | Notes |
|---|---|---|
| Twitch | system | Uses twitch_channel_client_id / twitch_channel_client_secret when set; may fall back to the Twitch login pair because Twitch supports multiple redirect URIs per app. |
| Kick | system | Requires kick_channel_client_id / kick_channel_client_secret; no login-key fallback because Kick apps accept one redirect URI. |
| Trovo | system | Requires trovo_channel_client_id / trovo_channel_client_secret; no login-key fallback because Trovo apps accept one redirect URI. |
| YouTube | account | BYO Google Cloud OAuth client; quota, verification, and the 7-day Testing refresh-token rule stay with the customer app. |
| Spotify | account | BYO Spotify app; Spotify Development Mode limits and registered-user rules stay with the customer app. |
If a system platform has no global key pair configured, new connects fail safe to account and use the legacy per-account app_credentials path. Existing rows keep refreshing with whichever source minted them; changing [platform_credentials] affects only future connects.
OAuth Scopes by Platform
Scopes are defined statically in apps/api/src/platforms.rs (get_oauth_config()). They are requested during the authorization flow and cannot be changed after the connection is established. To change scopes, the user must disconnect and reconnect.
Twitch (27 scopes)
| Scope | Purpose |
|---|---|
channel:read:subscriptions | Read subscriber list and events |
channel:read:redemptions | Read channel point redemptions |
channel:manage:redemptions | Manage channel point redemptions |
channel:read:hype_train | Read hype train events |
channel:read:polls | Read poll data |
channel:manage:polls | Create and manage polls |
channel:read:predictions | Read prediction data |
channel:manage:predictions | Create and manage predictions |
channel:read:goals | Read channel goals |
bits:read | Read cheer/bits events |
moderator:read:followers | Read follower list (used by ProfileService) |
moderator:read:suspicious_users | Read suspicious user flags |
moderator:manage:suspicious_users | Update suspicious user treatment |
moderator:manage:banned_users | Ban and unban users |
channel:bot | Send chat messages as the channel bot |
user:read:chat | Read chat messages |
channel:read:ads | Read ad schedule |
channel:manage:raids | Start and cancel raids |
channel:moderate | Moderate chat (delete, timeout) |
moderator:read:blocked_terms | Read blocked terms list |
moderator:read:chat_settings | Read chat settings |
moderator:read:unban_requests | Read unban requests |
moderator:read:banned_users | Read banned users list |
moderator:read:chat_messages | Read chat messages as moderator |
moderator:read:warnings | Read user warnings |
moderator:read:moderators | Read moderator list |
moderator:read:vips | Read VIP list |
Extra params: force_verify=true (forces re-authorization even if already granted)
YouTube (2 scopes)
| Scope | Purpose |
|---|---|
https://www.googleapis.com/auth/youtube.readonly | Read channel, video, and live chat data |
https://www.googleapis.com/auth/youtube.force-ssl | Full YouTube Data API access (chat, comments) |
Google scopes are requested as full URIs, not short names.
Extra params: access_type=offline (enables refresh tokens), prompt=consent (forces consent screen)
Channel connections use per-account BYO Google credentials — the customer's own Google Cloud OAuth client — so the customer's own consent-screen publishing status governs token lifetime. An External consent screen in publishing status "Testing" issues refresh tokens that expire after 7 days (the exception is name/email/profile-only scopes; because we request youtube.readonly + youtube.force-ssl, both sensitive, the rule always applies). When that refresh token dies the Token Refresh Worker sees invalid_grant, classifies it terminal, flags reconnect_required, and emails the owner — so in the product it looks like "YouTube keeps disconnecting every few days."
Fix (customer-side): set the OAuth client to "In production" (verification may be required for youtube.force-ssl), then reconnect once. Already-issued tokens stay 7-day-capped until reconnection. Check the OAuth client's publishing status before debugging the refresh path for a Google/YouTube "expires too fast" complaint. See Connections → Reconnect Flags.
Kick (6 scopes)
| Scope | Purpose |
|---|---|
user:read | Read user profile information |
channel:read | Read channel information |
events:read | Read channel events |
events:subscribe | Subscribe to real-time events |
chat:write | Send chat messages |
moderation:chat_message:manage | Delete chat messages |
Trovo (2 scopes)
| Scope | Purpose |
|---|---|
channel_details_self | Read own channel details |
channel_subscriptions | Read subscriber data |
Spotify (7 scopes)
| Scope | Purpose |
|---|---|
user-read-playback-state | Read current playback state |
user-modify-playback-state | Control playback (play, pause, skip) |
user-read-currently-playing | Read currently playing track |
playlist-read-private | Read private playlists |
playlist-read-collaborative | Read collaborative playlists |
playlist-modify-public | Modify public playlists |
playlist-modify-private | Modify private playlists |
OAuth Connection Flow
The complete flow from source resolution to active worker:
sequenceDiagram
participant User
participant Dashboard as Dashboard (Next.js)
participant API as API Server (Rust)
participant DB as PostgreSQL
participant Platform as Platform OAuth
Note over User,Platform: Step 1: Authorize Channel
User->>Dashboard: Click "Connect"
Dashboard->>API: Get authorize URL
API->>API: Resolve credential_source for (platform, channel)
alt system source
API->>API: Read global app from [auth] via CredentialResolver
API->>API: Mint unified state handle
else account source
API->>DB: Read encrypted app_credentials
API->>API: Mint legacy account state
end
API->>API: Build URL with scopes from get_oauth_config()
API-->>Dashboard: Redirect URL
Dashboard->>Platform: Redirect to OAuth authorize
Platform->>User: Show consent screen
User->>Platform: Approve
Platform-->>API: Callback with authorization code
Note over API,DB: Step 2: Token Exchange
API->>API: Reuse the pinned source from state
API->>Platform: Exchange code for access_token + refresh_token
Platform-->>API: Token response
API->>Platform: Fetch channel info (name, ID)
Platform-->>API: Channel details
API->>API: Encrypt tokens with AES-256-GCM
API->>DB: Upsert into channel_connections with credential_source
Note over API: Step 3: Start Worker
API->>API: Start platform worker (EventSub, chat listener, etc.)
Scope Behavior
Static Scopes
Scopes are defined at compile time in get_oauth_config(). When the authorize URL is built, all scopes for the platform are requested. There is no mechanism for requesting partial scopes.
Changing Scopes
If the required scopes change (e.g., after a Lumio update adds new features), users must:
- Disconnect the existing channel connection
- Reconnect to trigger a new OAuth flow with the updated scope list
The old tokens continue to work for their originally granted scopes, but new features requiring additional scopes will fail until reconnection.
Scope Storage
Granted scopes are stored in the scopes column of channel_connections as a nullable TEXT[] array. This records what was actually granted (which may differ from what was requested if the user denied specific scopes on platforms that support partial grants). bot_connections carries the same scopes TEXT[] column for the bot-side token.
Credential Requirements
The required OAuth app source depends on the row's pinned credential_source:
| Source | Required for refresh | Token row |
|---|---|---|
account | app_credentials for (account_id, platform) | channel_connections |
system | Global config pair from [auth] via CredentialResolver::system_pair | channel_connections |
If an account-pinned connection loses its app_credentials, refresh cannot succeed. If a system-pinned connection has no global pair at refresh time, refresh fails clearly instead of silently using a different app.
If channel_connections are deleted, no API calls can be made for that platform.
Deleting app_credentials via the API is source-aware. deleteAppCredentials deletes an account-pinned channel connection together with the credentials it depends on. A system-pinned channel connection survives because it refreshes with the global Lumio app; only the stray account credential row is removed. Use disconnectChannel to drop only the channel connection and keep account credentials.
Refresh Failure Handling
The Token Refresh Worker classifies every failed refresh (apps/api/src/oauth.rs, classify_refresh_response → RefreshError):
- Terminal (
invalid_grant/unauthorized_client, or HTTP 400/401) — the grant is dead.record_refresh_failuresetsreconnect_required = trueon the row (channel/login/bot), which stops the hourly retry (the candidate query skips flagged rows) and, on the first transition, emits an audit event and sends the reconnect email. - Transient (5xx / 429 / timeout / network, or an internal encrypt/persist error) — the refresh is retried on the 1-hour backoff; the flag is left untouched.
A reconnect never nulls a still-valid stored refresh token: upsert_connection uses refresh_token = COALESCE(EXCLUDED.refresh_token, channel_connections.refresh_token), matching bot_connections and the login path. See Connections → Reconnect Flags for the user-facing surface and the Google 7-day "Testing" rule.
Permissions
| Operation | Permission |
|---|---|
| Connection queries (list credentials / connections / status) | connections:read |
saveAppCredentials | connections:create |
authorizeChannel | connections:create |
deleteAppCredentials | connections:delete |
disconnectChannel | connections:delete |
Connection permissions are granular (read / create / edit / delete); there is no connections:write.
Key Files
| File | Purpose |
|---|---|
apps/api/src/platforms.rs | get_oauth_config() -- scopes, URLs, extra params per platform; PLATFORMS capability table |
apps/api/src/db/connections.rs | CRUD for app_credentials, channel_connections and bot_connections |
apps/api/src/graphql/connections.rs | GraphQL mutations for save/delete credentials, authorize, disconnect |
apps/api/src/oauth.rs | OAuthTokens, get_fresh_connection_token(), get_fresh_oauth_token(), get_fresh_bot_token() -- the only sanctioned refresh entry points |
apps/api/src/crypto.rs | AES-256-GCM encryption/decryption |
apps/api/migrations/20260318000007_create_connections.up.sql | Table definitions |