Skip to main content

Accounts

Overview​

The accounts module manages the full account lifecycle: creation (with automatic JWT re-issue), name editing, account dissolution (immediately or after a 7- or 30-day grace period, with the channel suspended in between), and leaving accounts as a non-owner member. Lumio supports multi-account switching -- a user can own multiple accounts and be a member of others.

Multi-Account Ownership​

Users can own multiple Lumio accounts. This is controlled by two mechanisms:

  1. Feature flag -- the feature:multi_account flag must be enabled for the user's best account (the account whose plan has the highest max_accounts value).
  2. Plan-based limit -- each plan defines a max_accounts field that caps how many accounts the user can own:
    • Free: 1 account
    • Pro: 3 accounts

The effective limit is derived from the user's best plan: MAX(plans.max_accounts) across all accounts they own. Admins can override this per-user via max_accounts_override on the user record; when set, the override replaces the plan-derived limit entirely.

Account Creation Wizard​

The dashboard presents a 3-step account creation wizard:

  1. Name & Description -- enter the account name and optional description.
  2. Assign Login Connections -- select which of the user's login connections to assign to the new account.
  3. Confirm -- review and create the account.

Channel name and picture​

A channel's name and picture come from one chosen source:

  • connection -- one of the channel's own channel connections on Twitch, YouTube or Kick (never Spotify). The name is the connection's channel name, the picture is the platform profile picture (Twitch profile_image_url, YouTube channel thumbnail, Kick profile picture). The picture is stored in channel_connections.channel_avatar_url, filled on connect, on reconnect and by a boot-time sync (sync_channel_identities). Only hosts already allowed by the web CSP are kept (static-cdn.jtvnw.net, yt3.ggpht.com, lh3.googleusercontent.com, files.kick.com); any other URL is dropped and the initial is shown. While a connection is the source, accounts.name mirrors its channel name, so search, the admin app and mails show the same name.
  • own -- an editable name (accounts.name) and an optional uploaded picture (accounts.avatar_storage_key, stored in lo-storage under accounts/{id}/avatar/). Without an upload the initial is shown.

New channels start with own. A channel whose source connection is deleted is treated as own.

The one read point is the database view account_identities (account_id, source_kind, source_connection_id, source_platform, name, connection_avatar_url, avatar_storage_key). Every reader joins it; nothing resolves the source by hand. The API exposes the result as identity (source_kind, source_connection_id, source_platform, name, avatar_url) on the account (Account.identity, GET /v1/accounts/{id}, POST /v1/accounts) and on every membership (me.accounts[].identity, GET /v1/users/me accounts[].identity). The membership's account_name is the same resolved name. owner_avatar_url / ownerAvatarUrl is deprecated and equals identity.avatar_url; there is no fallback to the owner's profile picture. The avatar_url of an uploaded picture is {storage.public_url}/accounts/{id}/avatar/{uuid}-512.webp, served by the API at GET /v1/uploads/file/accounts/{id}/avatar/{file} when storage.public_url is the default {api}/v1/uploads/file.

In the dashboard the source, the own name and the own picture are set in a tab of the channel settings.

Rules​

  • Switch source -- kind is connection (with connection_id) or own (without one). A Spotify connection, an unknown kind, connection without an id or own with an id fail with 400 INVALID_SOURCE; a connection of another channel or an unknown id fails with 404 Connection not found. Switching to own keeps the current name as starting value and shows the initial. Switching back to a connection deletes an uploaded picture.
  • Own name -- only with own, otherwise 409 SOURCE_NOT_OWN. Trimmed, NFC-normalised, 2-50 characters, no control or invisible characters (400 NAME_TOO_SHORT / NAME_TOO_LONG / NAME_INVALID_CHARACTERS).
  • Own picture -- only with own (409 SOURCE_NOT_OWN). Same pipeline as the profile picture: at most 5 MB (413 AVATAR_TOO_LARGE), PNG, JPEG or WebP by magic bytes (415 AVATAR_UNSUPPORTED_TYPE), at most 16 megapixels (400 AVATAR_TOO_MANY_PIXELS), 400 AVATAR_MISSING / AVATAR_UNREADABLE; re-encoded as 512 px and 128 px WebP without metadata. 10 uploads per channel per hour, counted before the body is read (429 RATE_LIMITED); 503 AVATAR_UNAVAILABLE without storage; 503 AVATAR_BUSY with Retry-After while the two decode slots shared with profile pictures are taken. A new upload or a removal deletes the old files.
  • Guard -- all four mutations need account:edit (owner and administrator by default), an interactive sign-in session (a user API key or popout token is refused with 403 This action requires an interactive sign-in session), and the {id} / accountId must be the session's active channel (403 Account id does not match the active account). During a dissolution grace period the refusal is 403 CHANNEL_DISSOLVING.
  • Disconnect -- disconnecting the channel connection that is the source (DELETE /v1/connections/channel/{platform} / disconnectChannel, or the admin DELETE /v1/admin/accounts/{id}/connections/{platform} / adminDeleteAccountChannelConnection) switches the channel to own 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).
  • Delete -- dissolving a channel (immediately, with grace 0, or by the scheduled job) and admin account deletion delete the uploaded picture files, through the channel's stored-file erasure (see Account Dissolution Flow, step 5).
  • Audit -- every effective change writes account:identity_changed (scope account, actor = caller) with metadata changed (a list of source, name, avatar), source_kind and source_platform; never a name or URL. Switching back to a connection that deleted an upload writes changed: ["source", "avatar"]. Nothing is written when nothing changed.

API​

ProtocolEndpointDescription
RESTPUT /v1/accounts/{id}/identity-sourceChoose the source ({kind, connection_id?})
RESTPATCH /v1/accounts/{id}/nameSet the own name ({name})
RESTPOST /v1/accounts/{id}/avatarUpload the own picture (multipart field file; REST only)
RESTDELETE /v1/accounts/{id}/avatarRemove the own picture
RESTGET /v1/uploads/file/accounts/{id}/avatar/{file}Serve the current uploaded picture (public, not audited)
GraphQLsetAccountIdentitySource(accountId, kind, connectionId)Choose the source
GraphQLupdateAccountName(accountId, name)Set the own name
GraphQLremoveAccountAvatar(accountId)Remove the own picture

The three GraphQL mutations return AccountIdentity!. See REST API › Channel name and picture.

Architecture​

Dashboard UI / ID App
|
v
Next.js API Proxy (/api/accounts)
|
+---> REST API (POST/GET/DELETE /v1/accounts, POST /v1/accounts/{id}/leave)
| |
| +--> JWT re-issue on create/dissolve
|
+---> GraphQL (AccountQuery / AccountMutation)
|
v
db::overlays, db::auth, db::members, db::roles (PostgreSQL)

Account Creation Flow​

  1. User calls POST /v1/accounts or the createAccount mutation. CreateAccountInput carries an optional name (defaults to the user's display name) and an optional planId (defaults to the Free plan).
  2. The system checks feature:multi_account is enabled and the user has not exceeded their max_accounts limit.
  3. The account is created with the user's display name and the specified plan_id.
  4. Default roles (Owner, Administrator, Moderator) are created via db::roles::create_default_roles.
  5. The user is added as a member with the Owner role.
  6. The caller's own session is switched to the new account (active_account_id updated on that one session row only, via db::auth::set_session_active_account). Account selection is per-device (ZAF-1416): creating an account on one device does not re-point the user's other devices. A session-less caller (API-key auth) persists nothing here.
  7. For a caller with an interactive session, a fresh JWT bound to that session is issued containing the new account_id and returned alongside the account data. A caller without one (e.g. a user API key) gets token: null (GraphQL CreateAccountResult.token is a nullable String) - a JWT is only minted for a session caller (ZAF-1922).

Account Dissolution Flow​

  1. User (must be owner, holding account:delete) calls DELETE /v1/accounts/{id} or dissolveAccount mutation with confirm_name matching the account name exactly. The REST path {id} must be the caller's active-session account - both protocols operate on the session account, and both require the account:delete permission (ZAF-450 S2). After these checks the calling session needs a step-up in the last 10 minutes (ZAF-2052); otherwise both protocols answer 403 / STEP_UP_REQUIRED ("Please confirm that it's you first") and nothing is deleted. Only an interactive sign-in session can hold a step-up, so a user API key, service key or popout token is refused even with account:delete.
  2. All sessions referencing this account have active_account_id set to NULL. This clear is deliberately account-wide (WHERE active_account_id = $1), unlike the per-device account switch (ZAF-1416): narrowing it to the caller's session would leave the user's other devices holding a dangling, now-deleted account in their token.
  3. The account's chatter PII is erased before the account row is removed: chat messages and platform events (TimescaleDB) and the chatter profiles, moderator notes and moderation log (platform_users, platform_user_notes, platform_moderation_log - no foreign key to accounts). The CASCADE reaches none of them. The operator delete (DELETE /v1/admin/accounts/{id}) runs the same erasure. See Chat › Erasure (GDPR Art. 17).
  4. The channel's platform workers are stopped and its stream history is deleted: every channel_history, channel_history_streams, channel_history_stats and channel_history_milestones row of the account, an open (live) session included, and the account's rows of the viewer-curve rollups channel_history_stats_5m / _1h (db::channel_history::delete_all_account_history, one transaction). The stream reports belong to the channel and go with it (founder ruling ZAF-2626). The workers stop first, so no stream-online hook opens a new session for the account. With a grace period of 7 or 30 days this happens only when the job dissolves the channel at the end of it; scheduling deletes nothing, and a cancel keeps the history. The step is fatal and runs before the account row is deleted, so a failure leaves the account in place and the dissolution retryable. Deleting the streams queues their platform channels for the Lumio Stats browse aggregates, which are recomputed without them; the crawler's own capture of the same platform channel is not the account's and stays. The operator delete (DELETE /v1/admin/accounts/{id} / adminDeleteAccount) runs the same step. The audit event carries deleted_history_row_count.
  5. The account is deleted (CASCADE handles related tables: memberships, roles, overlays, etc.) together with its stored files (services::storage_erasure::delete_account). In the same transaction, before the DELETE, every object key the account owns is copied into storage_deletion_queue: generic uploads, the account's own sounds, extension uploads no other account references (they are content-addressed per extension and shared), and the uploaded channel picture. Extension-bundled sounds are the extension's files and stay. After the commit the files are deleted; a file whose delete fails stays queued and the hourly account security worker retries it until it is gone. The operator delete (DELETE /v1/admin/accounts/{id} / adminDeleteAccount) uses the same path. The audit event carries deleted_object_count (files deleted) and pending_object_count (files left queued for the retry). After the commit, the bot-module ctx.cache hash of each extension install the CASCADE removed (lumio:ext:cache:{install_id}) is deleted at once instead of expiring with its field TTLs. This step is best-effort: a Redis failure is logged and the TTLs still bound the data to 24 hours.
  6. REST returns a fresh JWT without account_id, bound to the caller's session, so the user stays authenticated but can select another account (ZAF-1922). GraphQL dissolveAccount returns { success } only, no token.

Since an active Stripe subscription would keep billing a deleted account, both protocols refuse the dissolution with 409 / ACTIVE_SUBSCRIPTION while a subscription is running and not set to cancel at period end ("Cancel it in the billing settings first"). This check runs before the step-up.

Scheduled Dissolution (grace period)​

The owner can also schedule the dissolution with POST /v1/accounts/{id}/dissolution or scheduleAccountDissolution(accountId, graceDays), guarded by account:delete. grace_days is 0, 7 or 30 (default 30); any other value fails with 400 / INVALID_GRACE. Both protocols run the same checks in the same order: first-party user (popout tokens refused), INVALID_GRACE, the id is the active account (403), owner only (403 "Only the account owner can dissolve an account"), 409 DISSOLUTION_ALREADY_SCHEDULED, 409 ACTIVE_SUBSCRIPTION, then a fresh step-up (403 STEP_UP_REQUIRED; a user API key cannot hold one).

  • grace_days: 0 dissolves the account at once through the immediate flow above and returns { dissolution_scheduled_for: null, dissolved: true }.
  • 7 or 30 sets accounts.dissolution_scheduled_for (plus dissolution_requested_at, dissolution_requested_by, dissolution_grace_days) and returns the date with dissolved: false. The owner gets a confirmation mail, every other member a mail naming the channel and the date. Both are delivered as the locked notification type account_deletion_scheduled (in-app and email, English and German). The event account:dissolution_scheduled is written.
  • The account security worker reminds the owner 7 days before the end of a 30-day period and 1 day before the end of a 7-day period (dissolution_reminded_at), then dissolves due accounts through the same path as the immediate flow (account:dissolved with via: "scheduled"). The job claims each due channel right before its first write and acts only while the dissolution is still scheduled and due; its final DELETE repeats that condition. A cancel confirmed before the date therefore always wins.

DELETE /v1/accounts/{id}/dissolution / cancelAccountDissolution(accountId) cancels it. Owner only, interactive session only (no API key, no popout token), no step-up because cancelling is the safe direction. It works while the account is suspended, up to the end of the grace period. It fails with 409 PROFILE_DELETION_SCHEDULED while the owner's own profile is scheduled for deletion (cancel that first), with 409 DISSOLUTION_IN_PROGRESS ("The dissolution is already running") once the date has passed - the job owns the channel then - and with 409 NO_DISSOLUTION_SCHEDULED when nothing is scheduled. The owner and the members are mailed, and account:dissolution_cancelled is written.

dissolution_scheduled_for is exposed on Account / GET /v1/accounts/{id} and on every membership in me.accounts[] / GET /v1/users/me accounts[]. The dashboard reads the membership value to show the dissolution banner and the label in the account switcher.

A profile whose owner still owns channels can be scheduled for deletion only when every owned channel is scheduled for dissolution on or before the profile deletion date; otherwise 409 OWNS_CHANNELS. The daily job dissolves due channels first and deletes due profiles in the same run. Like the channel, a profile deletion can be cancelled only before its date (DELETE /v1/users/me/deletion / cancelProfileDeletion); afterwards both protocols answer 409 DELETION_IN_PROGRESS ("The deletion is already running"), and the job deletes a profile only while its deletion is still scheduled and due.

What a deleted profile leaves in other channels is fixed by each foreign key's ON DELETE action. Nothing in a channel the profile does not own blocks the deletion:

  • Kept, authorship cleared (SET NULL): records that belong to a channel or to the platform and only name their author. Examples are files and sounds the member uploaded (uploaded_by), invites they sent (invited_by), access they granted to overlays, widgets and extensions (granted_by), and admin or reviewer fields such as updated_by, reviewed_by and resolved_by. API fields that show these ids are nullable and read null afterwards.
  • Deleted with the profile (CASCADE): rows the user owns or that only make sense with them. Examples are memberships, sessions, notifications, popout tokens bound to the member, invites addressed to them, overlay and history shared links they created, and their copyright reports and confirmations.
  • Extensions the profile publishes (CASCADE, torn down): the extensions of the user's developer profile and of every developer team they own go with the profile, and so do the installs of those extensions on every channel. The same happens when an operator deletes a developer profile (DELETE /v1/admin/developers/{id} / adminDeleteDeveloper) or a team (DELETE /v1/admin/developer-teams/{id} / adminDeleteDeveloperTeam), or a team owner deletes their team (DELETE /v1/developer/teams/{id} / deleteDeveloperTeam). Nothing of those extensions outlives the delete: in the delete transaction the extension rows are locked and every stored file of the extension is queued in storage_deletion_queue (version files, bundles, icon, screenshots and every channel's extension uploads), and the installs' extension-bundled sounds are deleted. After the commit, each extension's ext_{extension_id} data schema on the extensions database is dropped, the removed installs' ctx.cache hashes are purged, and the queued files are deleted; a failed file delete stays queued for the hourly retry (services::storage_erasure::finish_extension_teardown). A draft extension delete runs the same teardown. The purchases made of those extensions are the exception: they are the buyers' and the accounting records, so they stay with their amounts, status and Stripe Checkout Session, and only their extension_id and version_id become null (the product reference stays with the Stripe session). A channel that bought a deleted extension no longer owns it.
  • Developer payouts (SET NULL): a payout is a booking record and stays for 8 calendar years after the year of its last status change, with a copy of the verified payee name, address and tax id; only its developer_id becomes null.
  • Blocking on purpose (RESTRICT): accounts.owner_id. An owned channel must be dissolved first (409 OWNS_CHANNELS above).
  • Blocked while Lumio owes the developer money: a developer profile with a revenue balance above 0 or a payout that is pending or processing is not deleted, on any path: profile deletion (the daily job holds a due deletion and retries it every run once the profile is paid out; held profiles do not take a place in its batch), DELETE /v1/admin/users/{id} / adminDeleteUser and DELETE /v1/admin/developers/{id} / adminDeleteDeveloper. A trigger on developer_profiles enforces it, so the user cascade cannot bypass it.
  • Unpayable balance released as a liability (ZAF-2831): when the balance cannot be paid out (no payout method, or no verified identity) and the person asks for the deletion, an operator releases it (adminReleaseFundsAndDeleteUser / adminReleaseFundsAndDeleteDeveloper, REST POST /v1/admin/{users,developers}/{id}/release-funds-delete). In the delete transaction the balance moves to a developer_released_balances row (balance → 0), so the trigger lets the delete through. The row holds no former user or developer id and no personal data; the person gets a 128-bit claim code by e-mail, stored only as a SHA-256 hash. Unsettled rows are deleted from 1 January (Europe/Berlin) of the release year + 4; a started claim holds the row; a settled row is deleted 8 years after the settlement year. When the person returns, an operator with developer-funds:settle records the presented code (claim_started_at, adminStartReleasedBalanceClaim / POST /v1/admin/released-balances/claim) and credits the amount to the person's new, verified developer profile in one transaction (adminSettleReleasedBalance / POST /v1/admin/released-balances/settle); the normal payout pays it out. The settled row is part of that profile's data export (developer.json → settled_released_balances); an unsettled row is linked to nobody and is in no export. A refund of a sale made before the release finds no developer to debit, so the released amount stays overstated by that share — accepted, the release is not held for the refund window.
  • Closing payout (ZAF-2825): scheduling a profile deletion with a grace period of 7 or 30 days pays the whole developer balance out as one pending payout, even below the minimum payout amount (same path as a manual payout: verified payee snapshot and self-billed credit note, one transaction), and returns the amount as closing_payout_cents / closingPayoutCents. Scheduling still fails with 409 DEVELOPER_FUNDS_OUTSTANDING when a payout is in flight, with grace_days: 0 while any money is owed, and when the balance cannot be paid out (no payout method, no verified identity); incomplete tax details refuse it with 422 TAX_DETAILS_REQUIRED (see Developer Credit Notes). Revenue that arrives later, while the deletion is scheduled (grace period running or held by the job), is paid out with POST /v1/developer/payouts/request { "closing": true } / requestPayout(closing: true), which skips the minimum only then (409 CLOSING_PAYOUT_REQUIRES_DELETION otherwise).

A dissolved channel keeps the extension purchases it made: extension_purchases.account_id becomes null, so the developer's revenue records stay complete. Sessions that had the channel active lose active_account_id.

Suspension while a dissolution is scheduled​

While dissolution_scheduled_for is set, one check in the API auth middleware (right after credential resolution) suspends the account:

  • Channel-bound credentials - user API keys (lm_usr_), service keys (lm_svc_), popout tokens, overlay (lm_overlay_), shared overlay (lm_share_), widget (lm_widget_) and extension (lm_ext_) tokens - are refused with 403 CHANNEL_DISSOLVING (message "This channel is being dissolved. Only cancelling the dissolution is possible", details.dissolution_scheduled_for). They are not revoked and work again after a cancel.
  • Member sessions keep their identity but lose every account permission. Permission-gated REST routes answer 403 CHANNEL_DISSOLVING; GraphQL errors carry extensions.code = "CHANNEL_DISSOLVING" and extensions.dissolutionScheduledFor. User-level routes keep working: the profile, me with the account list, switching and leaving accounts, and the owner's cancel.
  • WebSocket - the /v1/ws upgrade is refused with 403 CHANNEL_DISSOLVING for any credential of the account; open sockets are closed with CHANNEL_DISSOLVING on the 30-second re-validation tick.
  • Feature flags all resolve off (FeatureService), which stops automations, feature-gated routes and the WebSocket feature gate.
  • Bots - the account drops out of botChannels (Twitch, YouTube, Kick) and discordBotGuilds; bots leave on their next poll and rejoin by themselves after a cancel. Bot-module and automation-node handler feeds report the account's installs as disabled, and the bot-module trigger feed omits them.
  • Webhook ingest (YouTube PubSubHubbub, Kick) drops deliveries for the account.
  • Platform workers - scheduling the dissolution stops every worker of the account in the API process (Twitch EventSub, YouTube InnerTube polling, Kick, Spotify, StreamElements, OBS Remote, automation), so no chat or events of the channel are fetched or stored during the grace period. The replica that handles the request stops them at once and tells the others over Redis (lumio:account_workers); every replica also stops the workers of all dissolving accounts when it (re)subscribes and once a minute, in case a message was lost. The boot auto-start, the Spotify start on going live, the OBS reconcile loop and the Twitch IRC, Kick chat-moderation and 7TV rosters leave the account out. A cancel starts the workers again on the replica that handled it (like a new connection); the dissolution itself stops them before the account is deleted. Code: apps/api/src/workers/account_workers.rs.
  • Checkout - createCheckout / POST /v1/billing/checkout is refused with 403 CHANNEL_DISSOLVING.

Leave Account Flow​

  1. Non-owner members can call POST /v1/accounts/{id}/leave or leaveAccount mutation.
  2. Ownership check prevents the owner from leaving (they must dissolve instead).
  3. The user's membership row is deleted.

API​

REST Endpoints​

MethodPathDescriptionAuth
POST/v1/accountsCreate a new accountAuthenticated
GET/v1/accounts/{id}Get account detailsaccount:read; active account must match (same guard as GraphQL account)
DELETE/v1/accounts/{id}Dissolve accountaccount:delete + owner + confirm_name + path id = active account
POST/v1/accounts/{id}/dissolutionSchedule dissolution (grace_days 0, 7 or 30; 0 dissolves at once)account:delete + owner + path id = active account + step-up
DELETE/v1/accounts/{id}/dissolutionCancel a scheduled dissolutionOwner, interactive session, no step-up
POST/v1/accounts/{id}/leaveLeave an accountAuthenticated member
GET/v1/accounts/{id}/enabled-featuresEnabled feature keys for the accountAuthenticated (own account)
GET/v1/accounts/{id}/feature-statusesPer-feature resolution detail (global / plan / override)Authenticated (own account)
GET/v1/accounts/login-assignmentsList login-connection assignments on the accountlogin-assignments:read (own assignments always visible)
POST/v1/accounts/login-assignmentsAssign a login connection to the account (account_id defaults to the active account; own connections may target any account the caller is a member of)login-assignments:create (own always allowed)
DELETE/v1/accounts/login-assignments/{provider}Remove a login assignment (same account_id rule)login-assignments:delete (own always allowed)
PUT/v1/accounts/{id}/identity-sourceChoose the channel's name/picture sourceaccount:edit + interactive session + path id = active account
PATCH/v1/accounts/{id}/nameSet the own channel name (source own)account:edit + interactive session + path id = active account
POST/v1/accounts/{id}/avatarUpload the own channel picture (source own)account:edit + interactive session + path id = active account
DELETE/v1/accounts/{id}/avatarRemove the own channel pictureaccount:edit + interactive session + path id = active account
GET/v1/uploads/file/accounts/{id}/avatar/{file}Current uploaded channel picturePublic

Member management (GET /v1/accounts/{id}/members, DELETE /v1/accounts/{id}/members/{membership_id}, PATCH /v1/accounts/{id}/members/{membership_id}/role) is documented under Members.

POST /v1/accounts​

{
"plan_id": "uuid-of-plan"
}

When plan_id is omitted, the account is created on the Free plan.

Response (201):

{
"data": { "id": "...", "name": "...", "plan_id": "...", ... },
"token": "lm_eyJ..."
}

Response (403) - account creation disabled:

{
"error": "Account creation is currently disabled",
"error_code": "account_creation_disabled"
}

Account creation can be disabled globally by an administrator via the system:account_creation feature flag, or for a specific user via the per-user override on the user's admin detail page. The equivalent GraphQL error is extensions.code: "ACCOUNT_CREATION_DISABLED". See Feature Flags for the resolution chain.

DELETE /v1/accounts/{id}​

Requires the account:delete permission and account ownership; the path {id} must equal the caller's active-session account (mirrors GraphQL dissolveAccount, which operates on the session account). Returns 403 when the permission is missing or the id does not match the active account. This destructive action - like POST /v1/accounts/{id}/leave - additionally requires a first-party logged-in user session: a popout/overlay/widget/extension token is rejected with 403, so the owner authorization runs against the real session principal, not a popout-bound identity (ZAF-469). After the permission, owner, active-account and name checks the session needs a step-up in the last 10 minutes, otherwise 403 with code STEP_UP_REQUIRED (ZAF-2052); a user API key is refused here because it cannot hold a step-up.

{
"confirm_name": "My Account Name"
}

GraphQL Queries​

QueryArgsReturnsPermission
accountid: UUID!Account?account:read
accountFeatures--AccountFeatureQuery!AuthGuard (works with popout-token auth)

Users can only query their own active account. accountFeatures is a namespaced query object exposing enabledFeatures: [String!]! and featureStatuses: [AccountFeatureDetail!]!; it deliberately needs no account:read so popout tokens can resolve their own feature set. The account is always taken from the auth context, never from a client parameter.

Account.plan is the plan slug ("free", "pro"), not the plan UUID. Account.identity is the channel's resolved name and picture (see Channel name and picture).

GraphQL Mutations​

MutationArgsReturnsPermission
createAccountinput: CreateAccountInput!CreateAccountResult!AuthGuard (any authenticated)
updateAccount (deprecated)id: UUID!, name: String!Account!account:edit
setAccountIdentitySourceaccountId: UUID!, kind: String!, connectionId: UUIDAccountIdentity!account:edit + interactive session
updateAccountNameaccountId: UUID!, name: String!AccountIdentity!account:edit + interactive session
removeAccountAvataraccountId: UUID!AccountIdentity!account:edit + interactive session
dissolveAccountconfirmName: String!DissolveAccountResult!account:delete
scheduleAccountDissolutionaccountId: UUID!, graceDays: IntAccountDissolution!account:delete
cancelAccountDissolutionaccountId: UUID!AccountDissolution!AuthGuard + owner + interactive session
leaveAccount--LeaveAccountResult!AuthGuard (any authenticated)
  • createAccount returns error extension { "code": "ACCOUNT_CREATION_DISABLED" } with message "Account creation is currently disabled" if the system:account_creation flag is globally off or the user has a deny override.
  • updateAccount is deprecated and follows exactly the updateAccountName rules: source own only (SOURCE_NOT_OWN), 2-50 characters, interactive session only, target account = active account, audited as account:identity_changed.
  • setAccountIdentitySource, updateAccountName and removeAccountAvatar follow Channel name and picture.
  • dissolveAccount requires exact name confirmation, owner status, no active subscription (ACTIVE_SUBSCRIPTION) and a step-up on the session in the last 10 minutes (STEP_UP_REQUIRED, same as REST).
  • scheduleAccountDissolution and cancelAccountDissolution follow Scheduled Dissolution.
  • leaveAccount has no permission guard beyond authentication -- any member can leave.

GraphQL Types​

type Account {
id: UUID!
ownerId: UUID
name: String!
plan: String!
createdAt: String!
updatedAt: String!
dissolutionScheduledFor: String
identity: AccountIdentity!
}

type AccountIdentity {
sourceKind: String! # "connection" | "own"
sourceConnectionId: UUID
sourcePlatform: String # "twitch" | "youtube" | "kick"
name: String!
avatarUrl: String
}

type DissolveAccountResult {
success: Boolean!
}

type AccountDissolution {
dissolutionScheduledFor: String
dissolved: Boolean!
}

type LeaveAccountResult {
success: Boolean!
}

Permissions​

PermissionDescription
account:readView account details
account:editEdit the channel's name/picture source, own name and own picture
account:deleteDissolve account (also requires owner check)

Included in: Owner role has all three. Administrator has account:read and account:edit. The leaveAccount mutation bypasses permission guards (only requires authentication).

Database​

Table: accounts​

ColumnTypeDescription
idUUID (PK)Account ID
owner_idUUID (FK)Account owner user ID
nameTEXTAccount display name; with source connection it mirrors the connection's channel name
plan_idUUID (FK)References the plans table
identity_source_kindTEXTName/picture source: connection or own (default own)
identity_source_connection_idUUID (FK, nullable)Channel connection used as the source (only with connection). ON DELETE SET NULL; a missing connection reads as own.
avatar_storage_keyTEXT (nullable)lo-storage key prefix of the uploaded picture (accounts/{id}/avatar/{uuid}), only with own
created_atTIMESTAMPTZCreation timestamp
updated_atTIMESTAMPTZLast update timestamp
dissolution_scheduled_forTIMESTAMPTZ (nullable)Date of a scheduled dissolution; while set the account is suspended
dissolution_requested_at / dissolution_requested_byTIMESTAMPTZ / UUID (nullable)When and by whom it was scheduled (ON DELETE SET NULL)
dissolution_grace_daysSMALLINT (nullable)Chosen grace period (7 or 30)
dissolution_reminded_atTIMESTAMPTZ (nullable)When the owner reminder was sent

The view account_identities resolves the source into source_kind, source_connection_id, source_platform, name, connection_avatar_url and avatar_storage_key; it is the only read point for a channel's name and picture.

Related tables (CASCADE delete):

  • account_memberships -- User-account membership with role
  • account_roles -- Custom roles for the account
  • account_role_permissions -- Permissions assigned to roles
  • overlays, channel_rewards, se_tokens, integration_configs, etc.

Key Files​

FilePurpose
apps/api/src/graphql/accounts.rsGraphQL queries and mutations
apps/api/src/routes/accounts.rsREST endpoints with JWT re-issue
apps/api/src/services/account_dissolution.rsShared schedule / cancel / dissolve logic for both protocols
apps/api/src/db/account_dissolution.rsDissolution columns and due-account queries
apps/api/src/services/account_identity.rsChannel name/picture source, own name, own picture (both protocols)
apps/api/src/db/account_identity.rsaccount_identities view reads and source writes
crates/lo-auth/src/dissolution.rsCHANNEL_DISSOLVING suspension check
apps/api/src/workers/account_security.rsReminder and due dissolution job
apps/api/src/db/auth.rsUser lookup, session management
apps/api/src/db/roles.rsDefault role creation
apps/api/src/db/members.rsMembership management
crates/lo-auth/src/rbac.rsPermission constants (account:read/edit/delete)