3.5 KiB
3.5 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | ||||
|---|---|---|---|---|---|---|---|---|
| api | Setup Endpoint | Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists. |
|
2026-07-21T14:43:00Z |
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)
{
"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). |
| 404 | NOT_FOUND |
An admin already exists — the route is hidden once initialized. |
| 409 | USERNAME_TAKEN |
Username collision (reachable only during a race before init). |
| 429 | RATE_LIMITED |
RegistrationRateLimitService blocked the IP. |
Wiring
- Registered in
backend/src/app.module.tsviaSetupModule. SetupController(backend/src/modules/setup/setup.controller.ts) is@Public()and usesZodValidationPipe(CreateAdminDtoSchema).SetupService.createAdminruns everything inside a singleDataSource.transaction: admin count check → username uniqueness check → Argon2id hash → insertUserEntity(role='admin', status='enabled')→ mint session via the staticAuthService.createSession.- CSRF is bypassed for
/api/v1/setup/create-adminso the first caller (before any cookie exists) is not blocked. The middleware still mints acsrfcookie on the response so subsequent calls are covered.
Frontend consumer
frontend/src/app/features/setup/setup-create-admin.service.ts calls
this endpoint and normalizes the response into the discriminated union
CreateAdminResult:
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.