107 lines
4.9 KiB
Markdown
107 lines
4.9 KiB
Markdown
---
|
||
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:37:18Z
|
||
---
|
||
|
||
# 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": "<jwt>",
|
||
"expiresIn": 900,
|
||
"user": { "id": "<uuid>", "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) |