Auth
Overview
Lumio supports email/password and platform sign-in through the ID app. The API resolves eleven auth contexts (including developer keys and Anonymous), handles sessions and 2FA, and enforces RBAC through lo-auth.
In Security → Sign-in, a passkey row shows the user's label and the provider name or YubiKey model when known. A provider logo appears next to that name when available; providers without a usable logo and unknown providers show a generic passkey symbol. The trademark notice appears below the list whenever a provider logo is visible. See Account Security for setup and recovery.
Auth Types
| Type | Prefix | Use Case | Rate Limit |
|---|---|---|---|
| System Key | lm_sys_ | Internal service-to-service communication | Unlimited |
| User API Key | lm_usr_ | Personal, per-member external API access | 1200 req/min |
| Service Key | lm_svc_ | Account-owned key for CI/CD & integrations (survives member departure) | 1200 req/min |
| Developer API Key | lm_dev_ | Approved developer or team extension development | 1200 req/min |
| JWT | lm_ + eyJ... | Session-based auth after OAuth login | 600 req/min |
| Popout Token | lm_pop_ | Browser-source and dashboard popout access with a scoped RBAC permission set | 600 req/min |
| Overlay Token | lm_overlay_ | One-overlay browser-source WebSocket access | 600 req/min |
| Shared Overlay Token | lm_share_ | Time-limited shared overlay WebSocket access | 600 req/min |
| Extension Token | lm_ext_ | Short-lived extension iframe/runtime access | 600 req/min |
| Widget Token | lm_widget_ | One-widget browser-source WebSocket access | 600 req/min |
| Anonymous | (none) | Unauthenticated public access | 120 req/min |
Token type is automatically identified from the prefix via identify_token().
Architecture
Client Request
|
v
Auth Middleware (identify_token -> resolve AuthContext)
|
+-- lm_sys_* --> System Key lookup (config + DB, hash match)
+-- lm_usr_* --> User API Key lookup (DB, hash match)
+-- lm_svc_* --> Service Key lookup (account_service_keys, hash match)
+-- lm_dev_* --> Developer API Key lookup
+-- lm_share_* --> Shared overlay token lookup
+-- lm_overlay_* --> Overlay token lookup
+-- lm_ext_* --> Extension token lookup
+-- lm_widget_* --> Widget token lookup
+-- lm_eyJ* --> JWT validation (decode + verify)
+-- ?token=lm_pop_* --> Popout token lookup (DB, hash match)
+-- (none) --> Anonymous
|
v
AuthContext (enum: System | ApiKey | ServiceKey | DeveloperKey | User | PopoutToken | OverlayToken | SharedOverlayToken | ExtensionToken | WidgetToken | Anonymous)
|
v
Permission checks (PermissionGuard / require_permission)
|
v
Handler / Resolver
OAuth Login Flow
- User initiates login in the ID App (NextAuth-based).
- ID App handles OAuth with the provider (Twitch, YouTube/Google, Discord, Kick).
- ID App calls the
exchangeTokenGraphQL mutation with:- Provider name and provider-specific user ID
- OAuth access token
- User profile (display_name, username, avatar_url, email)
- The API finds or creates the user via
find_or_create_user_by_provider. - If 2FA is enabled, the API returns a pending challenge. The ID app offers a passkey, authenticator app code or recovery code before creating the session or a PKCE code. A passkey sign-in completes both steps. A trusted device may skip this step for 30 days.
- Once the sign-in is complete, a session is created with a SHA-256 hash of the refresh token's
jtiand cached in Redis. - A JWT and refresh token are returned. Email/password sign-in follows the same 2FA gate. Confirming an email address alone never signs the user in.
Changes to sign-in methods and enabling 2FA require a fresh identity confirmation on the interactive session. The confirmation expires after ten minutes. With 2FA enabled, a passkey, authenticator app code or recovery code counts; without it, the user can confirm with an existing platform connection or email password. A reconnect to the same provider account is exempt. Platform OAuth step-up does not request provider max_age, so a provider may reuse an existing login session.
Token Refresh Flow
- Client sends expired JWT's refresh token to the
refreshTokenmutation. - Refresh token is validated and the session is looked up by token hash.
- A new JWT is issued with the same session ID.
- A new refresh token is issued with the remaining session lifetime (refresh token rotation). The session's
token_hashis updated atomically, invalidating the old refresh token.
Proxy-Level Auto-Refresh
All three Next.js apps (web, admin, ID) run a proxy.ts that intercepts every request and proactively refreshes the JWT when it is within 60 seconds of expiry. The proxy calls the GraphQL refreshToken mutation directly and sets updated cookies on the response. The user experiences no interruption.
If the API is unreachable during a refresh attempt, the proxy passes the request through without logging out - the page renders and shows a service-unavailable error instead.
Public web pages that show the signed-in viewer refresh too, but never redirect to the login: the marketing pages (/, /features, /pricing, /music, /invite, /ideas and their sub-pages), /legal/*, /withdraw, /cancel, the session-reading public API /api/ideas/*, and /popout/* when no popout session cookie is present (the popout then falls back to the dashboard cookie). With an expired JWT and a valid refresh cookie the page renders signed in. Without a session, or when the refresh is refused, the page renders signed out and the cookies stay as they are. File routes such as /ideas/feed.xml, robots.txt and sitemap.xml, the share images under /og/, the share pages under /share/, and /overlay / /widget (they authenticate with their own token) are never refreshed: a shared cache could replay a Set-Cookie on a cacheable response to other visitors.
Cross-Origin Request Guard
The session cookies are SameSite=Lax. SameSite counts a whole site, so a request from any sibling origin (for example docs.lumio.vision to lumio.vision, or one *.zaflun.dev host to another) still carries them. The proxy.ts of all three Next.js apps therefore refuses a POST, PUT, PATCH or DELETE to /api/* with 403 FORBIDDEN unless it comes from the app's own pages:
- With a
Sec-Fetch-Siteheader (every current browser), onlysame-originpasses. - Without it, the
Originheader must equal the app's configured public origin:LUMIO_APP_URLfor web and admin (web also accepts the overlay origin fromLUMIO_OVERLAY_URL),AUTH_URLfor the ID app. A request with neither header is refused.
The guard runs before any auth check, so it also covers the public /api/* routes. GET, HEAD and OPTIONS are not checked. The only exemption is web's /api/csp-report, which browsers post to from the overlay and widget pages. The check lives in shared/api/src/same-origin.ts (@lumio/api/same-origin); a new route that must accept cross-origin writes needs an explicit, commented exemption in that app's proxy.ts.
Client-Side Refresh Timer
The web and admin apps run a useTokenRefresh() hook that proactively refreshes the JWT for long-lived pages (popouts, chat, dashboards left open overnight). The scheduler lives in src/lib/token-refresh.ts of each app; it calls POST /api/auth/refresh and reacts to the answer:
- Success - the next refresh is scheduled 5 minutes before the new token expires (at least 60 seconds later).
- 401 - the session is dead (revoked, expired, or another tab already rotated the refresh token). The hook stops for good and reloads the page once. The reload goes through the proxy: for a dead session it clears the cookies and redirects to the ID login; if another tab won the rotation, the browser already holds the new cookies and the page simply loads. A
sessionStoragemarker allows at most one such reload per 5 minutes, so a reload loop is impossible. - Popouts (
/popout/*, also in OBS) stop on a 401 and never reload: the proxy skips/popout, so a reload would not clear anything. - Network error, 5xx or 429 - retried with exponential backoff: 60 s, 2, 4, 8 minutes, capped at 15 minutes. A success resets the backoff.
- Single-flight across tabs - each refresh runs inside the Web Locks API lock
lumio-token-refresh(navigator.locks; a plain call where the API is missing), so two tabs never rotate the same refresh token at once.
The timer only fires while the tab is visible; becoming visible again triggers an immediate refresh unless the hook has stopped.
Neither the refresh route nor the proxies' /api/* branch deletes cookies on a failed refresh. Refresh-token rotation has no grace window, so with two concurrent refreshes the loser's delete could overwrite the winner's fresh cookies and log the user out. Only the page-navigation branch of the proxy clears them.
Logout Flow
- Client sends the refresh token to the
logoutmutation. - Session is deleted from PostgreSQL and Redis.
- Returns success even if the token was already expired.
API
GraphQL Queries
| Query | Args | Returns | Guard |
|---|---|---|---|
me | -- | MeResult! | AuthGuard |
myPermissions | -- | [String!]! | AuthGuard |
MeResult includes:
- User profile (
id,displayName,email,avatarUrl,createdAt) activeAccountId(nullable)accounts: [AccountMembership!]!(role, plan, owner status, the channel's name and picture asidentity)permissions: [String!]!-- resolved account-scoped permissionsadminPermissions: [String!]!-- resolved admin-scope permissionsuserPermissions: [String!]!-- resolved user-scoped (cross-account, non-admin) permissions, e.g. Ideas Hubideas:moderate_*. This is the exact setAuthContext::require_user_permissionenforces, so the frontend can gate user-scoped controls on it without drifting from the backend. Account-independent (unchanged by an active-account switch). REST parity:user_permissionsonGET/PATCH /users/me.loginConnections: [LoginConnection!]!enabledFeatures: [String!]!featureStatuses: [FeatureStatusGql!]!-- merged account-scope flags plus the user-scopesystem:account_creationflagtoken: String-- present only whenupdateMeswitched the active accountownedAccountCount: Int!/maxAccounts: Int!-- multi-account ownership countersstreamerMode: Boolean!-- hides sensitive data in the UIisDeveloper: Boolean!-- has adeveloper_profilesentry or an admin overrideextensionDevMode: Boolean!-- load draft extension versionslocale: String!-- UI language preference:auto·en·detimezone: String!-- timezone preference:autoor an IANA namedateFormat: String!-- date-format preset:auto·dmy_dot·dmy_slash·mdy_slash·ymd_dashtimeFormat: String!-- time-format preset:auto·h12·h24seasonLookEnabled: Boolean!/seasonStrength: String!(full·subtle) /seasonMotion: String!(auto·off) /seasonCreatures: JSON!/seasonBannerDismissed: [String!]!-- seasonal-look preferences (see GraphQL API)motionPreference: String!-- "Animations in Lumio":system·reduced·off(see GraphQL API)
The four display preferences, the five seasonal-look preferences and the motion preference are user-global (stored on users, applied across the webapp) and settable via updateMe / PATCH /users/me; an invalid value is rejected fail-closed on both protocols (never coerced to auto), and changing them emits no audit event.
GraphQL Mutations
| Mutation | Args | Returns | Guard |
|---|---|---|---|
exchangeToken | input: ExchangeTokenInput! | TokenResult! | None (public) |
refreshToken | refreshToken: String! | TokenResult! | None (public) |
logout | refreshToken: String! | LogoutResult! | None (public) |
logoutSession | -- | LogoutResult! | AuthGuard |
disconnectLoginConnection | loginConnectionId: UUID! | LogoutResult! | AuthGuard + session + step-up |
updateMe | input: UpdateMeInput! | MeResult! | AuthGuard |
ExchangeTokenInput
input ExchangeTokenInput {
provider: String! # "twitch", "discord", "google", "kick"
providerId: String! # Provider-specific user ID
accessToken: String! # OAuth access token from provider
profile: ProviderProfileInput!
}
input ProviderProfileInput {
displayName: String!
username: String
avatarUrl: String
email: String
}
TokenResult
type TokenResult {
token: String! # Lumio JWT (short-lived, lm_ prefix)
refreshToken: String! # Refresh token (long-lived)
expiresAt: String! # JWT expiration (ISO 8601)
isNewUser: Boolean! # First login ever
hasAccount: Boolean! # User has at least one account
}
REST Endpoints
Auth REST endpoints are public (no permission guard) unless noted. They live under /v1/auth.
| Method | Path | Description |
|---|---|---|
POST | /v1/auth/token | Issue a JWT for a dashboard login (ID App -> API handshake). |
POST | /v1/auth/refresh | Exchange a refresh token for a new JWT. |
POST | /v1/auth/logout | Invalidate the current refresh token/session. |
POST | /v1/auth/authorize | OAuth 2.0 authorize handshake for downstream clients. |
POST | /v1/auth/token/exchange | Exchange a provider OAuth token for a Lumio JWT. |
POST | /v1/auth/link | Link an additional provider identity to the authenticated user (session JWT required; a new link also needs a step-up, a reconnect of the same connection does not). |
POST | /v1/auth/ws-token | Mint a short-lived WebSocket handshake token for the current principal. |
POST | /v1/auth/popout/exchange | Exchange a raw lm_pop_* token for the short-lived popout session cookie (see Popout Tokens). |
Bodies are application/json with snake_case fields. Response shapes match the GraphQL TokenResult / LogoutResult types.
Step-up before sign-in changes
Turning on two-factor authentication (POST /v1/users/me/mfa/setup and POST /v1/users/me/mfa/enable, GraphQL startMfaSetup / enableMfa), linking a new sign-in (POST /v1/auth/link without reconnect) removing one (DELETE /v1/login-connections/{id}, GraphQL disconnectLoginConnection) dissolving a channel (DELETE /v1/accounts/{id}, GraphQL dissolveAccount, ZAF-2052) and scheduling a channel dissolution (POST /v1/accounts/{id}/dissolution, GraphQL scheduleAccountDissolution) need a step-up on the calling session in the last 10 minutes. Without it both protocols answer 403 with code STEP_UP_REQUIRED and the message "Please confirm that it's you first", and nothing changes. The dashboard then shows its "Is it you?" dialog. Without 2FA the step-up is a platform round trip or the password; with 2FA a passkey, app code or recovery code counts.
enableMfa checks the mark again, so a setup started under a fresh mark cannot be finished after the mark ran out. A reconnect (reconnect: true with the connection_id of a connection of the same platform account) adds no new sign-in and needs no step-up. Removing a connection checks the "last sign-in method" rule first, so nobody is asked to confirm a removal that cannot happen; dissolving checks account:delete, ownership, the active account, the typed name and the absence of an active subscription (ACTIVE_SUBSCRIPTION) first in the same way. Cancelling a scheduled dissolution needs no step-up. API keys, service keys and popout tokens cannot hold a step-up, so they cannot dissolve a channel.
The ID app calls /v1/auth/link with the shared lumio-token session cookie, so a step-up done in the dashboard counts for the link that follows.
In the dashboard (Security › Sign-in) the dialog comes before each of these actions and is skipped while the session counts as confirmed:
- Two-factor › Set up confirms before step 1 of the set-up dialog. Turn on asks again if the mark ran out during the set-up.
- Add connection › Continue to … confirms before the hop to the ID app. Reconnect needs no confirmation.
- Remove connection (the unlink icon on a connection) first asks "Remove connection?", then confirms, then removes.
If the user confirms through a platform or the password, the dashboard remembers the action and continues it after ?step_up=ok. If the server still answers STEP_UP_REQUIRED because the mark ran out in the meantime, the dialog opens again instead of an error. When the mark runs out while the user is at the platform during a link, the ID app returns link_result=step_up_required to the dashboard. The dashboard then asks again and repeats the link with the same channels.
The ID app's confirm card names the reason and the account. The dashboard adds two display-only parameters: purpose, one of deleteProfile, export, dissolveChannel, emailLogin, mfa, addSignIn or removeSignIn, and account_name, the selected platform account. The card shows the reason in its subtitle, for example "Lumio asks before you delete your profile", and the button reads "Confirm with Twitch · cruex". Unknown values fall back to a general sentence. Neither parameter grants anything; the API checks the account itself. Choose another sign-in returns with step_up=choose. The dashboard resumes the stored action, and because nothing was confirmed, its dialog opens again.
User / Session Endpoints
These live under /v1/users/me and are covered in detail in Sessions and the Users resource.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/users/me | Auth | Current user profile + permissions |
PATCH | /v1/users/me | Auth | Update email, preferences, active account (not the display name - see below) |
PUT | /v1/users/me/name-source-connection | Session | Choose the display name + avatar source: a login connection or own details (kind) |
PATCH | /v1/users/me/display-name | Session | Set the own display name (own details only) |
POST | /v1/users/me/avatar | Session | Upload the own profile picture (multipart file; REST-only) |
DELETE | /v1/users/me/avatar | Session | Remove the own profile picture |
GET | /v1/users/me/login-connections | Auth | List provider identities |
GET | /v1/users/me/sessions | Auth | List active sessions |
DELETE | /v1/users/me/sessions/{id} | Auth | Revoke one session (ownership enforced in handler) |
DELETE | /v1/users/me/sessions | Auth | Revoke all other sessions for the current user |
AuthContext
The AuthContext enum is the core of the auth system, resolved by middleware and available in every handler/resolver:
enum AuthContext {
System { name, permissions },
ApiKey { user_id, account_id, label, permissions, rate_tier },
User { user_id, account_id, session_id, admin_permissions, account_permissions, user_permissions },
PopoutToken { account_id, user_id, label, permissions, token_hash },
OverlayToken { token_id, overlay_id, account_id },
SharedOverlayToken { token_id, overlay_id, account_id, expires_at },
ExtensionToken { token_id, extension_id, install_id, account_id },
WidgetToken { token_id, widget_id, account_id },
Anonymous,
}
Permission Resolution
- System -- Checked against the system key's own permission list (not bound to an account, so it can act on any account within those permissions). Internal and cross-account System surfaces additionally require a named grant via
require_system_permission- the bot fleet's feeds (ZAF-1922), extension functions on any install, draft extension files/bundles and the WebSocketpresence:*/sounds:*channels (ZAF-1976) needbots:internal. No System surface is gated on the key type alone. - API Key -- Checked against the key's assigned permissions.
- User (JWT) -- Admin-scope and account-scope permission lists are checked in order for the active account.
- Popout Token -- Checked against the token's custom permission list.
- Overlay / Shared Overlay / Extension / Widget Token -- No RBAC permissions; they only grant their own scoped runtime/WebSocket access.
- Anonymous -- Always denied.
Account roles are registry-validated against explicit resource:action strings; wildcard grants are not assignable through roles. Runtime wildcard matching only applies to permission lists that legitimately contain wildcards, such as configured system keys.
JWT Structure
Claims
| Field | Type | Description |
|---|---|---|
sub | UUID | User ID |
account_id | UUID? | Active account (can be switched) |
session_id | UUID? | References sessions table (JWT auth only) |
iat | i64 | Issued at (Unix timestamp) |
exp | i64 | Expiration (Unix timestamp) |
jti | UUID | Unique token identifier (UUID v7) |
All JWTs are prefixed with lm_ for type identification.
API Keys
System Keys (lm_sys_)
- Generated via
generate_system_api_key() - Provisioned through the Admin panel (System → System API Keys), stored in the
api_keystable withis_system = trueand no owning user/account. The auth middleware resolves them by hash against that table, falling back to any static keys loaded from TOML config. - Used by internal services and bots. A key reaches only what its scopes allow: the bot fleet's internal surface requires the system-only
bots:internalgrant (ZAF-1922), so each bot/worker key carries it and theapps/idaudit-ingest key (audit:ingestonly) reaches nothing else. See System Keys → Provisioning bot and worker keys. - 32 random bytes, hex-encoded
- Stored as SHA-256 hash for verification
User API Keys (lm_usr_)
- Generated via
generate_user_api_key() - Created by users from Settings → Channel → Access → API Keys
- Available when
feature:apikeysis enabled - Gated by
apikeys:readto view keys,apikeys:createto create keys,apikeys:editto rename, andapikeys:deleteto revoke keys - Personal to the creator within the active account: each key is bound to
(user_id, account_id), and users only manage their own keys - Member-bound lifetime: a personal key stops authenticating the moment its owner is no longer a member of the account. Removing the member (or the member leaving) purges their personal keys for that account, and the auth layer additionally refuses any
lm_usr_key whose owner is not a current member (or the owner) of the key's account. This is the deliberate contrast with account-ownedlm_svc_service keys below, which survive member departure. - Permission checkboxes can only select a subset of the creator's current permissions; the backend rejects any requested permission the creator does not hold
- Optional expiry can be set at creation time
- Display prefix shows the first 2 chars of the random part (e.g.,
lm_usr_ab) - Full key shown once on creation, only hash stored in DB
- Revoke hard-deletes the key
Service Keys (lm_svc_)
Account-owned service API keys for CI/CD pipelines and long-lived integrations that must keep working across team changes. Unlike personal lm_usr_ keys, a service key is owned by the account, not a member, and survives the departure of the member who created it.
- Generated via
generate_service_api_key() - Created from Settings → Channel → Access → API Keys → Service Keys
- Available when
feature:apikeysis enabled (same flag andapikeys:*permission family as personal keys) - Gated by
apikeys:readto view,apikeys:createto create,apikeys:editto rename, andapikeys:deleteto revoke - Creating or rotating a service key requires an interactive sign-in session. The caller must hold every permission assigned to the key; rotation rechecks the existing scopes before replacing its secret. An API key cannot create or rotate another service key.
- Stored in the dedicated
account_service_keystable (neverapi_keys), scoped toaccount_id - Carries no member identity: the middleware resolves it to an account-scoped principal (
AuthContext::ServiceKey) with the key's own permission list;user_idisNone, so it keeps authenticating after any member - including its creator - is removed from the account created_byrecords the minting member only as a nullable audit pointer (ON DELETE SET NULL); removing that member sets it tonullbut never deletes the key- Permission checkboxes can only select a subset of the creator's current permissions; the backend rejects any requested permission the creator does not hold (same
ZAF-371subset guard as personal keys) - Optional expiry can be set at creation time
- Display prefix shows the first 2 chars of the random part (e.g.,
lm_svc_ab) - Full key shown once on creation, only hash stored in DB
- Rename changes the label only; permissions, key material and expiry are immutable once minted
- Revoke hard-deletes the key
- Managed on both protocols: GraphQL
accountServiceKeys/createAccountServiceKey/updateAccountServiceKey/deleteAccountServiceKeyand REST/v1/service-keys - Lifecycle emits account-scoped audit events
account:service_key_created/account:service_key_updated/account:service_key_revoked
Popout Tokens (lm_pop_)
- Generated via
generate_popout_token() - Long-lived by default, with optional
expires_atandrevoked_atlifecycle controls - Used for browser sources and dashboard popout windows
- Limited, configurable permissions
- 32 random bytes, hex-encoded, SHA-256 hashed for storage
- The raw token is exchanged once, at page load, for a short-lived (15-minute) popout session cookie (
lumio-popout-token); it never travels in a proxied API/WebSocket call. Proxy auth reads the session cookie only, so a?token=left in a URL can never downgrade an already-logged-in operator to token-scoped rights. See API authentication. - Always bound to an explicit user (ZAF-1922): creating without a user binds the creator, unassigning binds the editor, and there is no fallback to the account owner - a token with no bound user is rejected like an invalid one.
- Member-bound: a token bound to a member stops working once that member is no longer part of the account. Removing the member (or the member leaving) deletes the tokens assigned to them for that account, and the raw-token resolver, the exchange, the popout-session resolver and the WebSocket re-validation additionally refuse a token whose assigned member is neither a current member nor the owner of the account.
- An open popout WebSocket is re-validated every 30 seconds and closed with
TOKEN_REVOKEDonce its origin token is revoked, expired or deleted (or its member left), and withTOKEN_EXPIREDwhen its session JWT expires. See Credential re-validation. - Signing out of the webapp (
POST /api/auth/logout, also used by the footer's Manage Cookies → Authentication) deleteslumio-popout-tokenandlumio-popout-originalong with the login cookies, so pop-out sessions opened in that browser end too. Both cookies are host-only and are deleted without a domain. An OBS browser source is a separate browser and keeps its session.
Login Connections
Login connections link OAuth provider identities to Lumio users. Each connection stores:
- Provider name and provider account ID
- Username, display name, avatar URL
- Encrypted OAuth tokens (access_token, refresh_token)
- Scopes and token expiry
Supported providers: Twitch, YouTube (via Google), Discord, and Kick.
Discord is a sign-in-only provider. Its login connection cannot be assigned to a channel. In Security → Sign-in it shows Sign-in only instead of channel chips or + Channel; the add-connection dialog offers no channel selection for Discord. The personal name/avatar source can still be a Discord login connection.
Display name & avatar source
A user's Lumio display_name and avatar_url are derived from one of their login connections - the name source - not entered as free text. The user picks the source connection (persisted as users.name_source_connection_id); when none is picked it defaults to their first-ever-connected login (login_connections.created_at ascending). On every login the name/avatar are re-mirrored from the resolved source, so signing in through a secondary platform never changes them.
Change the source via PUT /v1/users/me/name-source-connection (REST) or the setNameSourceConnection mutation (GraphQL) - both ownership-checked against the caller's own connections and session-only. The current source is exposed as name_source_kind + name_source_connection_id on GET /users/me / me. A channel's name and picture are separate: they come from one of the channel's own channel connections or its own details, never from a login connection (see Accounts › Channel name and picture).
Own details (name_source_kind = 'own', ZAF-2096). The same mutation with kind: "own" switches to the user's own details; the current name and picture stay as the starting values and sign-ins no longer overwrite them (find_or_create_user_by_provider keeps both when the kind is own). E-mail-only profiles are always own. With own details, updateDisplayName / PATCH /v1/users/me/display-name sets the name (trimmed, NFC, 2-32 characters, no control or invisible characters). The picture is uploaded via REST-only POST /v1/users/me/avatar and removed via removeAvatar / DELETE /v1/users/me/avatar. The server checks the size (5 MB), the magic bytes (PNG/JPEG/WebP) and the pixel count (16 MP) itself, then re-encodes the picture as metadata-free 512 px and 128 px WebP in lo-storage (users/{user_id}/avatar/…, key prefix in users.avatar_storage_key). Switching back to a connection, removing the picture, a replacement and the profile deletion all delete the files. The GDPR "user data only" export contains the picture file. At most two profile or channel pictures are decoded at a time across the API; a further upload gets 503 AVATAR_BUSY with Retry-After. Every change writes user:profile_identity_changed. See the REST reference.
Key Files
| File | Purpose |
|---|---|
crates/lo-auth/src/lib.rs | Public API re-exports |
crates/lo-auth/src/jwt.rs | JWT creation, validation, refresh token generation |
crates/lo-auth/src/context.rs | AuthContext enum with permission checks |
crates/lo-auth/src/api_key.rs | API key generation, hashing, type identification |
crates/lo-auth/src/popout_token.rs | Popout token generation and validation |
crates/lo-auth/src/rbac.rs | Permission constants, default roles |
crates/lo-auth/src/error.rs | Auth error types |
apps/api/src/graphql/auth.rs | GraphQL mutations (exchange, refresh, logout) and me query |
apps/api/src/db/auth.rs | User, session, and login connection DB operations |