3.6 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||
|---|---|---|---|---|---|---|---|
| database | User Table | The `user` table — accounts, roles, and statuses. |
|
2026-07-23T13:27:25Z |
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. roleis enforced application-side (seeAdminService.updateUserRole).statusis checked byAuthService.loginandAuthService.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 byUsersService.enforceLastAdminInvariant(used byAdminService.updateUserRoleandAdminService.deleteUser). - A deployment-wide database safety net backs the invariant up at the
SQLite level via two triggers created by the
AddUserLastAdminTriggers1700000000800migration:trg_user_last_admin_update(fires onUPDATE OF rolewhen the old row was admin, the new role is not, and no other admin row exists) andtrg_user_last_admin_delete(fires onDELETEof an admin row when no other admin row exists). Both abort the offending statement withRAISE(ABORT, 'LAST_ADMIN'). They are recreated by the restore rebuild after the candidate database is rebuilt — see Admin System Endpoints — POST /restore/commit.
First-admin bootstrap
When the user table is empty of admins, the dedicated
POST /api/v1/setup/create-admin endpoint (see
Setup Endpoint) 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").