--- type: api title: Setup Endpoint description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists. tags: [api, setup, bootstrap, first-admin] timestamp: 2026-07-21T17:18:08Z --- # Endpoint | Method | Path | Auth | CSRF | Rate limited | |--------|-------------------------------|------|--------|--------------| | `POST` | `/api/v1/setup/create-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`) | Yes (per-IP via `RegistrationRateLimitService`) | # Request body Validated by `backend/src/modules/setup/dto/create-admin.dto.ts` (zod): | Field | Type | Constraints | |-------------------|--------|------------------------------------------------------------------------------| | `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` | | `password` | string | 1–256 chars; additionally passes `validatePassword` (Argon2 policy). | | `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED`, `passwordConfirm`).| # Successful response (201) ```json { "accessToken": "", "expiresIn": 900, "user": { "id": "", "username": "first.admin", "role": "admin" } } ``` The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, session.refreshToken, config)` so the SPA can keep refreshing without storing the raw token in JS. # Error envelope | HTTP | `code` | When | |------|-----------------------|----------------------------------------------------------------------| | 400 | `VALIDATION_FAILED` | zod body validation failed (length, regex, or `passwordConfirm`). | | 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password (length / mixed-case requirement). | | 409 | `SYSTEM_INITIALIZED` | An admin already exists. Returned to any request that arrives after bootstrap completes (also returned to the loser of a concurrent race — see *Concurrency* below). | | 409 | `USERNAME_TAKEN` | Username collision (defensive: reachable only if two concurrent requests with the *same* username somehow slip past the in-process lock). | | 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. | # Wiring * Registered in `backend/src/app.module.ts` via `SetupModule`. * `SetupController` (`backend/src/modules/setup/setup.controller.ts`) is `@Public()` and uses `ZodValidationPipe(CreateAdminDtoSchema)`. * `SetupService.createAdmin` runs everything inside a single `DataSource.transaction`: admin count check → username uniqueness check → Argon2id hash → insert `UserEntity(role='admin', status='enabled')` → mint session via the static `AuthService.createSession`. * CSRF is bypassed for `/api/v1/setup/create-admin` so the first caller (before any cookie exists) is not blocked. The middleware still mints a `csrf` cookie on the response so subsequent calls are covered. # Concurrency `SetupService` serializes bootstrap attempts inside a single Nest process via an in-process promise chain (`#bootstrapChain` + private `runBootstrap()`). Every caller runs its transaction exactly once; the chain never short-circuits, so validation and rate-limit semantics stay identical to the single-request case. Behavior under contention: 1. Two simultaneous first-admin POSTs enter `runBootstrap()`. 2. The first request runs its transaction and creates the admin row. 3. The second request waits on the first, then runs its own transaction; the `adminCount > 0` guard inside the transaction fires and it throws `SYSTEM_INITIALIZED` (HTTP 409). 4. Exactly one admin row and one refresh-token row exist afterwards. The lock is **per-process**; it does not coordinate multiple Nest instances behind a load balancer. Multi-replica deployments still rely on the unique-index/transaction guard, not on this chain. The matching test lives at `tests/backend/setup-create-admin.spec.ts` ("only one of two concurrent first-admin requests succeeds; the other receives a controlled conflict"). # Frontend consumer `frontend/src/app/features/setup/setup-create-admin.service.ts` calls this endpoint and normalizes the response into the discriminated union `CreateAdminResult`: ```ts type CreateAdminResult = CreateAdminSuccess | CreateAdminFailure; ``` `CreateAdminFailure` exposes `{ ok: false, status, code, message }` so the component can switch on `code === 'USERNAME_TAKEN'` to surface a non-retryable inline error and offer a Retry button for everything else. # See also - [First-Run Bootstrap Guide](/guides/bootstrap.md) - [Setup Form Field Errors](/guides/setup-field-errors.md) - [Backend Module Map](/architecture/backend-modules.md) - [Bootstrap Service](/architecture/frontend-structure.md)