Skip to main content

Connections

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.

Foreign channels are not connections

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.

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).

Database

Assignments are stored in the account_login_assignments table, which maps a login connection to a specific account for a specific user:

ColumnTypeDescription
account_idUUID (FK)Account the connection is assigned to
user_idUUID (FK)Owner of the login connection
login_connection_idUUID (FK)The login connection being assigned
providerTEXTPlatform provider (e.g. twitch, google)

Permissions

PermissionDescription
login-assignments:readView login connection assignments for all users on the account. Own assignments are always visible without this permission.
login-assignments:createAssign a login connection on behalf of another user. Own assignments are always allowed.
login-assignments:deleteRemove a login assignment on behalf of another user. Own assignments are always removable.

API

ProtocolOperationPermission
RESTGET /v1/accounts/login-assignmentslogin-assignments:read (own always visible)
RESTPOST /v1/accounts/login-assignmentslogin-assignments:create (own always allowed)
RESTDELETE /v1/accounts/login-assignments/{provider}login-assignments:delete (own always allowed)
RESTPUT /v1/accounts/primary-connectionOwner only
RESTGET /v1/users/me/login-connectionsAuthenticated
RESTDELETE /v1/login-connections/{id}Authenticated (own connection)
GraphQLsetPrimaryLoginConnection(loginConnectionId: UUID, accountId: UUID!)Owner only
GraphQLdisconnectLoginConnection(loginConnectionId: UUID!)AuthGuard

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.

Restrictions

A login connection that is set as the primary connection for an account cannot be disconnected or unassigned. The user must first change or clear the primary connection before removing the assignment.

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 a RowNotFound error for the flagged connection, causing platform workers that depend on it to stop gracefully.
  • Users see a "Reconnect Required" banner in the dashboard with a direct reconnect button.
  • The reconnect_required field is exposed on the connection APIs (GraphQL ChannelConnection.reconnectRequired, REST reconnect_required on /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 (GraphQL connectionStatuses, REST GET /v1/connections/status) exposes reconnectRequired and expiresAt, and its isConnected is health-aware — a flagged connection reports isConnected: false so a dead channel never renders a healthy-looking control that then silently fails. To tell "no connection" from "flagged connection", read reconnectRequired (a connection exists iff isConnected || reconnectRequired). Popout tokens carrying connections:read can read this overview; the first-party-only flaggedConnections query is unaffected.

Login-grant health

The Multichat sends with the acting user's personal login grant (twitch→twitch, youtube→google, kick→kick, trovo→trovo), 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) — true when the user's login grant for this platform (via account_login_assignments) exists and is healthy.
  • loginReconnectRequired (login_reconnect_required) — true when that login grant exists but is flagged reconnect_required; the send will fail until the user reconnects it.

Both 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).

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, Trovo, 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 shows a distinct "Reconnect Spotify" state with a reconnect button (the dashboard) or read-only notice (the popout), separate from the "never connected" hint. 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 platform, 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.
  • 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 connectionLost state.

Both markers are deliberately icon-only: they mark a platform as degraded, while the wording and the repair action live in the sticky alert described next.

Sticky expired-connection alert (Multichat and Events)

Because Multichat and Events are fullscreen surfaces, they render no page-level dashboard banner. Instead, whenever at least one channel connection is flagged, both show an error alert pinned directly under their topbar:

  • It sits in the normal document flow, so it shortens the feed rather than covering messages or events, and it is not dismissible — the connection is genuinely broken until it is repaired.
  • Like the other header chrome it is hidden when a popout runs with its header turned off, so a browser source used in a stream never composites a dashboard alert into the broadcast.
  • It carries one row per flagged platform: the platform logo, an explanation, and a Reconnect button that starts the OAuth flow for that platform in place (authorizeChannel → provider redirect), rather than merely linking to the connections page. From a first-party popout window the flow opens in a new tab so the docked window is not navigated away.
  • Visibility is connections:create — the permission the authorizeChannel mutation itself is guarded on, so the button can never 403. Among the default roles that is Owner and Administrator; a Moderator (who holds only connections:read) still sees the per-tab warning marker but is not shown a repair action they cannot perform.
  • When the alert is visible, Multichat's send-path reconnect banner drops its channel rows to avoid warning about the same connection twice; login-grant rows are unaffected and always shown, because the sticky alert does not cover the personal login grant.

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. A degraded feature indicator is not dismissible — only the global dashboard banner can be temporarily hidden.

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 (a refresh_token grant only 400/401s when the refresh token or client authorization is no longer valid). The worker sets reconnect_required = true on 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.

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 eventaccount:connection_reconnect_required (channel/bot, account scope) or user:login_reconnect_required (login, user scope), with platform + connection_type in the metadata.
  • Sends the reconnect email (lo_email ReconnectRequired template) 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

MethodPathDescription
PUT/v1/admin/channel-connections/\{id\}/reconnect-flagSet/clear reconnect flag on a channel connection
PUT/v1/admin/bot-connections/\{id\}/reconnect-flagSet/clear reconnect flag on a bot connection
PUT/v1/admin/login-connections/\{id\}/reconnect-flagSet/clear reconnect flag on a login connection

Request body: { "reconnect_required": true } or { "reconnect_required": false }.

Unified OAuth callback (single redirect URI)

Kick and Trovo let an OAuth app register only one redirect URI. Once Twitch/Kick/Trovo move to a global Lumio OAuth app (one app per platform), the three non-login flows — channel connection, account bot, and admin global bot — can no longer each register their own .../callback. They collapse onto one web-host redirect URI per platform:

{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}, account_id, platform, return_to, actor_user_id }, 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:

  1. Redeems the handle (single-use: deleted on read).
  2. Validates return_to fail-closed against the [web.public_url, admin.public_url] allowlist by comparing URL origin (scheme + host + port) — a free return_to would be an open redirect.
  3. Dispatches on kind into the shared channel / account-bot / global-bot completion logic (the same functions the legacy per-flow exchanges call), and
  4. 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).

The unified exchange is deliberately unauthenticated: the one-time, account-bound handle minted by the authenticated authorize step is the authorization. Requiring a caller JWT would break the admin global-bot flow, whose browser session on the web host is never an admin session — 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.

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 callbacks and exchanges, which remain as aliases for account/BYO setups and in-flight authorizations.

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.

PlatformChannel modeDashboard effect
Twitchsystem by defaultConnect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials.
Kicksystem by defaultConnect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials.
Trovosystem by defaultConnect can use Lumio's global app when keys are configured; otherwise it falls back to account credentials.
YouTubeaccountThe account supplies its own Google Cloud OAuth app.
SpotifyaccountThe 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.

The App Credentials page (/dashboard/connections/credentials) lists only the platforms an account can actually set up. A platform's card is shown when it is enabled and either it is not system-managed (a BYO platform such as YouTube or Spotify) or the account already holds its own legacy credential. System-managed platforms with no account credential (Twitch/Kick/Trovo when Lumio's global keys are configured) are hidden — a one-line notice explains that those run through the Lumio app and need no setup. An account still holding its own legacy Twitch/Kick/Trovo credential keeps its card with full update and delete, so it is never locked out of removing a live account-pinned connection. When an empty enabledProviders list comes back (e.g. the query timed out), no filtering is applied and every platform is shown, so a transient failure never blanks the page. If no card remains after filtering, the page shows a short "nothing to set up" explanation instead of an empty grid.

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.

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 from config.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_PLATFORMS constant and is_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=1 tag on a chat PRIVMSG. Emitted as a twitch:first_message event.
  • Watch streak — a USERNOTICE with msg-id=viewermilestone, msg-param-category=watch-streak, msg-param-value=<count>. Emitted as a twitch:watch_streak event carrying streak_count.

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). 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.

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

QueryPermissionDescription
appCredentialsconnections:readList app credentials for the account. Secrets are masked; only last 4 chars of client_id shown as hint.
channelConnectionsconnections:readList channel connections for the account. Tokens are never exposed. Shows platform, channel ID, channel name, scopes, expiry, and reconnectRequired.
connectionStatusesconnections:readConnection 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.
platformCredentialModesconnections:readCredential 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)
enabledPlatformsPublic (no auth)List platform slugs enabled for channel connections

GraphQL Mutations

MutationPermissionDescription
saveAppCredentials(input: SaveCredentialsInput)connections:createSave (upsert) app credentials for a platform. Encrypts client_id and client_secret before storage.
deleteAppCredentials(platform)connections:deleteDelete 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. Use disconnectChannel to drop only the connection and keep the credentials.
authorizeChannel(platform)connections:createStart 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.
disconnectChannel(platform)connections:deleteDisconnect a channel connection (keeps app credentials)

REST Endpoints

MethodPathPermissionDescription
GET/v1/connections/credentialsconnections:readList all app credentials
PUT/v1/connections/credentials/{platform}connections:createSave app credentials
DELETE/v1/connections/credentials/{platform}connections:deleteDelete app credentials (source-aware, twin of deleteAppCredentials). Stops the platform worker only when an account-pinned connection is actually disconnected; a surviving system-pinned connection keeps running.
GET/v1/connections/channelconnections:readList all channel connections
GET/v1/connections/statusconnections:readPer-platform connection status overview (REST counterpart of connectionStatuses; health-aware is_connected + reconnect_required + expires_at)
GET/v1/connections/credential-modesconnections:readCredential 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:deleteDisconnect a channel
GET/v1/connections/channel/{platform}/authorizeconnections:createInitiate 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.
GET/v1/connections/channel/{platform}/callback--Legacy per-flow OAuth callback handler (account/BYO alias)
POST/v1/connections/channel/{platform}/exchange--Exchange OAuth code for tokens (legacy per-flow, account/BYO alias)
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). Deliberately unauthenticated — the one-time, account-bound handle is the authorization.

Permissions

PermissionDescription
connections:readView credentials (masked) and channel connections. Enforced on appCredentials, channelConnections, connectionStatuses, and the matching REST reads.
connections:createSave app credentials, initiate OAuth flows. Enforced on saveAppCredentials, authorizeChannel, and the matching REST writes.
connections:editGates the connection-settings controls in the dashboard (<Gate permission="connections:edit">). No backend query or mutation checks it — credential writes go through connections:create and removals through connections:delete.
connections:deleteDelete 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

TableDatabaseDescription
app_credentialsPostgreSQLid, account_id, platform, client_id (encrypted), client_secret (encrypted), created_at, updated_at. Unique on (account_id, platform).
channel_connectionsPostgreSQLid, account_id, platform, platform_channel_id, channel_name, 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

  1. User clicks Connect, or first saves app credentials when the platform's current channel mode is account.
  2. authorizeChannel(platform) resolves the source for (platform, channel).
  3. For system, the API reads the global Lumio OAuth app from config and mints a structured state handle for the unified callback. For account, it decrypts the account's app_credentials and mints the legacy account state.
  4. Server builds the OAuth authorization URL with the platform's authorize endpoint, scopes, redirect URI, and state.
  5. Client redirects to the platform's authorization page.
  6. User authorizes on the platform. The web callback receives code and state.
  7. The callback exchange validates/redeems state, exchanges the code for tokens with the same OAuth app source, and fetches channel details.
  8. Access token, refresh token, granted scopes, channel details, and credential_source are encrypted/stored in channel_connections.
  9. 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.
  • 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.1 instead of localhost in 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, the OAuth authorization is rejected.
  • Audit trail: adding a channel connection emits an account:connection_added audit event whose metadata records the credential_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 emits account:connection_removed).

Key Files

PathDescription
apps/api/src/graphql/connections.rsGraphQL queries and mutations
apps/api/src/routes/connections.rsREST endpoints including OAuth callback/exchange
apps/api/src/crypto.rsAES-256 encryption/decryption utilities
apps/api/src/platforms.rsPlatform OAuth configuration registry
apps/api/src/db/connections.rsDatabase operations for credentials and connections