--- type: database title: User Table description: The `user` table — accounts, roles, and statuses. tags: [database, user, auth] timestamp: 2026-07-21T17:37:18Z --- # Schema | Column | Type | Description | |----------------|----------|------------------------------------------------------------------| | `id` | TEXT PK | UUID v4 generated on creation. | | `username` | TEXT | Unique login name. | | `password_hash`| TEXT | Argon2id hash (memory/time/parallelism from env config). | | `role` | TEXT | `'admin'` or `'player'`. Default `'player'`. | | `status` | TEXT | `'enabled'` or `'disabled'`. Default `'enabled'`. | | `created_at` | TEXT | ISO 8601 timestamp (`strftime('%Y-%m-%dT%H:%M:%fZ','now')`). | # Constraints - Unique on `username`. - `role` is enforced application-side (see `AdminService.updateUserRole`). - `status` is checked by `AuthService.login` and `AuthService.refresh` — disabled users cannot log in or refresh. # Invariants - The application enforces the **last-admin invariant**: at least one enabled user with `role = 'admin'` must always exist. Attempts to demote or delete the last admin are rejected by `UsersService.enforceLastAdminInvariant` (used by `AdminService.updateUserRole` and `AdminService.deleteUser`). # First-admin bootstrap When the `user` table is empty of admins, the dedicated `POST /api/v1/setup/create-admin` endpoint (see [Setup Endpoint](/api/setup.md)) creates the very first admin and auto-issues a session. Once any admin row exists the endpoint rejects further attempts with `409 SYSTEM_INITIALIZED`, so the route is effectively hidden in production. The legacy `POST /api/v1/auth/register-first-admin` route is preserved for compatibility but the canonical first-admin flow now lives in the `SetupModule`. # Concurrent bootstrap safety Two near-simultaneous `POST /api/v1/setup/create-admin` requests cannot both succeed. `SetupService.createAdmin` serializes callers on an in-process promise chain (`#bootstrapChain` + private `runBootstrap()` in `backend/src/modules/setup/setup.service.ts:111-130`) so that the first request commits inside its `DataSource.transaction` before the second request's transaction executes. The loser's transaction observes `adminCount > 0` and throws `ApiError(SYSTEM_INITIALIZED, 409)`; the winner is the only request that creates a `UserEntity(role='admin')` row and mints a refresh token. The lock is per-process; multi-replica deployments rely on the unique index and transaction guard, not on this chain. The regression test is `tests/backend/setup-create-admin.spec.ts` ("only one of two concurrent first-admin requests succeeds"). # See also - [Auth and Settings Tables](/database/auth-settings.md) - [REST API Overview](/api/rest-overview.md) - [Admin Endpoints](/api/admin.md)