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:
- Feature flag -- the
feature:multi_accountflag must be enabled for the user's best account (the account whose plan has the highestmax_accountsvalue). - Plan-based limit -- each plan defines a
max_accountsfield 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:
- Name & Description -- enter the account name and optional description.
- Assign Login Connections -- select which of the user's login connections to assign to the new account.
- 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 (Twitchprofile_image_url, YouTube channel thumbnail, Kick profile picture). The picture is stored inchannel_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.namemirrors 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 underaccounts/{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 --
kindisconnection(withconnection_id) orown(without one). A Spotify connection, an unknown kind,connectionwithout an id orownwith an id fail with400 INVALID_SOURCE; a connection of another channel or an unknown id fails with404 Connection not found. Switching toownkeeps the current name as starting value and shows the initial. Switching back to a connection deletes an uploaded picture. - Own name -- only with
own, otherwise409 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_UNAVAILABLEwithout storage;503 AVATAR_BUSYwithRetry-Afterwhile 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 with403 This action requires an interactive sign-in session), and the{id}/accountIdmust be the session's active channel (403 Account id does not match the active account). During a dissolution grace period the refusal is403 CHANNEL_DISSOLVING. - Disconnect -- disconnecting the channel connection that is the source (
DELETE /v1/connections/channel/{platform}/disconnectChannel, or the adminDELETE /v1/admin/accounts/{id}/connections/{platform}/adminDeleteAccountChannelConnection) switches the channel toownwith the connection's last channel name and the initial. The owner gets an in-app notification (typechannel_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(scopeaccount, actor = caller) with metadatachanged(a list ofsource,name,avatar),source_kindandsource_platform; never a name or URL. Switching back to a connection that deleted an upload writeschanged: ["source", "avatar"]. Nothing is written when nothing changed.
API
| Protocol | Endpoint | Description |
|---|---|---|
| REST | PUT /v1/accounts/{id}/identity-source | Choose the source ({kind, connection_id?}) |
| REST | PATCH /v1/accounts/{id}/name | Set the own name ({name}) |
| REST | POST /v1/accounts/{id}/avatar | Upload the own picture (multipart field file; REST only) |
| REST | DELETE /v1/accounts/{id}/avatar | Remove the own picture |
| REST | GET /v1/uploads/file/accounts/{id}/avatar/{file} | Serve the current uploaded picture (public, not audited) |
| GraphQL | setAccountIdentitySource(accountId, kind, connectionId) | Choose the source |
| GraphQL | updateAccountName(accountId, name) | Set the own name |
| GraphQL | removeAccountAvatar(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
- User calls
POST /v1/accountsor thecreateAccountmutation.CreateAccountInputcarries an optionalname(defaults to the user's display name) and an optionalplanId(defaults to the Free plan). - The system checks
feature:multi_accountis enabled and the user has not exceeded theirmax_accountslimit. - The account is created with the user's display name and the specified
plan_id. - Default roles (Owner, Administrator, Moderator) are created via
db::roles::create_default_roles. - The user is added as a member with the Owner role.
- The caller's own session is switched to the new account (
active_account_idupdated on that one session row only, viadb::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. - For a caller with an interactive session, a fresh JWT bound to that session is issued containing the new
account_idand returned alongside the account data. A caller without one (e.g. a user API key) getstoken: null(GraphQLCreateAccountResult.tokenis a nullableString) - a JWT is only minted for a session caller (ZAF-1922).
Account Dissolution Flow
- User (must be owner, holding
account:delete) callsDELETE /v1/accounts/{id}ordissolveAccountmutation withconfirm_namematching 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 theaccount:deletepermission (ZAF-450 S2). After these checks the calling session needs a step-up in the last 10 minutes (ZAF-2052); otherwise both protocols answer403/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 withaccount:delete. - All sessions referencing this account have
active_account_idset toNULL. 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. - 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 toaccounts). The CASCADE reaches none of them. The operator delete (DELETE /v1/admin/accounts/{id}) runs the same erasure. See Chat › Erasure (GDPR Art. 17). - The channel's platform workers are stopped and its stream history is deleted: every
channel_history,channel_history_streams,channel_history_statsandchannel_history_milestonesrow of the account, an open (live) session included, and the account's rows of the viewer-curve rollupschannel_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 carriesdeleted_history_row_count. - 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 theDELETE, every object key the account owns is copied intostorage_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 carriesdeleted_object_count(files deleted) andpending_object_count(files left queued for the retry). After the commit, the bot-modulectx.cachehash 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. - 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). GraphQLdissolveAccountreturns{ 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: 0dissolves the account at once through the immediate flow above and returns{ dissolution_scheduled_for: null, dissolved: true }.7or30setsaccounts.dissolution_scheduled_for(plusdissolution_requested_at,dissolution_requested_by,dissolution_grace_days) and returns the date withdissolved: 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 typeaccount_deletion_scheduled(in-app and email, English and German). The eventaccount:dissolution_scheduledis 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:dissolvedwithvia: "scheduled"). The job claims each due channel right before its first write and acts only while the dissolution is still scheduled and due; its finalDELETErepeats 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 asupdated_by,reviewed_byandresolved_by. API fields that show these ids are nullable and readnullafterwards. - 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 instorage_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'sext_{extension_id}data schema on the extensions database is dropped, the removed installs'ctx.cachehashes 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 theirextension_idandversion_idbecomenull(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 itsdeveloper_idbecomesnull. - Blocking on purpose (
RESTRICT):accounts.owner_id. An owned channel must be dissolved first (409 OWNS_CHANNELSabove). - Blocked while Lumio owes the developer money: a developer profile with a revenue balance above 0 or a payout that is
pendingorprocessingis 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}/adminDeleteUserandDELETE /v1/admin/developers/{id}/adminDeleteDeveloper. A trigger ondeveloper_profilesenforces 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, RESTPOST /v1/admin/{users,developers}/{id}/release-funds-delete). In the delete transaction the balance moves to adeveloper_released_balancesrow (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 withdeveloper-funds:settlerecords 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
pendingpayout, 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 asclosing_payout_cents/closingPayoutCents. Scheduling still fails with409 DEVELOPER_FUNDS_OUTSTANDINGwhen a payout is in flight, withgrace_days: 0while any money is owed, and when the balance cannot be paid out (no payout method, no verified identity); incomplete tax details refuse it with422 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 withPOST /v1/developer/payouts/request{ "closing": true }/requestPayout(closing: true), which skips the minimum only then (409 CLOSING_PAYOUT_REQUIRES_DELETIONotherwise).
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 with403CHANNEL_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 carryextensions.code = "CHANNEL_DISSOLVING"andextensions.dissolutionScheduledFor. User-level routes keep working: the profile,mewith the account list, switching and leaving accounts, and the owner's cancel. - WebSocket - the
/v1/wsupgrade is refused with403 CHANNEL_DISSOLVINGfor any credential of the account; open sockets are closed withCHANNEL_DISSOLVINGon 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) anddiscordBotGuilds; 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/checkoutis refused with403 CHANNEL_DISSOLVING.
Leave Account Flow
- Non-owner members can call
POST /v1/accounts/{id}/leaveorleaveAccountmutation. - Ownership check prevents the owner from leaving (they must dissolve instead).
- The user's membership row is deleted.
API
REST Endpoints
| Method | Path | Description | Auth |
|---|---|---|---|
POST | /v1/accounts | Create a new account | Authenticated |
GET | /v1/accounts/{id} | Get account details | account:read; active account must match (same guard as GraphQL account) |
DELETE | /v1/accounts/{id} | Dissolve account | account:delete + owner + confirm_name + path id = active account |
POST | /v1/accounts/{id}/dissolution | Schedule dissolution (grace_days 0, 7 or 30; 0 dissolves at once) | account:delete + owner + path id = active account + step-up |
DELETE | /v1/accounts/{id}/dissolution | Cancel a scheduled dissolution | Owner, interactive session, no step-up |
POST | /v1/accounts/{id}/leave | Leave an account | Authenticated member |
GET | /v1/accounts/{id}/enabled-features | Enabled feature keys for the account | Authenticated (own account) |
GET | /v1/accounts/{id}/feature-statuses | Per-feature resolution detail (global / plan / override) | Authenticated (own account) |
GET | /v1/accounts/login-assignments | List login-connection assignments on the account | login-assignments:read (own assignments always visible) |
POST | /v1/accounts/login-assignments | Assign 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-source | Choose the channel's name/picture source | account:edit + interactive session + path id = active account |
PATCH | /v1/accounts/{id}/name | Set the own channel name (source own) | account:edit + interactive session + path id = active account |
POST | /v1/accounts/{id}/avatar | Upload the own channel picture (source own) | account:edit + interactive session + path id = active account |
DELETE | /v1/accounts/{id}/avatar | Remove the own channel picture | account:edit + interactive session + path id = active account |
GET | /v1/uploads/file/accounts/{id}/avatar/{file} | Current uploaded channel picture | Public |
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
| Query | Args | Returns | Permission |
|---|---|---|---|
account | id: 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
| Mutation | Args | Returns | Permission |
|---|---|---|---|
createAccount | input: CreateAccountInput! | CreateAccountResult! | AuthGuard (any authenticated) |
updateAccount (deprecated) | id: UUID!, name: String! | Account! | account:edit |
setAccountIdentitySource | accountId: UUID!, kind: String!, connectionId: UUID | AccountIdentity! | account:edit + interactive session |
updateAccountName | accountId: UUID!, name: String! | AccountIdentity! | account:edit + interactive session |
removeAccountAvatar | accountId: UUID! | AccountIdentity! | account:edit + interactive session |
dissolveAccount | confirmName: String! | DissolveAccountResult! | account:delete |
scheduleAccountDissolution | accountId: UUID!, graceDays: Int | AccountDissolution! | account:delete |
cancelAccountDissolution | accountId: UUID! | AccountDissolution! | AuthGuard + owner + interactive session |
leaveAccount | -- | LeaveAccountResult! | AuthGuard (any authenticated) |
createAccountreturns error extension{ "code": "ACCOUNT_CREATION_DISABLED" }with message"Account creation is currently disabled"if thesystem:account_creationflag is globally off or the user has adenyoverride.updateAccountis deprecated and follows exactly theupdateAccountNamerules: sourceownonly (SOURCE_NOT_OWN), 2-50 characters, interactive session only, target account = active account, audited asaccount:identity_changed.setAccountIdentitySource,updateAccountNameandremoveAccountAvatarfollow Channel name and picture.dissolveAccountrequires 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).scheduleAccountDissolutionandcancelAccountDissolutionfollow Scheduled Dissolution.leaveAccounthas 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
| Permission | Description |
|---|---|
account:read | View account details |
account:edit | Edit the channel's name/picture source, own name and own picture |
account:delete | Dissolve 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
| Column | Type | Description |
|---|---|---|
id | UUID (PK) | Account ID |
owner_id | UUID (FK) | Account owner user ID |
name | TEXT | Account display name; with source connection it mirrors the connection's channel name |
plan_id | UUID (FK) | References the plans table |
identity_source_kind | TEXT | Name/picture source: connection or own (default own) |
identity_source_connection_id | UUID (FK, nullable) | Channel connection used as the source (only with connection). ON DELETE SET NULL; a missing connection reads as own. |
avatar_storage_key | TEXT (nullable) | lo-storage key prefix of the uploaded picture (accounts/{id}/avatar/{uuid}), only with own |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last update timestamp |
dissolution_scheduled_for | TIMESTAMPTZ (nullable) | Date of a scheduled dissolution; while set the account is suspended |
dissolution_requested_at / dissolution_requested_by | TIMESTAMPTZ / UUID (nullable) | When and by whom it was scheduled (ON DELETE SET NULL) |
dissolution_grace_days | SMALLINT (nullable) | Chosen grace period (7 or 30) |
dissolution_reminded_at | TIMESTAMPTZ (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 roleaccount_roles-- Custom roles for the accountaccount_role_permissions-- Permissions assigned to rolesoverlays,channel_rewards,se_tokens,integration_configs, etc.
Key Files
| File | Purpose |
|---|---|
apps/api/src/graphql/accounts.rs | GraphQL queries and mutations |
apps/api/src/routes/accounts.rs | REST endpoints with JWT re-issue |
apps/api/src/services/account_dissolution.rs | Shared schedule / cancel / dissolve logic for both protocols |
apps/api/src/db/account_dissolution.rs | Dissolution columns and due-account queries |
apps/api/src/services/account_identity.rs | Channel name/picture source, own name, own picture (both protocols) |
apps/api/src/db/account_identity.rs | account_identities view reads and source writes |
crates/lo-auth/src/dissolution.rs | CHANNEL_DISSOLVING suspension check |
apps/api/src/workers/account_security.rs | Reminder and due dissolution job |
apps/api/src/db/auth.rs | User lookup, session management |
apps/api/src/db/roles.rs | Default role creation |
apps/api/src/db/members.rs | Membership management |
crates/lo-auth/src/rbac.rs | Permission constants (account:read/edit/delete) |