Skip to main content

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:

PlatformChannel defaultNotes
TwitchsystemUses 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.
KicksystemRequires kick_channel_client_id / kick_channel_client_secret; no login-key fallback because Kick apps accept one redirect URI.
TrovosystemRequires trovo_channel_client_id / trovo_channel_client_secret; no login-key fallback because Trovo apps accept one redirect URI.
YouTubeaccountBYO Google Cloud OAuth client; quota, verification, and the 7-day Testing refresh-token rule stay with the customer app.
SpotifyaccountBYO 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)

ScopePurpose
channel:read:subscriptionsRead subscriber list and events
channel:read:redemptionsRead channel point redemptions
channel:manage:redemptionsManage channel point redemptions
channel:read:hype_trainRead hype train events
channel:read:pollsRead poll data
channel:manage:pollsCreate and manage polls
channel:read:predictionsRead prediction data
channel:manage:predictionsCreate and manage predictions
channel:read:goalsRead channel goals
bits:readRead cheer/bits events
moderator:read:followersRead follower list (used by ProfileService)
moderator:read:suspicious_usersRead suspicious user flags
moderator:manage:suspicious_usersUpdate suspicious user treatment
moderator:manage:banned_usersBan and unban users
channel:botSend chat messages as the channel bot
user:read:chatRead chat messages
channel:read:adsRead ad schedule
channel:manage:raidsStart and cancel raids
channel:moderateModerate chat (delete, timeout)
moderator:read:blocked_termsRead blocked terms list
moderator:read:chat_settingsRead chat settings
moderator:read:unban_requestsRead unban requests
moderator:read:banned_usersRead banned users list
moderator:read:chat_messagesRead chat messages as moderator
moderator:read:warningsRead user warnings
moderator:read:moderatorsRead moderator list
moderator:read:vipsRead VIP list

Extra params: force_verify=true (forces re-authorization even if already granted)

YouTube (2 scopes)

ScopePurpose
https://www.googleapis.com/auth/youtube.readonlyRead channel, video, and live chat data
https://www.googleapis.com/auth/youtube.force-sslFull 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)

The 7-day "Testing" refresh-token cap

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)

ScopePurpose
user:readRead user profile information
channel:readRead channel information
events:readRead channel events
events:subscribeSubscribe to real-time events
chat:writeSend chat messages
moderation:chat_message:manageDelete chat messages

Trovo (2 scopes)

ScopePurpose
channel_details_selfRead own channel details
channel_subscriptionsRead subscriber data

Spotify (7 scopes)

ScopePurpose
user-read-playback-stateRead current playback state
user-modify-playback-stateControl playback (play, pause, skip)
user-read-currently-playingRead currently playing track
playlist-read-privateRead private playlists
playlist-read-collaborativeRead collaborative playlists
playlist-modify-publicModify public playlists
playlist-modify-privateModify 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:

  1. Disconnect the existing channel connection
  2. 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:

SourceRequired for refreshToken row
accountapp_credentials for (account_id, platform)channel_connections
systemGlobal config pair from [auth] via CredentialResolver::system_pairchannel_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_responseRefreshError):

  • Terminal (invalid_grant / unauthorized_client, or HTTP 400/401) — the grant is dead. record_refresh_failure sets reconnect_required = true on 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

OperationPermission
Connection queries (list credentials / connections / status)connections:read
saveAppCredentialsconnections:create
authorizeChannelconnections:create
deleteAppCredentialsconnections:delete
disconnectChannelconnections:delete

Connection permissions are granular (read / create / edit / delete); there is no connections:write.

Key Files

FilePurpose
apps/api/src/platforms.rsget_oauth_config() -- scopes, URLs, extra params per platform; PLATFORMS capability table
apps/api/src/db/connections.rsCRUD for app_credentials, channel_connections and bot_connections
apps/api/src/graphql/connections.rsGraphQL mutations for save/delete credentials, authorize, disconnect
apps/api/src/oauth.rsOAuthTokens, get_fresh_connection_token(), get_fresh_oauth_token(), get_fresh_bot_token() -- the only sanctioned refresh entry points
apps/api/src/crypto.rsAES-256-GCM encryption/decryption
apps/api/migrations/20260318000007_create_connections.up.sqlTable definitions