Accounts
Overview
The accounts module manages the full account lifecycle: creation (with automatic JWT re-issue), name editing, account dissolution with confirmation, 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 and choose a primary provider for the account avatar.
- Confirm -- review and create the account.
Primary Login Connection
Each account has an optional primary_login_connection_id that determines the account's avatar. When a primary connection is set, the avatar is pulled from the linked platform profile (e.g. the user's Twitch or YouTube profile picture). When no primary connection is set, the account falls back to the owner's users.avatar_url.
Only the account owner can change the primary connection. A login connection set as primary for an account cannot be disconnected or unassigned until a different primary is selected (or the primary is cleared).
API
| Protocol | Endpoint | Description |
|---|---|---|
| REST | PUT /v1/accounts/primary-connection | Set primary login connection (owner only) |
| GraphQL | setPrimaryLoginConnection(loginConnectionId, accountId) | Set primary connection |
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, Viewer) are created via
db::roles::create_default_roles. - The user is added as a member with the Owner role.
- All active sessions for the user are switched to the new account (
active_account_idupdated). - A fresh JWT is issued containing the new
account_idand returned alongside the account data.
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). - All sessions referencing this account have
active_account_idset toNULL. - The account's chat PII is erased from TimescaleDB (
platform_chat_messages— viewer names and message text) before the account row is removed; that table lives outside the main database, so the CASCADE never reaches it. See Chat › Erasure (GDPR Art. 17). - The account's stream-history statistics are retained, not deleted. The
channel_history,channel_history_streamsandchannel_history_statsrows are broadcast statistics over a public stream (session duration, viewer curve, message and event counts, top emotes) rather than personal data of the account holder, so account deletion does not remove them. Any still-open session is only closed (itsended_atis stamped) so the sampler and startup recovery stop treating it as a live row for a now-deleted account. The manual operator purge that would delete these rows (delete_all_account_history) is no longer wired into dissolution (founder ruling ZAF-568). - The account is deleted (CASCADE handles related tables: memberships, roles, overlays, etc.).
- A fresh JWT without
account_idis returned so the user stays authenticated but can select another account.
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 | Active account must match |
DELETE | /v1/accounts/{id} | Dissolve account | account:delete + owner + confirm_name + path id = active account |
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 | login-assignments:create (own always allowed) |
DELETE | /v1/accounts/login-assignments/{provider} | Remove a login assignment | login-assignments:delete (own always allowed) |
PUT | /v1/accounts/primary-connection | Set primary login connection | Owner only |
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 and PUT /v1/accounts/primary-connection — 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).
{
"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. The
primary_login_connection_id column is not exposed on the Account GraphQL type
— it is set through setPrimaryLoginConnection and read back through the account
avatar resolution in me.
GraphQL Mutations
| Mutation | Args | Returns | Permission |
|---|---|---|---|
createAccount | input: CreateAccountInput! | CreateAccountResult! | AuthGuard (any authenticated) |
updateAccount | id: UUID!, name: String! | Account! | account:edit |
dissolveAccount | confirmName: String! | DissolveAccountResult! | account:delete |
leaveAccount | -- | LeaveAccountResult! | AuthGuard (any authenticated) |
setPrimaryLoginConnection | loginConnectionId: UUID, accountId: UUID! | Boolean! | Owner only |
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.updateAccountvalidates that the name is not empty and the target account matches the active account.dissolveAccountrequires exact name confirmation and owner status.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!
}
type DissolveAccountResult {
success: Boolean!
}
type LeaveAccountResult {
success: Boolean!
}
Permissions
| Permission | Description |
|---|---|
account:read | View account details |
account:edit | Edit account name |
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 |
plan_id | UUID (FK) | References the plans table |
primary_login_connection_id | UUID (FK, nullable) | Login connection used for the account avatar. ON DELETE SET NULL. |
created_at | TIMESTAMPTZ | Creation timestamp |
updated_at | TIMESTAMPTZ | Last update timestamp |
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/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) |