Files
HIPCTF2/docs/api/setup.md
T
2026-07-21 17:38:20 +00:00

107 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 | 332 chars, regex `^[a-zA-Z0-9_.-]+$` |
| `password` | string | 1256 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)