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, and 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). Shopify's OAuth install is not a channel connection either: it stores its expiring offline token in integration_configs and requests the Admin API scopes read_orders,read_products,read_legal_policies from [shopify] scopes (see Shopify).
Discord's separate global-bot invite uses get_bot_oauth_config("discord") with scopes bot applications.commands identify, integration_type=0, and permissions bitmask 2147576832 (View Channels, Send Messages, Manage Messages, Embed Links, Read Message History, Use Application Commands). identify makes Discord return a verifiable server in the code exchange; the resulting user token is revoked and never stored. The callback is /api/connections/oauth/discord/callback. Discord has no channel app or account-owned bot app.
Channel Identity Columns: channel_name vs channel_login (ZAF-1461)
channel_connections carries two name columns, set from the platform's channel-info fetch on connect:
channel_name— the display name (Twitchdisplay_name, YouTube channel title, Kick/Spotify display). Shown in the UI. May be localized (こさむん).channel_login— the login / handle (Twitchlogin, Kick slug). The URL-safe, lowercase spelling used for IRCJOIN, IRC-tag routing, the stream-history freeze, and profile URLs.NULLfor platforms without a login concept (YouTube/Spotify).
Do not conflate them. A Twitch display_name can be non-ASCII, so JOIN #<display_name> is never honoured (to_lowercase is a no-op on CJK) — the bot goes silently absent while EventSub (keyed on broadcaster_user_id) keeps the channel looking healthy. Consumers read channel_login and fall back to channel_name only for legacy rows the boot backfill has not filled yet.
Backfill. Rows stored before P6 have a NULL channel_login. A best-effort boot backfill (backfill_missing_channel_logins) fills them from platform_chat_messages.channel_name (where the EventSub ingest already wrote broadcaster_user_login, so no Helix call is needed for any channel with chat history), then via Helix GET /users for the remainder. The upsert COALESCEs channel_login, so a corrected value survives every later reconnect.
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 | Uses kick_channel_client_id / kick_channel_client_secret when set; falls back to the Kick login pair (kick_client_id / kick_client_secret) with a one-time warning when unset (ZAF-1493) — Kick accepts up to 10 redirect URIs per app, so the login app can also register the channel callback. |
| 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. The Connections group of the settings window can request the current scopes again by reconnecting the same channel account; a disconnect is not required for an ordinary scope grant.
Twitch (33 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 | Cancel raids (Lumio does not start 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 |
moderator:read:chatters | Read the connected chatters (Helix GET /chat/chatters) for the chatter list |
moderation:read | Read the moderator list (Helix Get Moderators) — the chatter list's moderator group |
channel:read:vips | Read the VIP list (Helix Get VIPs) — the chatter list's VIP group |
moderator:manage:automod | Receive held AutoMod messages and decisions through EventSub |
moderator:manage:blocked_terms | Requested for later blocked-term management; Lumio does not use it yet |
moderator:manage:automod_settings | Requested for later AutoMod-level management; Lumio does not use it yet |
Extra params: force_verify=true (forces re-authorization even if already granted)
Reconnect an existing Twitch channel once to grant these three scopes. The held-message queue needs moderator:manage:automod; the other two rights are requested in the same consent for future controls. Allow and Deny use the moderator's login grant instead, which also needs moderator:manage:automod: ConnectionStatus.loginMissingScopes / login_missing_scopes reports the login scopes the acting user's stored grant lacks (empty when unknown, nothing is missing or there is no grant; openid, email and profile are never reported), and a decision with a known scope set that lacks it fails with login_scope_missing before any Helix call. The backend's login-scope lists (TWITCH_LOGIN_SCOPES, GOOGLE_LOGIN_SCOPES, KICK_LOGIN_SCOPES in apps/api/src/platforms.rs) must match apps/id/src/lib/providers.ts; apps/api/tests/login_scopes_drift.rs fails on drift.
YouTube (2 required scopes, 1 optional scope)
| Scope | Purpose |
|---|---|
https://www.googleapis.com/auth/youtube.readonly (required) | Read channel, video, and live chat data |
https://www.googleapis.com/auth/youtube.force-ssl (required) | Full YouTube Data API access (chat, comments) |
https://www.googleapis.com/auth/yt-analytics-monetary.readonly (optional) | Read estimated revenue for channels in the YouTube Partner Program |
Google scopes are requested as full URIs, not short names.
The YouTube own-app guide has six steps. It displays the two required Data API scopes and the optional Analytics monetary scope from channelOAuthScopes. Its final step accepts a Google web-client JSON file, validates its callback address in the browser and extracts the client ID and secret. Declining the optional scope keeps the channel connected and does not populate missing_scopes.
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 (8 scopes)
| Scope | Purpose |
|---|---|
user:read | Read user profile information |
channel:read | Read channel information |
events:subscribe | Subscribe to real-time events |
chat:write | Send chat messages |
moderation:chat_message:manage | Delete chat messages |
kicks:read | Read KICKs (kicks.gifted events, KICKs leaderboard) |
channel:rewards:read | Read channel-point rewards and redemptions (channel.reward.redemption.updated) |
ads:read | Read ad-break status for the stream-history revenue estimate; polled only for a live Kick stream and once at finalization |
channel:rewards:write is not requested: Lumio shows channel-point redemptions but does not manage rewards. Kick does not document whether it ties webhook subscriptions to these scopes. A Kick connection missing ads:read must be reconnected once for ad estimates; missing_scopes lists it, and a provider 403 removes it from the stored grant so the reconnect notice returns. The ad-break status is polled, not delivered by a webhook.
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 |
The Spotify own-app guide has five steps: confirm the intended account has Premium, create the app with Lumio's callback, allow the connecting account in Development Mode, copy the keys, and enter them in Lumio. The guide reads scope names from channelOAuthScopes. Spotify authorization ends 180 days after consent even when access tokens refresh; ChannelConnection.authExpiresAt / REST auth_expires_at exposes that date, and the hub prompts reconnection during the last 14 days. Reconnecting renews the authorization while retaining the app credentials.
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()
opt platform requires PKCE (OAuth 2.1, e.g. Kick)
API->>API: Mint code_verifier, store under oauth:pkce:{state} (600s)
API->>API: Add code_challenge + code_challenge_method=S256 to URL
end
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
opt platform requires PKCE
API->>API: Take code_verifier from oauth:pkce:{state} (single-use)
end
API->>Platform: Exchange code (+ code_verifier) 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.)
PKCE (OAuth 2.1)
Providers that run OAuth 2.1 require PKCE (RFC 7636) on every authorize/exchange,
even for confidential clients that send a client_secret. This is opt-in per
platform via PlatformOAuthConfig::requires_pkce in apps/api/src/platforms.rs
(currently true only for Kick, channel and bot). When set, the authorize
step mints a 43-char base64url code_verifier, stores it in Redis under
oauth:pkce:{state} (10-minute TTL, single-use), and puts the S256
code_challenge on the authorize URL (apps/api/src/routes/pkce.rs). The token
exchange takes the verifier back and sends code_verifier. All six authorize
builders share platforms::build_authorize_url, so the PKCE parameters cannot
drift between the channel/bot/global-bot paths on GraphQL and REST. refresh_token
grants never carry PKCE.
Scope Behavior
Static Scopes
Scopes are defined at compile time in get_oauth_config(). PlatformOAuthConfig.scopes contains the base scopes; optional_scopes contains grants a feature can work without. build_authorize_url requests both lists on every connection. Google may still let the user untick an optional scope on its consent screen. YouTube's monetary Analytics scope is optional, while its two Data API scopes are base scopes.
Changing Scopes
When the requested scope list grows, existing connections keep their old grant. Their tokens keep working for the scopes they hold, but the features behind the new scopes stay off until the owner reconnects: the normal connect flow (authorizeChannel / GET /v1/connections/channel/{platform}/authorize) requests the full current list, and the OAuth exchange upserts the existing row with the grant the provider returned. A user may decline an optional Google scope. No disconnect is needed.
After a reconnect the platform worker restarts with the new token. For Kick, the worker lists the existing event subscriptions and subscribes to every event not yet subscribed (workers/kick.rs), so events that needed the new scopes start arriving without further action. A reconnect records account:connection_added like a first connect (see Audit Log).
Chatter-list scopes (one reconnect)
The chatter list needs three Twitch scopes, added together so existing connections get exactly one reconnect prompt: moderator:read:chatters (Get Chatters), moderation:read (Get Moderators) and channel:read:vips (Get VIPs). The older moderator:read:moderators / moderator:read:vips are EventSub channel.moderate scopes and are not accepted by the two list endpoints. Until the owner reconnects, the list reports available = false, reason = "missing_scope" and shows only people who write; without the moderator/VIP scopes the groups fall back to chat badges. The poller uses the channel connection's token (via get_fresh_connection_token), never a login token.
Missing Scopes
Every channel connection exposes missing_scopes: the base scopes the platform's connect flow requests today that the stored grant lacks, in request order. A non-empty list means "reconnect to grant these". Optional scopes never enter this list, so declining YouTube Analytics does not leave a permanent warning on the connection card or in the connection notices. The revenue area checks the stored grant separately and can show not_granted there.
channelOAuthScopes(platform) / GET /v1/connections/oauth-scopes/\{platform\} returns {platform, required, optional} with full provider names. required is the base list, optional is the optional list, and authorization requests both. Both protocols use feature:connections and connections:read and return Unsupported platform: X for an unsupported platform.
| Protocol | Field | Guard |
|---|---|---|
| GraphQL | channelConnections { missingScopes } ([String!]!) | feature:connections + connections:read (same as scopes) |
| REST | GET /v1/connections/channel → missing_scopes (string[]) | feature:connections + connections:read (same as scopes) |
Both protocols compute it with the same helper, platforms::missing_scopes(platform, scopes), which returns the base get_oauth_config().scopes minus the stored grant. It is always a list, never null:
- Nothing missing →
[]. - Grant never recorded (
scopesisNULLor empty — legacy rows, or a token response without ascopefield) →[]. What such a token holds is unknown, so it is not reported as missing; this avoids a false "reconnect" on every old connection. - Platform without an OAuth config →
[].
This is independent of reconnect_required, which flags a dead grant (terminal refresh failure). A connection with missing_scopes still works for everything its grant covers.
For optional features, platforms::grant_has_scope checks the actual stored grant; a NULL or empty grant does not prove that the optional scope is present. platforms::granted_optional_scopes records the optional scopes granted in the account:connection_added audit event's optional_scopes_granted metadata. See YouTube Analytics setup.
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. Extension chat moderation checks it before calling the provider: a Twitch bot row without moderator:manage:banned_users (ban, unban, timeout) or moderator:manage:chat_messages (delete), or a YouTube bot row without youtube.force-ssl, fails with bot_scope_missing without touching reconnect_required. An empty scopes value counts as unknown and is not blocked up front; a provider 401 then maps to the same code. The bot scope lists live in get_bot_oauth_config(), separate from the channel lists above.
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; missing_scopes() -- requested minus granted scopes; 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 |