AI Implementation feature(846): First-Start Create Admin Modal 1.05 #8

Merged
m0rph3us1987 merged 2 commits from feature-846-1784653758959 into dev 2026-07-21 17:18:55 +00:00
8 changed files with 208 additions and 203 deletions
-147
View File
@@ -1,147 +0,0 @@
# Implementation Plan: Job 845 — First-Start Create Admin Modal 1.04 (surface field validation)
## 1. Architectural Reconnaissance
- **Codebase style & conventions:**
- NestJS API + Angular 17 SPA monorepo (workspaces in `package.json`).
- Frontend: **Standalone components**, `ChangeDetectionStrategy.OnPush`, Angular **signals** (`signal`/`computed`), no NgModules. Style guide mandates `OnPush` and signals for state.
- Reactive Forms via `ReactiveFormsModule` with `fb.nonNullable.group(...)` (non-nullable typed controls).
- All services are `providedIn: 'root'`. Two HTTP interceptors wired in `frontend/src/main.ts` (`csrfInterceptor`, then `authInterceptor`).
- Frontend tests are **pure-logic / no TestBed**: `tests/frontend/**/*.spec.ts` import pure functions (validators, session-storage helpers, shell decision helpers) and rely on `jest` (`testEnvironment: 'jsdom'`) runnable with the root command `npm test` (which uses `jest --config tests/jest.config.js --selectProjects frontend`).
- Strictly-typed reactive forms per `angular-forms` skill (`FormGroup<T>`, `fb.nonNullable`).
- Data attributes / `data-testid` selectors are the stable DOM hooks (see `docs/guides/bootstrap.md`).
- **Data Layer:** N/A for this Job — purely a frontend UI/validation correctness fix. The backend already returns a structured `400 VALIDATION_FAILED` envelope; the bug is solely that the SPA short-circuits on `form.invalid` without ever surfacing per-field error messages.
- **Test Framework & Structure:**
- Jest with `ts-jest`, jsdom environment for frontend.
- Tests live **only** in `tests/frontend/` (never co-located with source).
- Root command: `npm test` (runs all projects via `jest --config tests/jest.config.js`).
- Existing precedent at `tests/frontend/setup-create-admin.spec.ts` is a pure-logic suite (mirrors component logic in standalone helper functions). **We will follow that pattern** — no TestBed, no DOM rendering required, no visual confirmation.
- **Required Tools & Dependencies:** No new dependencies, system tools, or `setup.sh` changes needed. The fix is a localized source-code change in the standalone component + its template + a pure-helper module that mirrors the per-field error messages (for parity testing). The npm workspace already has `@angular/forms ^17.3` and `jest`.
## 2. Impacted Files
### To Modify
- **`frontend/src/app/features/setup/setup-create-admin.component.ts`** — fix the reactivity of the per-field error calculations and expose `passwordErrors()`. Convert the `computed(...)`s whose bodies read `FormControl` properties (which are NOT signals in Angular 17) into plain **methods** so the template re-evaluates them on every change-detection cycle (cheap, OnPush-friendly, no extra subscriptions). Alternatively keep them as computed but reflect `touched` into a local `signal()` driven by `statusChanges`/`valueChanges` + an effect; **plain methods are the minimal, idiomatic fix** and are used elsewhere in the same component style. Keep all existing public surface (`form`, `submitting`, `serverError`, `serverErrorCode`, `canRetry`, `policyDescription`, `submit`, `swallowBackdrop`, `swallowEscape`) intact.
- **`frontend/src/app/features/setup/setup-create-admin.component.html`** — render the new Password field-error block, mirror `aria-invalid` on the Password and Confirm Password inputs (Username already has it), and keep the `data-testid` hooks the bootstrap guide documents (`setup-username-error`, add `setup-password-error`, keep `setup-confirm-error`).
- **`frontend/src/app/features/setup/setup-create-admin.component.css`** — no functional change required; `.field-error` styling already exists.
- **`tests/frontend/setup-create-admin.spec.ts`** — extend the existing pure-logic suite to cover the new password-error helper and to assert that, given an all-empty (or partially-filled) post-submit state, the per-field error strings the template renders are correct.
### To Create
- **`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`** — pure helper functions that return the per-field error string (or `null`) for a given `(value, touched)` snapshot. Keeping them as pure functions (no Angular DI, no signals) is deliberate: it lets the component call them from methods **and** lets the pure-logic test suite assert them without spinning up Angular. Mirrors the pattern already used by `setup-create-admin.validators.ts` and tested in `setup-create-admin.spec.ts`.
## 3. Proposed Changes
### Root cause
Angular 17 `FormControl.touched`, `.valid`, and `.errors` are plain class properties, **not** signals. The existing `computed(() => … usernameErrors …)` (component file lines 7078 and 8087) reads those plain properties and so registers **zero signal dependencies**. Even after `submit()` calls `form.markAllAsTouched()`, the computed is not re-evaluated — its cached `null` is returned and the `@if (usernameErrors()) { … }` block stays hidden. The same applies to `confirmErrors()` for the `passwordConfirm` control. There is also no `passwordErrors()` at all and no `aria-invalid` binding on the Password/Confirm inputs, which matches the user's observation.
### 3.1 New pure helper module
`frontend/src/app/features/setup/setup-create-admin.field-errors.ts`:
```ts
export type FieldErrorKey =
| 'required'
| 'minlength'
| 'maxlength'
| 'pattern'
| 'passwordMismatch';
export interface FieldErrorContext {
value: string;
touched: boolean;
errors: Partial<Record<FieldErrorKey, true>> | null;
groupMismatch?: boolean;
}
export function usernameError(ctx: FieldErrorContext): string | null { }
export function passwordError(ctx: FieldErrorContext): string | null { } // currently required-only; password POLICY is server-enforced
export function confirmError(ctx: FieldErrorContext): string | null { }
```
Rules encoded (single source of truth, identical to current component intent):
- **Username:**
- not touched or no errors → `null`
- `required``'Username is required'`
- `minlength``'Username must be at least 3 characters'`
- `maxlength``'Username must be at most 32 characters'`
- `pattern``'Username may only contain letters, digits, dot, dash and underscore'`
- else → `'Invalid username'`
- **Password:**
- not touched or no errors → `null`
- `required``'Password is required'`
- else → `null` (policy is intentionally server-side only; this component never surfaces a "weak password" field error pre-submit)
- **Confirm:**
- not touched and no `passwordMismatch` group error → `null`
- `required``'Please confirm your password'`
- `passwordMismatch` (group-level) → `'Passwords do not match'`
- else → `'Invalid value'`
These signatures accept the small slice of state they need so the component can pass `c.value`, `c.touched`, and `c.errors` (cast through `Partial<Record<…, true>>`) and so the test suite can call them directly without touching Angular.
### 3.2 Component changes
`setup-create-admin.component.ts`:
1. **Replace the two `computed(...)`s with plain methods** so the template re-renders them on every CD pass:
- `usernameError(): string | null { return usernameError({ value: this.form.controls.username.value, touched: this.form.controls.username.touched, errors: this.form.controls.username.errors }); }`
- `passwordError(): string | null { return passwordError({ value: this.form.controls.password.value, touched: this.form.controls.password.touched, errors: this.form.controls.password.errors }); }`**new**
- `confirmError(): string | null { return confirmError({ value: this.form.controls.passwordConfirm.value, touched: this.form.controls.passwordConfirm.touched, errors: this.form.controls.passwordConfirm.errors, groupMismatch: !!this.form.errors?.['passwordMismatch'] }); }`
2. **Delete the orphaned `usernameErrors` and `confirmErrors` signal properties** (template rewires to methods).
3. Leave `submit()` exactly as-is: `markAllAsTouched()` then early-return on invalid. The early return is now correct and observable (because `…Error()` is re-evaluated by the next CD cycle that runs after `markAllAsTouched` synchronously mutates `touched` on every control). No new `effect()` needed; this is the minimal fix.
4. Keep OnPush (`ChangeDetectionStrategy.OnPush`), keep all existing imports/decorators/HostListener for `escape` and `swallowBackdrop`. The host binding keyboard handler stays so the modal remains non-dismissible.
> Optional, NOT recommended for this Job (kept out of scope to honor "minimal changes"): a reactive alternative would be to subscribe to `statusChanges` / `valueChanges` via `toSignal()` in the constructor and gate rendering on those signals. The plain-method approach above is the minimum-surface fix that fully resolves the bug and matches the component's current style.
### 3.3 Template changes
`setup-create-admin.component.html`:
1. Username `<input>` — keep existing `[attr.aria-invalid]="!!usernameError()"` and existing `<small class="field-error">` block, but rebind to the new method name.
2. Password `<label class="field">` — add `aria-invalid` and add the missing error block below the policy hint:
```html
<input … formControlName="password"
[attr.aria-invalid]="!!passwordError()"
data-testid="setup-password" />
<small class="hint">{{ policyDescription() }}</small>
@if (passwordError()) {
<small class="field-error" data-testid="setup-password-error">{{ passwordError() }}</small>
}
```
3. Confirm `<input>` — add `aria-invalid`:
```html
<input … formControlName="passwordConfirm"
[attr.aria-invalid]="!!confirmError()"
data-testid="setup-password-confirm" />
@if (confirmError()) {
<small class="field-error" data-testid="setup-confirm-error">{{ confirmError() }}</small>
}
```
4. Submit button — **unchanged**. The Job accepts either "visible per-field error messages" **or** "disabled submit button until valid" — we keep the click-to-trigger-feedback behavior (already used by `markAllAsTouched`) and now surface real errors, which is the original design intent. We deliberately do **not** disable the button on invalid (preserves parity with the existing guide which says the button is "disabled only while submitting").
### 3.4 CSS
No changes. `.field-error` color/size is already defined.
### 3.5 No backend changes
The backend's `SetupController.createAdmin` already responds with `400 VALIDATION_FAILED` and a `details` array; that contract is unchanged. The client never reached it before, and after the fix the client still won't for an empty form (which is correct — the per-field client-side errors are now visible; the server is reachable when the client actually submits valid input).
## 4. Test Strategy
- **Target Unit Test File:** `tests/frontend/setup-create-admin.spec.ts` (extend the existing pure-logic file; do **not** create a co-located spec under `frontend/`).
- The existing file already exports nothing new — it lives entirely in `tests/frontend/` per project convention and runs with the root command `npm test` (Jest, jsdom).
- **Mocking Strategy:**
- **No TestBed, no HTTP, no DOM rendering.** The new helpers (`usernameError`, `passwordError`, `confirmError`) are pure functions in `setup-create-admin.field-errors.ts` — they are imported and called directly with hand-built `FieldErrorContext` literals, mirroring the existing `usernameErrors`/`isFormReadyForSubmit` helpers in the same file.
- The existing `passwordMatchValidator` tests and username rules act as the de-facto model; we extend the "Composed submit gate" suite to also assert the rendered error strings.
- **New test cases (≤ 8, focused on the bug paths):**
1. `usernameError({ value: '', touched: true, errors: { required: true } })` returns `'Username is required'`.
2. `usernameError({ value: 'ab', touched: true, errors: { minlength: true } })` returns the minlength message.
3. `passwordError({ value: '', touched: true, errors: { required: true } })` returns `'Password is required'`.
4. `passwordError({ value: 'x', touched: true, errors: null })` returns `null` (policy is server-side only; no pre-submit hint).
5. `confirmError({ value: '', touched: true, errors: { required: true } })` returns `'Please confirm your password'`.
6. `confirmError({ value: 'a', touched: true, errors: null, groupMismatch: true })` returns `'Passwords do not match'`.
7. `confirmError({ value: 'a', touched: false, errors: null })` returns `null` (no spurious visibility pre-touch).
8. `usernameError({ value: '', touched: false, errors: { required: true } })` returns `null` (no spurious visibility pre-touch).
- **How to run:** `npm test` (root). Selectors: `npm run test:frontend` for fast feedback. No browser, no screenshot, no Visual regression.
- **Out of scope (explicitly):** TestBed rendering, HTTP mock for `SetupCreateAdminService.create`, fixture-based aria assertion, and end-to-end visual confirmation — all forbidden by the test rules.
+52
View File
@@ -0,0 +1,52 @@
# Implementation Plan: First-Start Create Admin Modal 1.05
## 1. Architectural Reconnaissance
- **Status:** The requested race-condition fix is **not fully implemented**. `backend/src/modules/setup/setup.service.ts:58-93` performs the admin count check inside a transaction, but two transactions can both observe zero admins before the Argon2 hash and subsequent insert. Existing tests cover serial creation and post-initialization rejection, but not simultaneous different-user submissions or refresh-token cardinality.
- **Codebase style & conventions:** TypeScript monorepo using NestJS 10, Angular 17 standalone components, async/await, dependency injection, Zod request validation, TypeORM repositories/transactions, and centralized `ApiError`/global exception formatting. Controllers are thin; bootstrap business logic belongs in `SetupService`.
- **Data Layer:** SQLite through `better-sqlite3`, accessed with TypeORM and explicit migrations (`synchronize: false`). The `user` table has a unique username but no invariant-enforcing uniqueness constraint for the `role` value. Database startup runs migrations and enforces WAL mode. The setup mutation and refresh-token creation already share one TypeORM transaction.
- **Test Framework & Structure:** Root Jest multi-project configuration (`tests/jest.config.js`) runs backend Node tests from `tests/backend/**/*.spec.ts` and frontend jsdom tests from `tests/frontend/**/*.spec.ts`; tests are kept outside source directories and run with `npm test`. Backend integration tests use Nest `Test.createTestingModule`, the real SQLite TypeORM database, `supertest` via `csrfClient`, and cleanup in `beforeEach`/`afterAll`.
- **Required Tools & Dependencies:** No new package or system dependency should be required. Continue using the existing Node/npm toolchain, NestJS, TypeORM, `better-sqlite3`, Argon2, Jest, and Supertest. `setup.sh` already installs dependencies and builds both workspaces; do not add infrastructure unless the chosen SQLite locking approach requires an existing package (the preferred plan does not). If a migration is added, it is TypeScript and is included by the existing database migration registration rather than requiring a new CLI.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/setup/setup.service.ts` — serialize or otherwise atomically coordinate first-admin bootstrap attempts so only one request can pass the empty-admin gate and complete user/session creation.
- `tests/backend/setup-create-admin.spec.ts` — add one focused concurrent-submission regression test asserting one success, one controlled conflict/business-rule failure, exactly one admin, and exactly one refresh-token row.
- `docs/api/setup.md` — document the concurrency guarantee and the structured losing-request response if the public contract changes from the currently documented 404/USERNAME_TAKEN cases.
- `docs/database/users.md` and/or `docs/guides/bootstrap.md` — update the first-admin invariant description to explicitly cover concurrent requests, if documentation is maintained as part of implementation.
- `backend/src/common/errors/error-codes.ts` — only if implementation introduces a dedicated bootstrap-race error code rather than reusing the existing `SYSTEM_INITIALIZED` or `NOT_FOUND` code.
- **To Create:**
- No new application source file is required by the preferred design.
- A new migration file is not expected unless the implementer chooses a database-level sentinel/lock-row design; if that design is selected, create a timestamped migration under `backend/src/database/migrations/` and register it in `backend/src/database/database.module.ts`.
## 3. Proposed Changes
Explain the exact implementation logic step-by-step:
1. **Database / Schema Migration:**
- Prefer no schema change: the existing transaction and SQLite WAL database are sufficient if the setup critical section is serialized before the admin count check.
- Implement a process-local, promise-based mutex/critical-section guard owned by `SetupService` (or an equivalent injected singleton lock service) around the entire first-admin check-to-commit path. The lock must be acquired before `dataSource.transaction` performs the admin count query and released in a `finally` block on success, validation failure, database failure, or controlled conflict.
- Keep the password equality, password-policy validation, rate-limit check, and Argon2 hashing semantics unchanged. The critical requirement is that the second request cannot execute the empty-admin check until the first transaction has either committed the admin and refresh token or rolled back.
- Once the first transaction commits, the next request rechecks the admin count and exits without hashing, creating a user, or minting a refresh token. Return a controlled business-rule response. The cleanest contract is the existing `SYSTEM_INITIALIZED` 409 already used by the legacy first-admin endpoint; alternatively retain the setup endpoint's existing `NOT_FOUND` 404 contract if compatibility is more important. Whichever status/code is selected must be consistent and explicitly tested/documented.
- Do not rely on the username unique constraint alone: concurrent requests intentionally use different usernames, so that constraint cannot enforce the first-admin invariant. Do not rely on the current unlocked count query or on Argon2's synchronous blocking behavior.
- If deployment topology may include multiple Node processes/workers, document that an in-process mutex only protects one process. For a true multi-process guarantee, replace it with a database-backed lock/sentinel approach: add one immutable bootstrap lock row/table via migration, acquire it using SQLite's write serialization/transaction semantics before counting admins, then perform the check, hash, insert, and session mint in the same transaction. Validate the chosen approach against the repository's single-process topology before implementation; the minimum Job reproduction is within one Nest process.
2. **Backend Logic & APIs:**
- Update `SetupService.createAdmin` so every path that can mutate bootstrap state is within the same serialized/atomic boundary. Preserve the existing transaction callback ordering: count admins, reject initialized systems, check username, hash password, save enabled admin, record rate limit, and call `AuthService.createSession` using the transaction manager.
- Ensure lock release occurs after the transaction promise settles, not immediately after starting it. This prevents the second request from observing an uncommitted or partially completed first request.
- Normalize the losing request through `ApiError` with a stable structured `code`, message, and HTTP status. Do not expose raw SQLite `SQLITE_BUSY`/constraint errors. If a database-level locking strategy is used, catch/map expected lock contention to the same business-rule error and ensure failed transactions cannot leave a user or refresh-token row behind.
- Leave `SetupController` as a thin delegator. It should continue to derive the IP, call the service, set the HttpOnly refresh cookie only on a successful session, and return the existing 201 response shape for the winner.
- Keep frontend behavior unchanged unless the selected loser code is not already handled. `SetupCreateAdminService` already preserves status/code from the API, while `SetupCreateAdminComponent` treats only `USERNAME_TAKEN` as non-retryable and presents a generic retryable error for other codes. If `SYSTEM_INITIALIZED` is chosen, update the component/service contract only if UX should stop retrying after another caller wins; otherwise document that a concurrent loser should be redirected/reloaded rather than repeatedly retrying a now-initialized setup route.
- Update the setup API documentation to state: among simultaneous valid first-admin requests, exactly one may return 201; all others receive the chosen structured conflict/business-rule response; only one enabled admin and one refresh-token row are created.
3. **Frontend UI Integration:**
- No visual/modal redesign is required. The modal already disables duplicate clicks within one component instance (`submitting` signal), but that does not protect independent browser requests; the invariant must remain server-side.
- If backend returns `SYSTEM_INITIALIZED`/409 for the losing request, decide and implement the smallest compatible handling: mark the setup flow unavailable and route to login/bootstrap reload, or retain the existing generic failure presentation. Add a frontend logic test only if a changed error-code branch is introduced; do not add visual/browser tests for this backend race fix.
## 4. Test Strategy
- **Target Unit Test File:** `tests/backend/setup-create-admin.spec.ts`.
- Add one minimal regression test using the existing initialized Nest app and real in-memory SQLite database. Clear both users and refresh tokens before the test (the current `beforeEach` only clears users, so the new test must explicitly clean refresh tokens or use a safe test isolation arrangement).
- Arrange two independent valid requests with different usernames and invoke them concurrently with `Promise.all` through separate `csrfClient` instances or equivalent Supertest agents. Avoid mocking Argon2, TypeORM, or the database: the bug is transaction/concurrency behavior and must be verified at the HTTP/service boundary.
- Assert that the result statuses contain exactly one `201` and exactly one expected controlled loser status (normally `409`, or `404` if compatibility-preserving behavior is selected). Assert the loser body has the exact structured error `code`; do not assert a generated token value.
- Assert database postconditions through repositories: exactly one row with `role: 'admin'`, exactly one enabled first-admin user overall, and exactly one row in `refresh_token`. Verify the winning username is one of the two submitted names and the losing username is absent. This directly guards against both duplicate admins and duplicate sessions.
- Retain the existing focused tests for winner success, serial post-initialization rejection, weak passwords, mismatch, and invalid usernames. Update only assertions/comments that become stale if the selected concurrency error code changes.
- Follow AAA structure, clear mocks if any are introduced, close the Nest app, and ensure no shared persistent data is written. Use the existing `DATABASE_PATH=':memory:'`; `/data` is unnecessary because the test does not create successor-job assets or persistent fixtures.
- Run the repository's single root command `npm test` during implementation, plus the project's available build/type validation commands discovered from package scripts. There is no lint or typecheck script in the inspected root/backend package manifests; do not invent one. `setup.sh` needs no update for the planned implementation.
+38 -2
View File
@@ -20,6 +20,7 @@ const REFRESH_TTL_DEFAULT = 7 * 24 * 60 * 60;
export class SetupService implements OnModuleInit { export class SetupService implements OnModuleInit {
private readonly logger = new Logger(SetupService.name); private readonly logger = new Logger(SetupService.name);
private refreshTtlSeconds = REFRESH_TTL_DEFAULT; private refreshTtlSeconds = REFRESH_TTL_DEFAULT;
private bootstrapChain: Promise<unknown> = Promise.resolve();
constructor( constructor(
@InjectRepository(UserEntity) private readonly users: Repository<UserEntity>, @InjectRepository(UserEntity) private readonly users: Repository<UserEntity>,
@@ -55,10 +56,11 @@ export class SetupService implements OnModuleInit {
); );
} }
return this.dataSource.transaction(async (manager) => { return this.runBootstrap(() =>
this.dataSource.transaction(async (manager) => {
const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } }); const adminCount = await manager.count(UserEntity, { where: { role: 'admin' } });
if (adminCount > 0) { if (adminCount > 0) {
throw ApiError.notFound('Setup endpoint not available'); throw new ApiError(ERROR_CODES.SYSTEM_INITIALIZED, 'System already initialized', 409);
} }
const existing = await manager.findOne(UserEntity, { where: { username } }); const existing = await manager.findOne(UserEntity, { where: { username } });
@@ -90,7 +92,41 @@ export class SetupService implements OnModuleInit {
{ jwt: this.jwt, config: this.config, refreshTtlSeconds: this.refreshTtlSeconds }, { jwt: this.jwt, config: this.config, refreshTtlSeconds: this.refreshTtlSeconds },
user, user,
); );
}),
);
}
/**
* Serialize first-admin bootstrap attempts within this Nest process so
* that two concurrent callers cannot both observe an empty admin table
* and create two enabled admins. The winner is whichever call the
* promise chain reaches first; subsequent callers wait for that call to
* settle, then re-execute and observe the now-non-empty admin table and
* receive a 409 SYSTEM_INITIALIZED error.
*
* The chain never short-circuits: every caller runs its callback exactly
* once, which keeps validation/rate-limit semantics identical to the
* single-request case while still guaranteeing serialized execution.
*/
private async runBootstrap<T>(task: () => Promise<T>): Promise<T> {
const previous = this.bootstrapChain;
let release: () => void = () => {};
const gate = new Promise<void>((resolve) => {
release = resolve;
}); });
this.bootstrapChain = previous.then(() => gate, () => gate);
try {
await previous;
} catch {
// Swallow previous bootstrap errors so they do not poison the chain;
// the throwing caller already observed its own error.
}
try {
return await task();
} finally {
release();
}
} }
private parseTtl(ttl: string): number { private parseTtl(ttl: string): number {
+30 -4
View File
@@ -3,7 +3,7 @@ type: api
title: Setup Endpoint title: Setup Endpoint
description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists. description: Dedicated POST /api/v1/setup/create-admin endpoint used only while no administrator exists.
tags: [api, setup, bootstrap, first-admin] tags: [api, setup, bootstrap, first-admin]
timestamp: 2026-07-21T16:48:20Z timestamp: 2026-07-21T17:18:08Z
--- ---
# Endpoint # Endpoint
@@ -39,11 +39,11 @@ refreshing without storing the raw token in JS.
# Error envelope # Error envelope
| HTTP | `code` | When | | HTTP | `code` | When |
|------|---------------------|----------------------------------------------------------------------| |------|-----------------------|----------------------------------------------------------------------|
| 400 | `VALIDATION_FAILED` | zod body validation failed (length, regex, or `passwordConfirm`). | | 400 | `VALIDATION_FAILED` | zod body validation failed (length, regex, or `passwordConfirm`). |
| 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password (length / mixed-case requirement). | | 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 | `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 (reachable only during a race before init). | | 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. | | 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. |
# Wiring # Wiring
@@ -59,6 +59,32 @@ refreshing without storing the raw token in JS.
(before any cookie exists) is not blocked. The middleware still mints (before any cookie exists) is not blocked. The middleware still mints
a `csrf` cookie on the response so subsequent calls are covered. 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 consumer
`frontend/src/app/features/setup/setup-create-admin.service.ts` calls `frontend/src/app/features/setup/setup-create-admin.service.ts` calls
+2 -2
View File
@@ -25,7 +25,7 @@ also registers two global providers:
| `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). | | `SettingsModule` | `backend/src/modules/settings/settings.module.ts` | Exposes `SettingsService` (get/set/getAll over `setting` table). |
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).| | `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/*`) + `AuthService` (login/refresh/logout/register-first-admin).|
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). | | `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant). |
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 404 once any admin exists (route hidden). | | `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | `AdminController` (`/api/v1/admin/users*`) + `AdminService`. Gated by `AdminGuard` + `@Roles('admin')`. |
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
@@ -37,7 +37,7 @@ also registers two global providers:
|------------------------|-------------------------|--------------|--------------------------------------------------------------| |------------------------|-------------------------|--------------|--------------------------------------------------------------|
| `AuthController` | `/api/v1/auth` | Mostly public (login, refresh, logout, csrf) | `backend/src/modules/auth/auth.controller.ts` | | `AuthController` | `/api/v1/auth` | Mostly public (login, refresh, logout, csrf) | `backend/src/modules/auth/auth.controller.ts` |
| `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` | | `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` |
| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 404 once an admin exists) | `backend/src/modules/setup/setup.controller.ts` | | `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 409 `SYSTEM_INITIALIZED` once an admin exists) | `backend/src/modules/setup/setup.controller.ts` |
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` | | `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` | | `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
+8 -5
View File
@@ -3,7 +3,7 @@ type: guide
title: First-Run Bootstrap title: First-Run Bootstrap
description: How a fresh HIPCTF instance is initialized by the very first administrator. description: How a fresh HIPCTF instance is initialized by the very first administrator.
tags: [guide, bootstrap, first-admin, onboarding, tester] tags: [guide, bootstrap, first-admin, onboarding, tester]
timestamp: 2026-07-21T16:48:20Z timestamp: 2026-07-21T17:18:08Z
--- ---
# When this flow runs # When this flow runs
@@ -15,12 +15,15 @@ to create the very first admin user. This is gated by the
| `initialized` | User experience | | `initialized` | User experience |
|---------------|---------------------------------------------------------------| |---------------|---------------------------------------------------------------|
| `false` | Every route is redirected to `/bootstrap` until an admin is created. | | `false` | Every route is redirected to `/bootstrap` until an admin is created. |
| `true` | The setup route becomes a 404 and the modal can never appear again. | | `true` | The setup endpoint becomes a 409 `SYSTEM_INITIALIZED` and the modal can never appear again. |
The `authGuard` enforces the redirect on the client side; the backend The `authGuard` enforces the redirect on the client side; the backend
independently rejects `/api/v1/setup/create-admin` with `404 NOT_FOUND` independently rejects `/api/v1/setup/create-admin` with
once any admin row exists, so the flow is safe even if the SPA is `409 SYSTEM_INITIALIZED` once any admin row exists, so the flow is safe
bypassed. even if the SPA is bypassed. Concurrent first-admin requests are
serialized inside the Nest process so that two simultaneous submissions
cannot both succeed — the loser receives the same `SYSTEM_INITIALIZED`
409 (see [Setup Endpoint — Concurrency](/api/setup.md)).
# How to access (tester steps) # How to access (tester steps)
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-21T16:48:20Z. they need. Last regenerated 2026-07-21T17:18:08Z.
# Architecture # Architecture
+45 -10
View File
@@ -12,11 +12,14 @@ import { GlobalExceptionFilter } from '../../backend/src/common/filters/global-e
import { csrfClient } from './csrf-client'; import { csrfClient } from './csrf-client';
import { initDb } from './db-helper'; import { initDb } from './db-helper';
import { UserEntity } from '../../backend/src/database/entities/user.entity'; import { UserEntity } from '../../backend/src/database/entities/user.entity';
import { RefreshTokenEntity } from '../../backend/src/database/entities/refresh-token.entity';
import { SetupService } from '../../backend/src/modules/setup/setup.service'; import { SetupService } from '../../backend/src/modules/setup/setup.service';
import { ERROR_CODES } from '../../backend/src/common/errors/error-codes';
describe('Setup: POST /api/v1/setup/create-admin', () => { describe('Setup: POST /api/v1/setup/create-admin', () => {
let app: INestApplication; let app: INestApplication;
let users: Repository<UserEntity>; let users: Repository<UserEntity>;
let refreshTokens: Repository<RefreshTokenEntity>;
let setupService: SetupService; let setupService: SetupService;
beforeAll(async () => { beforeAll(async () => {
@@ -28,6 +31,7 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
await app.init(); await app.init();
await initDb(app); await initDb(app);
users = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity)); users = app.get<Repository<UserEntity>>(getRepositoryToken(UserEntity));
refreshTokens = app.get<Repository<RefreshTokenEntity>>(getRepositoryToken(RefreshTokenEntity));
setupService = app.get(SetupService); setupService = app.get(SetupService);
}); });
@@ -36,6 +40,7 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await refreshTokens.clear();
await users.clear(); await users.clear();
}); });
@@ -58,7 +63,7 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
expect(adminCount).toBe(1); expect(adminCount).toBe(1);
}); });
it('returns 404 once an admin already exists (route is hidden)', async () => { it('returns 409 SYSTEM_INITIALIZED once an admin already exists', async () => {
const c = csrfClient(app); const c = csrfClient(app);
await c await c
.post('/api/v1/setup/create-admin') .post('/api/v1/setup/create-admin')
@@ -68,24 +73,24 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
const res = await c const res = await c
.post('/api/v1/setup/create-admin') .post('/api/v1/setup/create-admin')
.send({ username: 'second', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }); .send({ username: 'second', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' });
expect(res.status).toBe(404); expect(res.status).toBe(409);
expect(res.body).toBeDefined(); expect(res.body.code).toBe(ERROR_CODES.SYSTEM_INITIALIZED);
}); });
it('SetupService would map a username collision to USERNAME_TAKEN (reached only during a race before init)', () => { it('SetupService would map a username collision to USERNAME_TAKEN (reached only during a race before init)', () => {
// The current ordering (route hidden after init) means USERNAME_TAKEN is // With the in-process bootstrap serialization in place, USERNAME_TAKEN
// reachable only when two requests enter the transaction concurrently // remains a defensive code: it is reachable only if two concurrent
// before any admin exists. Verify the error code is present in the // requests with the same username somehow both slip past the lock.
// error-codes enum so the frontend can switch on it. // Verify the code is present in the error-codes enum so the frontend
const { ERROR_CODES } = require('../../backend/src/common/errors/error-codes'); // can switch on it.
expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN'); expect(ERROR_CODES.USERNAME_TAKEN).toBe('USERNAME_TAKEN');
}); });
it('SetupService throws not-found (route hidden) after init', async () => { it('SetupService throws SYSTEM_INITIALIZED (409) after init', async () => {
await setupService.createAdmin('init', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'); await setupService.createAdmin('init', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1');
await expect( await expect(
setupService.createAdmin('newone', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'), setupService.createAdmin('newone', 'Sup3rSecret!Pass', 'Sup3rSecret!Pass', '127.0.0.1'),
).rejects.toMatchObject({ code: 'NOT_FOUND' }); ).rejects.toMatchObject({ code: ERROR_CODES.SYSTEM_INITIALIZED });
}); });
it('rejects weak password with WEAK_PASSWORD', async () => { it('rejects weak password with WEAK_PASSWORD', async () => {
@@ -112,4 +117,34 @@ describe('Setup: POST /api/v1/setup/create-admin', () => {
.send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }) .send({ username: 'bad name!', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' })
.expect(400); .expect(400);
}); });
it('only one of two concurrent first-admin requests succeeds; the other receives a controlled conflict', async () => {
// Two independent Supertest agents simulate two separate browser
// connections that arrive within the same event-loop tick and share no
// cookies, mirroring the production race reproduction.
const a = csrfClient(app);
const b = csrfClient(app);
const [resA, resB] = await Promise.all([
a
.post('/api/v1/setup/create-admin')
.send({ username: 'adminAlpha', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }),
b
.post('/api/v1/setup/create-admin')
.send({ username: 'adminBeta', password: 'Sup3rSecret!Pass', passwordConfirm: 'Sup3rSecret!Pass' }),
]);
const statuses = [resA.status, resB.status].sort();
expect(statuses).toEqual([201, 409]);
const loser = resA.status === 409 ? resA : resB;
expect(loser.body.code).toBe(ERROR_CODES.SYSTEM_INITIALIZED);
const admins = await users.find({ where: { role: 'admin' } });
expect(admins).toHaveLength(1);
expect(['adminAlpha', 'adminBeta']).toContain(admins[0].username);
const sessions = await refreshTokens.count();
expect(sessions).toBe(1);
});
}); });