Connections
Channel and bot authorization in the dashboard begins with a dialog that explains the requested permissions. A successful or failed return appears as a toast. Channel reconnection verifies the same platform account, and bot connection refuses the main account. Login connections are managed separately under Security → Sign-in; adding or removing one requires a fresh step-up, while reconnecting the same account is exempt.
Overview
The Connections module manages platform OAuth connections for Lumio accounts. It handles app credentials when a platform needs the account's own OAuth app, channel connections that store the channel access/refresh tokens, and credential modes that tell the dashboard whether a new connection uses Lumio's global OAuth app for that (platform, kind) pair or account-owned credentials. The module supports CSRF protection via Redis-stored state handles, automatic token refresh, and encrypted credential storage. All secrets are encrypted at rest using AES-256 derived from a master key.
The dashboard presents these as the Connections group of the settings window, on its channel side (#settings/channel/connections/<area>, short link /dashboard/settings/channel/connections/<area>). Its areas are Overview, Twitch, YouTube, Kick, Spotify, Discord and Services. A platform area contains its channel, rights, chat bot (except Spotify) and OAuth app. Discord server connections live in the Discord area; OBS, StreamElements and Shopify live in Services. Each area and section is shown only when its feature, provider and read permission are available; each area loads through its own settings section route, which applies the same gate. The group root /dashboard/settings/channel/connections directs query strings such as ?reconnect=spotify and OAuth results to the named area, or to Overview when none is named. /dashboard/settings/channel/connections/shopify (also with ?connected=shopify or ?platform=shopify) opens Services at its Shopify section; the same path with #link=<handle> is the Shopify link-confirm screen (see Shopify › Web routes). Bookmarks to the former Connections hub redirect permanently (308) to the matching short link and keep the query.
Public stats for non-connected channels are gathered without any OAuth connection by the Public Stats Crawler (anonymous Twitch IRC read + app-token Helix polling). A connection is only ever created for a channel a Lumio account explicitly links here.
Channel status and followers
channelStatus / GET /v1/channel-status reports each platform's live, offline or scheduled status under connections:read. The Spotify manual-status operation remains under spotify:read. The dashboard uses the channel's URL-safe login or slug for Twitch and Kick links; a scheduled YouTube broadcast links to its watch page.
channelSchedule(from, to) / GET /v1/channels/schedule (also connections:read) lists the planned broadcasts of the connected channels: YouTube scheduled broadcasts, the Twitch schedule (Helix GET /schedule) and the Kick calendar (undocumented, see below). See Channel Status → Stream plan.
channelConnections { followerCount } reads the channel's follower count when the field is selected. REST GET /v1/connections/channel?include=follower_count fills follower_count; without that include it is null. Twitch uses Helix follower totals, YouTube uses an approximately rounded subscriber count (three significant digits), and Kick accepts the string-valued v2 follower count. Spotify has no follower count. Results are cached for 10 minutes (failed lookups for 2 minutes), with stored public channel metrics as a fallback; an unavailable count remains null. A connection needing reconnect does not trigger a platform lookup. Unknown REST include values return 400.
channelConnections { paidSubscriberCount } (REST ?include=paid_subscriber_count → paid_subscriber_count) reads the channel's current paid subscribers. It needs history:read on top of connections:read; without it the field is null, never an error.
| Platform | Source | Notes |
|---|---|---|
| Twitch | Helix GET /subscriptions?broadcaster_id=…&first=1 → total, with the channel connection's token | Gifted subs included. Needs channel:read:subscriptions; an older grant without it gives null until the owner reconnects (the connection notice already lists the missing scope). Twitch does not document whether the broadcaster's own channel sub counts, so nothing is subtracted. |
| Kick | Official GET /public/v1/channels?broadcaster_user_id=… → active_subscribers_count, with the channel connection's token | The kick.com v2 website JSON has no subscriber count (only followers_count). Kick also reports active_gifted_subscribers_count separately; Lumio shows active_subscribers_count unchanged. |
| YouTube | — (always null) | Memberships are not readable: Data API members.list is restricted to approved partners and InnerTube has no member count. YouTube "subscribers" are followers and appear in followerCount. |
| Spotify | — (always null) |
The number is never estimated from events and has no public-metrics fallback. Results are cached in Redis like the follower count (10 minutes, unavailable for 2 minutes); tokens refresh through get_fresh_connection_token, and a connection needing reconnect is not asked. Reading the number emits no audit event.
Login Connection Assignments
Login connections (Twitch, YouTube, etc.) are managed at the user level. Each user can have multiple login connections per platform. When a user is a member of multiple Lumio accounts, they can assign which login connection to use on each account.
This allows scenarios like:
- A creator with separate streaming channels assigns different Twitch accounts to different Lumio accounts
- A moderator assigns their personal Twitch login to the accounts they moderate on
Assignments are managed from the Profile page (user-centric view) or the Account Management page (account-centric view).
Which account an assignment targets
assignLoginConnection / removeLoginAssignment (and their REST twins) take an
optional account_id and fall back to the caller's active account when it is
omitted. Because Profile is a user-centric surface, it always sends the account
the user picked - both from the Add connection modal, where the pick is
carried through the ID App's OAuth round-trip as assign_account_id, and from
the per-connection Assign modal. The account switcher does not decide where a
login lands.
A supplied account_id must be an account the caller genuinely belongs to. When
the caller acts on their own login connection, any account in their
membership list is accepted - the same list the picker offers, so the UI and the
gate cannot drift. When acting on another user's connection (user_id
supplied, gated on login-assignments:create / :delete), the target is pinned
to the active account, because those permissions are resolved for the active
account's roles. Anything else - an account the caller holds no membership in -
is rejected with You do not have access to the target account.
Database
Assignments are stored in the account_login_assignments table, which maps a login connection to a specific account for a specific user:
| Column | Type | Description |
|---|---|---|
account_id | UUID (FK) | Account the connection is assigned to |
user_id | UUID (FK) | Owner of the login connection |
login_connection_id | UUID (FK) | The login connection being assigned |
provider | TEXT | Platform provider (e.g. twitch, google) |
Permissions
| Permission | Description |
|---|---|
login-assignments:read | View login connection assignments for all users on the account. Own assignments are always visible without this permission. |
login-assignments:create | Assign a login connection on behalf of another user. Own assignments are always allowed. |
login-assignments:delete | Remove a login assignment on behalf of another user. Own assignments are always removable. |
API
| Protocol | Operation | Permission |
|---|---|---|
| REST | GET /v1/accounts/login-assignments | login-assignments:read (own always visible) |
| REST | POST /v1/accounts/login-assignments | login-assignments:create (own always allowed) |
| REST | DELETE /v1/accounts/login-assignments/{provider} | login-assignments:delete (own always allowed) |
| REST | GET /v1/users/me/login-connections | Authenticated |
| REST | DELETE /v1/login-connections/{id} | Session (own connection) + step-up in the last 10 minutes |
| GraphQL | disconnectLoginConnection(loginConnectionId: UUID!) | Session (own connection) + step-up in the last 10 minutes |
Login assignments are also broadcast on the WebSocket channel
login-assignments:{key}, which is gated Public - it carries no credential
material, only the fact that an assignment changed, so a connected dashboard
can refetch.
Login connections do not decide a channel's name or picture; that comes from a channel connection or the channel's own details (see Channel name and picture).
Channel name and picture
A Twitch, YouTube or Kick channel connection can be the channel's name and picture source (Spotify cannot). The name is the connection's channel_name; the picture is channel_connections.channel_avatar_url - the Twitch profile_image_url, the YouTube channel thumbnail or the Kick profile picture. It is filled on connect, on reconnect and by the boot-time sync sync_channel_identities. Only URLs on hosts already allowed by the web CSP are kept (static-cdn.jtvnw.net, yt3.ggpht.com, lh3.googleusercontent.com, files.kick.com); otherwise the column stays empty and the initial is shown. While a connection is the source, accounts.name mirrors its channel name.
Disconnecting the connection that is the source - disconnectChannel / DELETE /v1/connections/channel/{platform}, or the admin adminDeleteAccountChannelConnection / DELETE /v1/admin/accounts/{id}/connections/{platform} - switches the channel to its own details with the connection's last channel name and the initial. The owner gets an in-app notification (type channel_identity_source_removed, link /dashboard/settings/channel/general). The source is chosen in the channel settings; the endpoints are described in Accounts › Channel name and picture.
What a disconnect stops and deletes
Every path that removes a channel connection - disconnectChannel / DELETE /v1/connections/channel/{platform}, the admin adminDeleteAccountChannelConnection / DELETE /v1/admin/accounts/{id}/connections/{platform}, and the cascade of deleteAppCredentials / DELETE /v1/connections/credentials/{platform} - goes through one service (services::account_identity), so the protocols behave the same:
- The platform's worker (Twitch EventSub, YouTube polling, Kick, Spotify) stops on the replica that handled the request, and the other API replicas are told over Redis (
lumio:account_workers). Every replica also stops, once a minute and when it (re)subscribes, any platform worker whose connection no longer exists, in case a message was lost. The account's other platforms keep running. - The connection row and its encrypted tokens are deleted. Disconnecting YouTube also deletes the associated revenue data (the revenue tables reference the connection with
ON DELETE CASCADE) and revokes Lumio's Google grant; the YouTube API Services Developer Policies require this. The disconnect dialog states this before confirmation.
Nothing else is deleted. The channel's data belongs to the channel: the chatter profiles, moderator notes, moderation log, AutoMod history log, chat messages, platform events and stream history the account holds for that platform stay. A Twitch disconnect also leaves the AutoMod queue (held messages in Redis, lumio:automod:held:\{account_id\}) in place, and it expires on its own within 30 minutes. The kept rows follow the plan retention window: the retention sweeps work per account and do not depend on a live connection, so they keep running after a disconnect. They are deleted at the latest when the channel is dissolved or the account is deleted. A reconnect of the same platform sees them again.
Reconnect Flags
All three connection tables (channel_connections, login_connections, and bot_connections) have a reconnect_required boolean column (default false).
When reconnect_required is set to true on a connection:
- The Token Refresh Worker skips the connection entirely during its refresh cycle (it no longer retries a dead grant every hour).
- All token retrieval functions (
get_fresh_connection_token,get_fresh_bot_token,get_fresh_oauth_token) return aRowNotFounderror for the flagged connection, causing platform workers that depend on it to stop gracefully. - Users see a red entry in the dashboard's connection notices with a direct reconnect button.
- The
reconnect_requiredfield is exposed on the connection APIs (GraphQLChannelConnection.reconnectRequired, RESTreconnect_requiredon/v1/connections/channel), so the dashboard shows a "Reconnect required" state rather than treating a lapsed 1-hour access token as an error. - The connection-status overview that feature surfaces read (Music control, Events, Multichat - including their popouts) also carries the health signal:
ConnectionStatus(GraphQLconnectionStatuses, RESTGET /v1/connections/status) exposesreconnectRequiredandexpiresAt, and itsisConnectedis health-aware - a flagged connection reportsisConnected: falseso a dead channel never renders a healthy-looking control that then silently fails. To tell "no connection" from "flagged connection", readreconnectRequired(a connection exists iffisConnected || reconnectRequired). Popout tokens carryingconnections:readcan read this overview; the first-party-onlyflaggedConnectionsquery is unaffected.
Login-grant health
The Multichat sends with the acting user's personal login grant (twitch→twitch, youtube→google, kick→kick), not the account's channel connection, so connectionStatuses (GraphQL) / GET /v1/connections/status (REST) additionally expose that grant's health per platform, separately from the channel signal:
-
loginConnected(login_connected) -truewhen the user's login grant for this platform (viaaccount_login_assignments) exists and is healthy. -
loginReconnectRequired(login_reconnect_required) -truewhen that login grant exists but is flaggedreconnect_required; the send will fail until the user reconnects it. -
loginMissingScopes(login_missing_scopes) - the scopes Lumio's login asks for that the stored login grant lacks (for examplemoderator:manage:automod, which Twitch AutoMod Allow/Deny needs). Empty when nothing is missing, when the stored scopes are unknown (an empty array on older grants) or when there is no grant. OpenID identity scopes (openid,email,profile) are never reported. The backend's login-scope lists (TWITCH_LOGIN_SCOPES,GOOGLE_LOGIN_SCOPES,KICK_LOGIN_SCOPESinapps/api/src/platforms.rs) mirrorapps/id/src/lib/providers.ts;apps/api/tests/login_scopes_drift.rsfails when they differ.
loginConnected and loginReconnectRequired are false when there is no login grant, no user context (fail-closed - an unknown grant is never reported connected), or the platform is not sent-to via a login grant (e.g. Spotify). The channel signals (isConnected / reconnectRequired) are unchanged - the login fields are additive, so a dead login grant never mutates the channel's connected state. This is the data source the Multichat reads to warn before the first send attempt, and it rides the same popout-readable overview (no new guard).
Missing scopes
A connection can be healthy and still lack required scopes that Lumio requests today — for example a Kick connection without kicks:read, channel:rewards:read or ads:read. Each channel connection carries missing_scopes (GraphQL ChannelConnection.missingScopes, REST missing_scopes on GET /v1/connections/channel, same connections:read guard as scopes): the base scopes the grant lacks, [] when nothing is missing. A connection whose granted scopes were never recorded (legacy rows) reports [], not a false "reconnect".
In the web app a non-empty missing_scopes shows as an amber New permissions available hint on the connection card and as an amber entry in the connection notices of the dashboard, Events and Chat (only for members holding connections:create, and not for a connection already flagged reconnect_required). The hint names the features the missing scopes unlock (Kick: KICKs, channel points and ads) and falls back to a generic sentence for a scope it has no label for. Its Grant {platform} permission button is the normal connect flow.
Reconnecting through the normal connect flow requests the full current scope list on the same connection. Google can let you decline an optional scope. For Kick, the worker then subscribes to the events that were missing. Details: Channel Connections & OAuth Scopes.
The channel connection's required and optional OAuth scopes are available to members with connections:read through GraphQL channelOAuthScopes(platform) or REST GET /v1/connections/oauth-scopes/\{platform\}. Both require feature:connections; the connect flow requests both lists. Kick ad-break estimates need the required scope ads:read. If Kick refuses an ad-break status request with 403, Lumio removes ads:read from the stored grant and shows the reconnect prompt.
YouTube also requests yt-analytics-monetary.readonly on every channel connect, but it is optional on Google's consent screen. Declining it never fills missing_scopes and never produces a standing connection-card or AlertDeck warning. Only the Stream History revenue area explains a missing grant. An existing connection without the scope gets one dismissible announcement on the history list. The own-app guide on the YouTube tab marks the Analytics scope optional and shows its full Google name. The YouTube connect dialog explains that members with View revenue (history:revenue-read) can see the YouTube amounts; there is no separate sharing switch. See YouTube Analytics API setup.
Where the reconnect state is shown
The dashboard makes the reconnect-required state visible everywhere a dead connection would otherwise fail silently - for all platforms (Twitch, YouTube, Kick, Spotify) and for login connections alike:
- Account → Profile · Login connections - a flagged login shows a "Reconnect required" badge (instead of the default green Connected badge), an inline error on the affected connection card with a highlighted Reconnect button, and a page-level reconnect banner. A healthy login is unchanged.
- Music control (dashboard and popout) - a flagged Spotify grant puts the player into an unavailable state: it stays visible but dimmed, with the last known track (or the empty state) and every control disabled. The notice and its repair action are a connection notice above it, so Spotify is reported once. This is unrelated to the transient "Connection lost - reconnecting…" WebSocket notice.
- Events (dashboard and popout) - the per-platform stats row (viewers / likes / views) shows a reconnect-required error pill for a flagged chat platform (Twitch, YouTube, Kick), and keeps showing it even while that channel is offline (a dead OAuth grant is exactly when nothing is online). The pill is the platform logo plus a red warning triangle, with the platform name and the expiry in its tooltip. A dead non-chat grant (Spotify) is not shown here - it belongs to the Music surface above, because this header speaks only to chat.
- Multichat (dashboard and popout) - the flagged platform keeps its tab and shows a red warning triangle in place of the live counts, with a short tooltip naming the platform. This is distinct from, and not merged with, the WebSocket
connectionLoststate.
Both markers are deliberately icon-only: they mark a platform as degraded, while the wording and the repair action live in the connection notices. Hiding a notice never removes a marker.
Connection notices
Every reconnect, permission and setup notice of the web app lives in one container, the connection notices (an AlertDeck from @lumio/ui). It shows one entry at a time; with more than one entry a pager ‹ 2 / 5 › sits top right next to the ✕, and the entry's button(s) sit underneath, right-aligned. Title and description are never shortened - long text wraps, and the container's height follows the entry that is shown.
| Entry | Severity | Shown when |
|---|---|---|
| Platform login expired | red | the acting user's personal login grant is flagged (loginReconnectRequired, or a flagged login connection in the dashboard) |
| Platform sign-in needs a new permission | amber | the acting user's known loginMissingScopes includes a scope used by an account permission they hold; unknown stored scopes (an empty list) show no entry |
| No login assigned to this channel | amber | none of your login connections is assigned to the active account (dashboard and Chat) |
| Connect a streaming platform | amber | your only login is Discord - one secondary button per streaming platform, none highlighted |
| Platform channel disconnected | red | a channel connection is flagged reconnect_required |
| Platform bot disconnected | red | a bot connection is flagged (dashboard) |
| Platform needs new permissions | amber | the connection's missing_scopes is non-empty and it is not flagged |
Order: your own (user) problems always come first, then the channel's: user red → user amber → channel red → channel amber. Within a group the platforms follow the tab order (Twitch, YouTube, Kick, then others). The first entry is shown first; when an entry is repaired the container stays on the entry that took its place instead of jumping elsewhere.
Paging: the ‹ › buttons, the ←/→ keys while the container has focus (switches instantly, without animation) and, in popouts on touch screens, a horizontal swipe. Nothing rotates on its own. With reduced motion only a short cross-fade remains.
Hiding: every entry can be hidden with ✕ until the page is reloaded (remembered per entry and account for the browser tab). If the cause still exists after a reload, the entry is back.
Where it appears:
- Dashboard and every non-fullscreen page - a card at the top of the page: flagged login / channel / bot connections, missing scopes of every platform (Spotify included), the login assignment and onboarding. The Upgrade banner stays separate.
- Chat and Events (dashboard and popout) - directly under the topbar, in the normal layout: chat platforms only. A bottom-stuck chat stays at its newest message while the container appears, grows, switches or disappears; a reader who scrolled up keeps their line.
- Chat popout with the header hidden - only what concerns sending (an expired login or channel on a platform you can send on) appears compactly above the input; with header and input both hidden (a pure browser source) nothing is shown.
- Music popout - Spotify only, read-only: a link to Connections in a new tab, never the OAuth start.
The sign-in needs a new permission entry belongs to the acting user. It appears in Chat under the header, above the chat input, and in the dashboard shell, with Sign in with {platform} again; it never appears in a popout. For Twitch AutoMod, moderator:manage:automod is relevant only to viewers with chat:automod-allow or chat:automod-deny; the entry says they need to sign in with Twitch again to allow or deny AutoMod messages. The other mapped pairs are moderator:manage:banned_users → chat:ban/chat:timeout, moderator:manage:chat_messages → chat:delete, and user:write:chat or chat:edit → chat:write. An unmapped login scope uses a general explanation. A disconnected login shows its existing sign-in disconnected entry instead of a second scope entry.
Who sees what (unchanged guards, no new API):
- Channel and bot entries with a repair button need
connections:create- the permissionauthorizeChannelitself is guarded on, so the button never 403s. In Chat, a member without it (a Moderator) still sees the channel entry for a platform they can send on, with a link to Connections instead. In the dashboard such members see channel and bot entries as text only. - Login entries in Chat need
chat:writeand appear only for platforms you can send on. - A delegated popout (shared token window) shows entries without buttons, and never the login assignment or onboarding - those belong to the viewer's own login, not to the account. All of a popout's requests carry the popout session, never the dashboard cookie.
- A repair button names its platform - Reconnect Twitch, Reconnect bot on Twitch, Grant Kick permission - and shows the platform's logo on a neutral button. It starts the platform's OAuth flow in place; from a popout window it opens in a new tab. When authorize refuses (the platform is not on the plan, an
Account-source platform has no or undecryptableapp_credentials- codeCREDENTIAL_DECRYPTION_FAILED-, a self-hosted install lacks a global OAuth app, or the permission was revoked since the page loaded), an error toast says so; the button never resets silently.
The feature-surface indicators read the popout-token-readable connectionStatuses overview, so the state is visible in browser-source popouts without loosening the first-party flaggedConnections guard, which only the dashboard reads.
Automatic detection
The Token Refresh Worker classifies every failed refresh as terminal or transient:
- Terminal - the provider rejected the grant itself: an OAuth2 error of
invalid_grant/unauthorized_client, or HTTP 400 / 401 (arefresh_tokengrant only 400/401s when the refresh token or client authorization is no longer valid). The worker setsreconnect_required = trueon the affected row (channel / bot / login). - Transient - a retryable failure (provider 5xx, 429 rate-limit, timeout, network error, or an internal encrypt/persist hiccup). The worker leaves the flag untouched and retries on the existing 1-hour backoff.
The worker also treats two conditions the refresh HTTP call never even reaches as terminal - previously both were skipped silently, leaving the dead grant invisible until the first failed send:
- Undecryptable stored token - ciphertext that no longer authenticates under the encryption key (a key mismatch / corruption,
aead::Error), not an expired token. It can never be refreshed, so the row is flagged. - Expired login grant with no refresh token - e.g. a Google grant that only returned a refresh token on the first consent. Once its access token has expired it can never be renewed, so a dedicated worker pass flags it.
A token can also be dead while its stored expiry still looks fine - revoked by the user on the platform, or minted by a different OAuth app than the one this environment pins (for example a database copied from another environment). The Token Refresh Worker never renews such a token, because it only refreshes tokens close to their expiry. The Twitch EventSub and Kick subscription workers therefore handle a provider HTTP 401 themselves:
- The first 401 on a (re)connect stops the run before any further call - Twitch subscription creation stops at the first 401 instead of failing once per event type.
- The worker forces one refresh of the channel token through the same single-flight lock and pinned credentials as the Token Refresh Worker. If another flight already renewed the token, the stored token is used without a second refresh.
- If the refresh is rejected, or the provider answers 401 again with the new token, the row is flagged
reconnect_requiredand the worker stops until the channel is reconnected. A refresh that fails for a retryable reason (network, provider 5xx, missing global app credentials) only backs off and never flags the connection.
The Multichat send path acquires the sender's login token through the same terminal-aware chokepoint: an undecryptable, already-flagged, or expired-without-refresh grant is flagged reconnect_required, fires the same audit + email, and returns a typed login_reconnect_required result instead of a raw error string (see Chat).
On the terminal false → true transition (and only then, so it fires once) the worker:
- Emits an audit event -
account:connection_reconnect_required(channel/bot, account scope) oruser:login_reconnect_required(login, user scope), withplatform,connection_typeandreasonin the metadata (token_refresh_terminalfrom the Token Refresh Worker or a login refresh,provider_rejected_tokenfrom a platform worker after a 401). - Sends a reconnect email (
lo_emailNoticeKind::ReconnectLogin,ReconnectChannel, orReconnectBot, according to the connection) to the account owner.
The flag is automatically cleared when the user reconnects through any of the standard flows:
- Login callback (re-authentication clears the flag on the login connection)
- Channel OAuth upsert (reconnecting the channel clears the flag)
- Bot OAuth upsert (reconnecting the bot clears the flag)
Admins can also set or clear the flag manually via the admin panel for any connection type. This is useful for forcing a user to re-authorize after a token leak, scope change, or platform-side revocation.
Google / YouTube: the 7-day "Testing" rule
The most common "my connection keeps expiring every few days" support case is not a bug in our refresh path. Channel connections use per-account BYO Google credentials (the customer's own Google Cloud OAuth client), so Google-side settings decide token lifetime. If the customer's OAuth consent screen is External with publishing status "Testing", Google issues refresh tokens that expire after 7 days (the exception is name/email/profile-only scopes; we request youtube.readonly + youtube.force-ssl, so the rule always applies). When that refresh token dies, our worker classifies it as terminal invalid_grant, flags reconnect_required, and emails the owner.
Fix (customer-side): set the OAuth client to "In production" (verification may be required for youtube.force-ssl), then reconnect once. Before debugging the refresh path for a Google/YouTube complaint, check the OAuth client's publishing status first.
Admin API
| Method | Path | Description |
|---|---|---|
PUT | /v1/admin/channel-connections/\{id\}/reconnect-flag | Set/clear reconnect flag on a channel connection |
PUT | /v1/admin/bot-connections/\{id\}/reconnect-flag | Set/clear reconnect flag on a bot connection |
PUT | /v1/admin/login-connections/\{id\}/reconnect-flag | Set/clear reconnect flag on a login connection |
Request body: { "reconnect_required": true } or { "reconnect_required": false }.
Unified OAuth callback (one shared redirect URI)
Lumio routes the three non-login OAuth flows through one shared web-host
redirect URI per platform by design — fewer registrations, one callback path, one
handle format. This is a deliberate choice, not a provider constraint: Kick
accepts up to 10 redirect URIs per OAuth app (verified in the Kick developer
dashboard, 2026-09-23; Kick's public documentation does not state the limit). The
three non-login flows — channel connection, account bot, and admin global bot —
therefore share one web-host redirect URI per platform instead of each
registering their own .../callback:
{web.public_url}/api/connections/oauth/{platform}/callback
Which flow a callback belongs to is not in the URL. It lives in a
server-side state handle minted at the authorize step:
OAuthStateHandle { kind ∈ {channel, bot, global_bot, integration}, account_id, platform, return_to, actor_user_id, shop_host },
stored in Redis under an unguessable single-use nonce with a 10-minute TTL. The
nonce is what travels through the provider as the state parameter.
The browser callback (Next.js) forwards code + state to the unified exchange
POST /v1/connections/oauth/{platform}/exchange, which:
- Redeems the handle (single-use: deleted on read).
- Binds a channel / account-bot handle to the initiator session (see below) before anything is written.
- Validates
return_tofail-closed against the[web.public_url, admin.public_url]allowlist by comparing URL origin (scheme + host + port) - a freereturn_towould be an open redirect. - Dispatches on
kindinto the shared channel / account-bot / global-bot completion logic (the same functions the legacy per-flow exchanges call), and - Returns the absolute, marker-appended redirect URL (
data.redirect_url) for the browser to follow (?connected=for channel/bot,?bot_connected=for the admin global bot).
For account-facing channel flows, the API mints the short link /dashboard/settings/channel/connections/{platform} as the handle's return_to (GraphQL and REST share one helper), never an address with #settings/…, because an OAuth return drops the fragment. The exchange refuses a return_to that carries a fragment. Account bot flows return to the same platform area with kind=bot, so the area handles the bot result independently of the channel result. Discord returns to the Discord area. Existing returns that name the former Connections hub reach the same area through its 308 redirect. The group root routes connected, reconnect and platform query strings to the matching area while preserving their query parameters; the window shows the result toast. The former dashboard bot and credentials paths redirect to the group root.
Initiator-session binding (channel and account bot). Starting a connect is
session-only: the authorize step accepts only an interactive sign-in session
(not an lm_usr_ API key) and records that user as the handle's
actor_user_id. The exchange then completes a channel / bot handle only when
the caller presents the same user's session - the web callback forwards the
browser's session bearer - and that user still has access to the handle's
account plus connections:create / bot-connections:create. No session, another
user's session, an API key, or a handle without a recorded actor is refused
(401 / 403) before the PKCE verifier is consumed or any connection row is
written; the single-use handle is spent either way.
Admin global bot is exempt. The global bot starts in the admin app, returns
through the web host, and return_to sends the browser back into the admin
providers UI. The browser session on the web host is never an admin session, so
a global_bot handle completes without one. That stays safe because only the
admin-only authorize step (platform-operator bot-connections:create) can mint
such a handle.
Fail-safe / prod-gated. The unified flow only activates for a system-sourced
connect, which requires global OAuth keys. Without them (local, dev, staging,
operator-managed installs) every connect degrades to account and keeps using the legacy
per-flow exchanges (POST /v1/connections/channel/{platform}/exchange,
POST /v1/bot-connections/{platform}/exchange), which remain as aliases for
account/BYO setups. Their state keys (oauth:state: / lumio:bot_oauth_state:)
store the same structured handle as the unified flow, including the initiating
user, and the alias exchanges run the same initiator-session binding
(check_exchange_binding: same user's interactive session, account access,
connections:create / bot-connections:create) before PKCE and the token
exchange (ZAF-1922). A state written before that binding existed carries no
initiator and is refused as expired - the connect is restarted. The browser
returns through the web app's callback route, which calls the session-bound
exchange; the API's server-side GET /v1/connections/channel/{platform}/callback
completes nothing and only redirects to
{web}/dashboard/settings/channel/connections/{platform}?error=…&platform=…, which redirects to the platform's area in the settings window, so the user restarts the connect there.
Reconnecting with a different account
A channel reconnect must come back with the platform account that is already
connected. When the platform reports a different account, the exchange changes
nothing and refuses with WRONG_PLATFORM_ACCOUNT (409); to switch the channel,
disconnect first. The refusal names the account the user was signed in with —
its login, else its display name, never a token or platform ID — so the
dashboard toast can name both the account that was used and the one that is
needed:
- Legacy exchange (
POST /v1/connections/channel/{platform}/exchange):409withdetails.signed_in_as. The web callback route copies it onto the dashboard URL. - Unified exchange (
POST /v1/connections/oauth/{platform}/exchange):200with aredirect_urlthat carrieserror=…&code=WRONG_PLATFORM_ACCOUNT&platform=…&signed_in_as=…, so the browser lands on the handle'sreturn_tolike any other outcome. - GraphQL has no exchange; the shared error mapper exposes the same value as
the
signedInAsextension.
signed_in_as is missing when the platform did not report a name.
When the provider rejects the token exchange
If the provider answers the code→token exchange with a 4xx (an expired or
replayed code → invalid_grant, a wrong client_secret → invalid_client, or
a redirect_uri / PKCE mismatch — RFC 6749 §5.2), the exchange fails with a
dedicated, client-facing error instead of a scrubbed INTERNAL_SERVER_ERROR
(ZAF-1506):
- HTTP
422, codeTOKEN_EXCHANGE_FAILED, message"<Provider> rejected the token exchange: <oauth_error>"(e.g.Kick rejected the token exchange: invalid_grant). The message names only the provider's OAuth error code — never a token, theclient_secret, or the raw upstream body. Same code and message on GraphQL and REST. - The API logs one structured line (
platform,token_url,redirect_uri, an 8-charclient_idprefix, HTTP status, and the parsederror/error_description) so an operator can tell an unregisteredredirect_urifrom a wrong secret without a body dump. - A provider 5xx or a transport failure is a transient upstream fault, not a
client-actionable rejection, and stays a scrubbed
500 INTERNAL_SERVER_ERROR.
For Kick, the most common cause is a missing redirect URI: when no dedicated
Kick Channel app is configured, a Kick channel connect falls back to the
login app's keys (kick_client_id / kick_client_secret, ZAF-1493), so that
login app must also register the unified channel callback
({web.public_url}/api/connections/oauth/kick/callback) in one of its ten
redirect-URI slots — otherwise Kick rejects the exchange with invalid_grant /
redirect_uri mismatch (see the redirect-URI tables in
OAuth setup).
Credential modes
The backend exposes the effective platform policy through platformCredentialModes and GET /v1/connections/credential-modes. The dashboard reads this surface instead of hardcoding which platforms need a customer OAuth app.
| Platform | Channel mode | Dashboard effect |
|---|---|---|
| Twitch | system by default | Connect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials. |
| Kick | system by default | Connect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials. |
| YouTube | account | The account supplies its own Google Cloud OAuth app. |
| Spotify | account | The account supplies its own Spotify app. |
Each mode row includes systemConfigured. mode: "system" with system_configured: false means the installation is configured to use Lumio-managed credentials, but the required dedicated global keys for that (platform, kind) pair are not present, so new connects may degrade to the account-credential path. The fallback is intentional: a local or operator-managed install with no global keys behaves like the historical BYO flow.
systemConfigured is kind-specific. Channel rows report the channel app pair (Twitch may count its login-key fallback); Bot rows report only the dedicated bot app pair. A bot connect can still run through the channel fallback when bot keys are missing, but the Bot row remains systemConfigured: false so the dashboard does not claim a dedicated bot app exists.
Each platform tab contains an App section. YouTube and Spotify require an account-owned app; Twitch and Kick normally use Lumio's system app, with an account-app fallback when the required global keys are absent. A saved account credential can still be managed on its platform tab. The own-app guides show five steps for Spotify and six for YouTube; Twitch and Kick fallback setup uses three steps. The guides read callback and required/optional scope values from channelOAuthScopes, rather than a separate frontend scope list.
A credential with decryptable: false appears as needing attention in the Overview and its platform's App section. Its stored client ID cannot be read under the current token_encryption_key; the owner must re-enter the credentials through the guide. The affected account-pinned connection cannot authorize or refresh with that credential.
When operators change [platform_credentials], existing channel connections are not rewritten. The row's credential_source remains pinned because a refresh token minted by OAuth app A cannot be refreshed with OAuth app B. Users reconnect when they want a connection to move to the newly selected source.
Kick: undocumented kick.com endpoints
Kick's official API (api.kick.com/public/v1) has no emote list, no badge
artwork, no follower count, no level badge, no chat-room id and no stream schedule. For those, Lumio reads
undocumented kick.com website endpoints. None of them receives a token (an
OAuth token never goes to an undocumented host), all use a 5-second timeout, and
all are fail-open: a Cloudflare challenge, a timeout or a format change only
removes that one piece of data — chat, events, role badges and history never
depend on them.
| Endpoint | Used for | Cached | Degrades to |
|---|---|---|---|
https://kick.com/emotes/{slug} | Emote menu: channel set, Kick Global, Kick Emojis (Emotes) | emote-set cache (6 h) + chat_emotes catalog | last catalog state |
https://kick.com/api/v2/channels/{slug} → subscriber_badges | Sub-badge images per month tier (Chat badges → Kick) | Redis, 6 h per channel | built-in sub icon |
https://kick.com/api/v2/channels/{slug}/users/{user_slug} → badges_v2 | The chatter's selected global badges: level, founder … | Redis, 4 h per user | no global badge |
https://kick.com/api/v2/channels/{slug} → followers_count | Follower count in the user card (ProfileService) | ProfileService profile cache | no follower count |
https://web.kick.com/api/v1/channels/{channel_id}/calendar/events?from_utc=&to_utc= → data.events[] | The channel's stream plan — the "Schedule" tab on kick.com (Channel Status → Stream plan). channel_id is the kick.com channel id from kick.com/api/v2/channels/{slug} → id | Redis, 10 min per channel (failure: 2 min) | empty plan for that channel |
https://kick.com/api/v2/channels/{slug} → chatroom.id | Chat room on Kick's Pusher chat socket, for deletes and unbans made on kick.com (Kick Chat-Moderation Listener) | Redis, 7 d per channel | channel skipped; kick.com deletes/unbans not picked up |
The channel slug comes from channel_connections.channel_login (the Kick slug;
set on connect, back-filled from the chat webhook's broadcaster.channel_slug
when missing).
The chat-moderation listener also reads Kick's unofficial Pusher chat WebSocket (not a kick.com HTTP endpoint): it is the only source for messages deleted and users unbanned on kick.com. It is best effort in the same sense — see Chat → Kick: which kick.com action arrives how.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/connections.rs) -- Queries for listing credentials, channel connections, and connection status. Mutations for saving/deleting credentials, initiating OAuth authorization, and disconnecting channels. - REST (
apps/api/src/routes/connections.rs) -- REST endpoints for credential CRUD, channel connection listing, OAuth authorization initiation, OAuth callback handling, and code exchange. REST routes provide the full OAuth flow with redirect handling. - Crypto (
apps/api/src/crypto.rs) -- AES-256 encryption/decryption for client_id, client_secret, access_token, and refresh_token. Key derived fromconfig.auth.token_encryption_key. - Platforms (
apps/api/src/platforms.rs) -- Platform registry with OAuth configuration (authorize URL, token URL, scopes, extra params) per platform.SUPPORTED_PLATFORMSconstant andis_valid_platform()validation.
Twitch IRC-tags listener (connected channels only)
Two Twitch chat-metadata signals are exposed only over IRC and are absent
from EventSub, so a dedicated worker
(apps/api/src/workers/twitch_irc_tags.rs) supplements the EventSub worker for
connected channels:
- First-time chatter - the
first-msg=1tag on a chatPRIVMSG. Emitted as atwitch:first_messageevent. - Watch streak - a
USERNOTICEwithmsg-id=viewermilestone,msg-param-category=watch-streak,msg-param-value=<count>. Emitted as atwitch:watch_streakevent carryingstreak_count, the viewer'sbadges(chat-messageChatBadgeshape, always present,[]when none), namecolorand - best effort -avatar_url(see the payload).
The worker opens a single anonymous (justinfan) read-only IRC connection —
no per-channel OAuth token, no credential surface - and JOINs only the channels
that have an active Twitch channel_connections row (join on connect, part on
removal via a 60s roster reload). The JOIN and the tag-router lookup key on
channel_connections.channel_login (the lowercase Twitch login), not on
channel_name (the display name). Twitch display names can be localized
(こさむん), and JOIN #こさむん is never honoured — so a display-name JOIN left
the bot silently absent from localized channels while EventSub kept them looking
healthy. Consumers fall back to channel_name only for legacy rows the boot
backfill has not yet filled. It is scoped to connected channels only and
is entirely separate from the global-scope
Public Stats Crawler, which it does
not touch. Both signals flow through the normal lo-events insert + broadcast
pipeline, so they appear on the same GraphQL / REST / WebSocket surfaces as every
other twitch:* event (see Events). This is a
deliberately reversible IRC dependency: if Twitch later exposes these via
EventSub, the source is swapped and the listener dropped.
The listener never resolves badge images or profiles on its own. Badge
image_urls come from the per-account badge cache that each Twitch EventSub
worker builds (HelixClient::build_badge_cache) and publishes into shared state;
until that worker has built one, images stay null. The avatar comes from the
ProfileService cache (Redis, then platform_users) without credentials, so it
never calls Helix from here, and it is bounded by a short timeout. Both fail
open and never hold back or drop an event.
Frontend
- Connection settings page with cards per platform showing connection status.
- App credential form for entering client_id/client_secret.
- "Connect" button initiates OAuth flow, redirects to platform, callback saves tokens.
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
appCredentials | connections:read | List app credentials for the account. Secrets are masked; only last 4 chars of client_id shown as clientIdHint. Each row also carries a non-null decryptable flag - false when the stored client_id can no longer be decrypted under the current token_encryption_key (a broken row that must be re-entered), so the UI can distinguish "broken" from a legitimate **** hint. |
channelConnections | connections:read | List channel connections for the account. Tokens are never exposed. Shows platform, channel ID, channelName (display name, may be localized), channelLogin (the URL-safe login/handle — use this to build a profile URL), scopes, expiry, and reconnectRequired. |
connectionStatuses | connections:read | Connection status overview for all supported platforms. Fields: hasCredentials, isConnected (health-aware - false when the connection is flagged reconnectRequired), reconnectRequired, channelName, expiresAt, enabled. Readable with a popout token that carries connections:read. |
platformCredentialModes | connections:read | Credential mode per (platform, kind) - the backend truth the frontend reads instead of hardcoding which platforms Lumio operates globally. One PlatformCredentialMode per (platform, kind) that has an OAuth mode: platform, kind (CHANNEL | BOT), mode (SYSTEM | ACCOUNT), and systemConfigured. systemConfigured is not derivable from mode - it says whether global keys are actually configured, distinguishing "Lumio manages this" from "should, but is not set up". |
enabledProviders(connectionType) | Public (no auth) | List enabled provider slugs for a given connection type (login, channel, or bot) |
enabledPlatforms | Public (no auth) | List platform slugs enabled for channel connections |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
saveAppCredentials(input: SaveCredentialsInput) | connections:create | Save (upsert) app credentials for a platform. Encrypts client_id and client_secret before storage. |
deleteAppCredentials(platform) | connections:delete | Delete app credentials, source-aware cascade. An account-pinned connection depends on the credentials (its token refresh reads app_credentials), so it is torn down together with them - leaving it alive would lock the customer out of an unrefreshable connection. A system-pinned connection uses the global Lumio app and does not depend on the account credentials, so it survives: only the (stray) credentials are removed. Runs in one transaction so the connection and its credentials never diverge. When the connection is torn down, its platform worker stops, exactly as for disconnectChannel; the platform's chatter data stays. Use disconnectChannel to drop only the connection and keep the credentials. |
authorizeChannel(platform) | connections:create | Start OAuth authorization flow. Generates a state parameter (stored in Redis with 10-min TTL), builds the authorization URL with scopes, and returns the redirect URL. For an account/BYO platform whose stored app_credentials can no longer be decrypted, it refuses with the stable code CREDENTIAL_DECRYPTION_FAILED and an actionable message (re-enter the credentials) - identical message and code on the REST twin. |
disconnectChannel(platform) | connections:delete | Disconnect a channel connection (keeps app credentials). Stops the platform worker on every API replica; the platform's chatter data stays (see What a disconnect stops and deletes). If it is the channel's name/picture source, the channel switches to its own details |
REST Endpoints
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/connections/credentials | connections:read | List all app credentials |
PUT | /v1/connections/credentials/{platform} | connections:create | Save app credentials |
DELETE | /v1/connections/credentials/{platform} | connections:delete | Delete app credentials (source-aware, twin of deleteAppCredentials). Stops the platform worker only when an account-pinned connection is actually disconnected (the chatter data stays); a surviving system-pinned connection keeps running. |
GET | /v1/connections/channel | connections:read | List all channel connections |
GET | /v1/connections/status | connections:read | Per-platform connection status overview (REST counterpart of connectionStatuses; health-aware is_connected + reconnect_required + expires_at) |
GET | /v1/connections/credential-modes | connections:read | Credential mode per (platform, kind) (REST twin of platformCredentialModes). Each item: platform, kind ("channel" | "bot"), mode ("system" | "account"), system_configured. Same connections:read + feature:connections guard as the GraphQL query. |
DELETE | /v1/connections/channel/{platform} | connections:delete | Disconnect a channel (twin of disconnectChannel, same worker stop; the chatter data stays). If it is the channel's name/picture source, the channel switches to its own details |
GET | /v1/connections/channel/{platform}/authorize | connections:create | Initiate OAuth flow (redirect URL). A system-sourced connect mints a structured state handle and targets the unified callback; an account/BYO connect keeps the legacy per-flow callback. Refuses with 422 and code CREDENTIAL_DECRYPTION_FAILED when the account's stored app_credentials can no longer be decrypted (twin of authorizeChannel). |
GET | /v1/connections/channel/{platform}/callback | -- | Retired server-side callback: completes nothing, redirects the browser to the platform's Connections tab with an error so the user can restart the connect |
POST | /v1/connections/channel/{platform}/exchange | Session of the initiating user + connections:create | Exchange OAuth code for tokens (legacy per-flow, account/BYO alias). Bound to the initiator's interactive session like the unified exchange, checked before PKCE and the token exchange (ZAF-1922) |
POST | /v1/connections/oauth/{platform}/exchange | -- | Unified OAuth code exchange behind the single web-host callback. Redeems the structured state handle, validates return_to fail-closed against [web, admin], dispatches on kind (channel / bot / global_bot) into the shared completion logic, and returns the absolute browser redirect URL (data.redirect_url). channel / bot handles complete only for the session of the user who ran the authorize step (re-checking account access + connections:create / bot-connections:create; 401 without a session, 403 otherwise, before anything is written); global_bot handles need no session. Answers 422 / TOKEN_EXCHANGE_FAILED when the provider rejects the exchange (invalid_grant, invalid_client, redirect_uri/PKCE mismatch), naming the provider's OAuth error code; a provider 5xx stays a scrubbed 500. |
GET / PUT / DELETE / POST | /v1/connections/shopify, /shopify/link/inspect, /shopify/link/confirm, /shopify/settings, /shopify/events/\{event_id\}/hide-name | connections:*; the hide needs events:edit | The Shopify store connection; see Shopify |
Permissions
| Permission | Description |
|---|---|
connections:read | View credentials (masked) and channel connections. Enforced on appCredentials, channelConnections, connectionStatuses, and the matching REST reads. |
connections:create | Save app credentials, initiate OAuth flows. Enforced on saveAppCredentials, authorizeChannel, and the matching REST writes. |
connections:edit | Gates the connection-settings controls in the dashboard (<Gate permission="connections:edit">) and the Shopify settings (updateShopifySettings, PUT /v1/connections/shopify/settings). Channel credential writes go through connections:create and removals through connections:delete. |
connections:delete | Delete credentials, disconnect channels. Enforced on deleteAppCredentials, disconnectChannel, and the matching REST deletes. |
Feature Gating
The channel-connections management surface is gated on the feature:connections
flag, in addition to the connections:* permissions. Every credential/channel
query and mutation (appCredentials, channelConnections, connectionStatuses,
saveAppCredentials, deleteAppCredentials, authorizeChannel,
disconnectChannel on GraphQL; the /v1/connections/credentials,
/v1/connections/channel, /v1/channel-info reads/writes, the authorize step
and the authenticated OAuth exchange on REST) enforces it on both protocols —
GraphQL via FeatureGuard::new("feature:connections") chained before the
permission guard, REST via require_feature(..., "feature:connections", ...).
With the flag disabled the API rejects the call directly (Feature 'feature:connections' is not available, HTTP 403); the webapp route gate is
UX only. The public enabledProviders / enabledPlatforms queries (used by
unauthenticated pricing pages) and the deprecated unauthenticated OAuth redirect
callback are intentionally not feature-gated. Per-platform availability is
additionally gated by the platform:{platform}:channel sub-flags on the
credential-save and authorize steps.
Database
| Table | Database | Description |
|---|---|---|
app_credentials | PostgreSQL | id, account_id, platform, client_id (encrypted), client_secret (encrypted), created_at, updated_at. Unique on (account_id, platform). |
channel_connections | PostgreSQL | id, account_id, platform, platform_channel_id, channel_name, channel_avatar_url (platform profile picture, CSP-allowed hosts only), access_token (encrypted), refresh_token (encrypted), scopes (text array), credential_source (account/system), reconnect_required (bool, default false), expires_at, created_at, updated_at. Unique on (account_id, platform). |
Data Flow
OAuth Connection Flow
- User clicks Connect, or first saves app credentials when the platform's current channel mode is
account. authorizeChannel(platform)resolves the source for(platform, channel).- For
system, the API reads the global Lumio OAuth app from config and mints a structured state handle for the unified callback. Foraccount, it decrypts the account'sapp_credentialsand mints the legacy account state. - Server builds the OAuth authorization URL with the platform's authorize endpoint, scopes, redirect URI, and state.
- Client redirects to the platform's authorization page.
- User authorizes on the platform. The web callback receives
codeandstate. - The callback exchange validates/redeems state, exchanges the code for tokens with the same OAuth app source, and fetches channel details.
- Access token, refresh token, granted scopes, channel details, and
credential_sourceare encrypted/stored inchannel_connections. - Platform workers start or continue for the connected platform.
Security
- Encryption at rest: All client_id, client_secret, access_token, and refresh_token values are encrypted using AES-256 with a key derived from
config.auth.token_encryption_key. This key is mandatory on any non-developmentdeployment: the API refuses to boot instaging/productionwhen it is empty, a shipped placeholder (CHANGE_ME/change-me-in-production), or shorter than 16 characters - there is no silent fallback to a constant key (an empty value would derive the public constantSHA-256(""), readable by anyone with this repository). See Mandatory auth secrets. - CSRF protection: OAuth state is stored in Redis with 10-minute TTL; unified system flows use single-use structured state handles.
- Secret masking: Client secrets and tokens are never exposed in API responses. Only a 4-character hint of the client_id is shown.
- Spotify localhost workaround: For development, Spotify requires
127.0.0.1instead oflocalhostin redirect URIs. - Spotify Development Mode allowlist: A BYO Spotify app that Spotify has not approved stays in Development Mode and can only be authorized by accounts the customer allows manually. The credentials setup wizard tells the customer to open User Management in their Spotify app and add the email address of the account they will connect; without it, Spotify still completes the token exchange but answers the first API call (
GET /v1/me) with403"User not registered in the Developer Dashboard". The code exchange treats that answer as a refusal: it saves no connection and writes no audit event, and returnsFORBIDDENwith a message containing "not registered", for which the dashboard names the User Management step (step 3) of the setup guide. Other failures of that profile lookup (network,5xx) do not block a Spotify connect; the connection is saved without a profile name. - Audit trail: adding a channel connection emits an
account:connection_addedaudit event whose metadata records thecredential_source("system"/"account") - so the log shows which OAuth app minted the token. Never a secret. Identical on both protocols (the add path is REST-side; the GraphQL disconnect twin emitsaccount:connection_removed).
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/connections.rs | GraphQL queries and mutations |
apps/api/src/routes/connections.rs | REST endpoints including OAuth callback/exchange |
apps/api/src/crypto.rs | AES-256 encryption/decryption utilities |
apps/api/src/platforms.rs | Platform OAuth configuration registry |
apps/api/src/db/connections.rs | Database operations for credentials and connections |