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

4.9 KiB
Raw Blame History

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.
api
setup
bootstrap
first-admin
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)

{
  "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:

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