From 81fd3d8a29da842e90de49e92e37a016c9224562 Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Tue, 21 Jul 2026 14:45:52 +0000 Subject: [PATCH] docs: update documentation to OKF v0.1 format --- docs/api/rest-overview.md | 84 +++++++++++++++++++ docs/api/setup.md | 80 ++++++++++++++++++ docs/architecture/backend-modules.md | 4 +- docs/architecture/frontend-structure.md | 6 +- docs/architecture/key-files.md | 10 ++- docs/database/users.md | 15 +++- docs/guides/bootstrap.md | 105 ++++++++++++++++++++++++ docs/index.md | 4 +- 8 files changed, 297 insertions(+), 11 deletions(-) create mode 100644 docs/api/rest-overview.md create mode 100644 docs/api/setup.md create mode 100644 docs/guides/bootstrap.md diff --git a/docs/api/rest-overview.md b/docs/api/rest-overview.md new file mode 100644 index 0000000..5be60bf --- /dev/null +++ b/docs/api/rest-overview.md @@ -0,0 +1,84 @@ +--- +type: api +title: REST API Overview +description: Base URL, versioning, auth, CSRF, and the standard error envelope. +tags: [api, rest, overview, csrf] +timestamp: 2026-07-21T14:43:00Z +--- + +# Base URL & versioning + +All endpoints live under `/api/v1`. The Angular SPA uses the same +prefix; the SPA fallback only kicks in for non-API routes. + +| Path prefix | Purpose | +|-----------------|--------------------------------------------------------| +| `/api/v1/*` | Versioned JSON REST endpoints. | +| `/api/docs` | Swagger UI (OpenAPI 3.1). | +| `/api/docs-json`| Raw OpenAPI 3.1 document. | +| `/uploads/*` | Static uploads directory (`UPLOAD_DIR`). | +| `/*` | SPA fallback (`FRONTEND_DIST/index.html`). | + +# Authentication + +* `JwtAuthGuard` is registered as `APP_GUARD` and runs on every request + unless the handler is marked with `@Public()` from + `backend/src/common/decorators/public.decorator.ts`. +* Successful auth produces an access JWT in the response body and an + `HttpOnly` refresh cookie named `rt` (see `backend/src/modules/auth/cookie.ts`). +* `Authorization: Bearer ` is attached by the + `authInterceptor` on the frontend. + +# CSRF + +* `CsrfMiddleware` (registered globally) mints a `csrf` cookie on every + response and, for unsafe methods (`POST`, `PUT`, `PATCH`, `DELETE`), + compares the cookie against the `x-csrf-token` header using + `crypto.timingSafeEqual`. +* The frontend's `csrfInterceptor` runs *before* `authInterceptor` so + the header is attached on every unsafe call. +* Skip list (`SKIP_PATH_PREFIXES` in + `backend/src/common/middleware/csrf.middleware.ts`): + - `/api/v1/auth/register-first-admin` + - `/api/v1/auth/login` + - `/api/v1/setup/create-admin` + +# Error envelope + +Every error flows through `GlobalExceptionFilter` and is shaped as: + +```json +{ + "code": "USERNAME_TAKEN", + "message": "Username already exists", + "details": null, + "path": "/api/v1/setup/create-admin", + "timestamp": "2026-07-21T14:43:00.000Z" +} +``` + +| Field | Type | Notes | +|-------------|---------------------|--------------------------------------------------------| +| `code` | string | One of `ERROR_CODES` (`backend/src/common/errors/error-codes.ts`). | +| `message` | string | Human-readable summary. | +| `details` | unknown \| null | Optional structured details (e.g. zod field errors). | +| `path` | string | Original request path. | +| `timestamp` | ISO 8601 string | When the filter produced the response. | + +# Canonical error codes + +`USERNAME_TAKEN` is the most recently added code (used by the setup +endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`: + +`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, +`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`, +`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`, +`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `INTERNAL`. + +# See also + +- [Auth Endpoints](/api/auth.md) +- [Admin Endpoints](/api/admin.md) +- [Setup Endpoint](/api/setup.md) +- [System Endpoints](/api/system.md) +- [Uploads Endpoints](/api/uploads.md) \ No newline at end of file diff --git a/docs/api/setup.md b/docs/api/setup.md new file mode 100644 index 0000000..26ec4f5 --- /dev/null +++ b/docs/api/setup.md @@ -0,0 +1,80 @@ +--- +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-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) + +```json +{ + "accessToken": "", + "expiresIn": 900, + "user": { "id": "", "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.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. + +# 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) +- [Backend Module Map](/architecture/backend-modules.md) +- [Bootstrap Service](/architecture/frontend-structure.md) \ No newline at end of file diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index d98ed6d..7840ad3 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -3,7 +3,7 @@ type: architecture title: Backend Module Map description: NestJS modules, controllers, services, and how they are wired together. tags: [architecture, backend, nestjs, modules] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Module Map @@ -25,6 +25,7 @@ also registers two global providers: | `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).| | `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). | | `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')`. | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | @@ -36,6 +37,7 @@ also registers two global providers: |------------------------|-------------------------|--------------|--------------------------------------------------------------| | `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` | +| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 404 once an admin exists) | `backend/src/modules/setup/setup.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` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 8c2dd1e..5b3e3d8 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -3,7 +3,7 @@ type: architecture title: Frontend Structure description: Angular routes, components, services, guards, and interceptors. tags: [architecture, frontend, angular] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Routes @@ -12,7 +12,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | Path | Component | Guard | Notes | |--------------|----------------------------------|---------------|----------------------------------------| -| `/bootstrap` | `CreateAdminComponent` | — | Rendered only when `initialized === false`. | +| `/bootstrap` | `SetupCreateAdminComponent` | — | Rendered only when `initialized === false`. Non-dismissible modal overlay. | | `/login` | `LoginComponent` | — | Standard login form. | | `/` | `HomeComponent` | `authGuard` | Requires auth + initialized. | | `**` | Redirect to `/` | — | Wildcard fallback. | @@ -27,7 +27,7 @@ All components are standalone (no NgModules). Each component imports | `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `` and calls `BootstrapService.load()`. | | `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Landing page shown after auth. | | `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. | -| `CreateAdminComponent` | `frontend/src/app/features/bootstrap/create-admin.component.ts`| First-admin bootstrap form. | +| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal (non-dismissible, typed reactive forms). | # Services diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index d6d0e96..26feb6a 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -3,7 +3,7 @@ type: architecture title: Key Files Index description: One-line responsibility for every important source file in the repository. tags: [architecture, index, key-files] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Backend @@ -74,6 +74,10 @@ timestamp: 2026-07-21T14:18:00Z | `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. | | `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. | | `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. | +| `backend/src/modules/setup/setup.module.ts` | Wires `SetupController` + `SetupService` (dedicated first-admin bootstrap flow). | +| `backend/src/modules/setup/setup.controller.ts` | `POST /api/v1/setup/create-admin` (returns 404 once an admin exists). | +| `backend/src/modules/setup/setup.service.ts` | Hashes password (Argon2id), runs the create inside a transaction, mints session via `AuthService.createSession`. | +| `backend/src/modules/setup/dto/create-admin.dto.ts` | Zod schema with `password === passwordConfirm` refine. | | `backend/src/modules/admin/admin.module.ts` | Wires `AdminController` + `AdminService`. | | `backend/src/modules/admin/admin.controller.ts` | `/api/v1/admin/users[/{id}]`. | | `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. | @@ -110,7 +114,9 @@ timestamp: 2026-07-21T14:18:00Z | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. | | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. | | `frontend/src/app/features/auth/login.component.ts` | Login form. | -| `frontend/src/app/features/bootstrap/create-admin.component.ts` | First-admin creation form. | +| `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin modal overlay (non-dismissible; typed reactive form). | +| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. | +| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. | | `frontend/src/app/features/home/home.component.ts` | Landing page after auth. | # Tests diff --git a/docs/database/users.md b/docs/database/users.md index 8eb9159..c09c6c9 100644 --- a/docs/database/users.md +++ b/docs/database/users.md @@ -3,7 +3,7 @@ type: database title: User Table description: The `user` table — accounts, roles, and statuses. tags: [database, user, auth] -timestamp: 2026-07-21T14:18:00Z +timestamp: 2026-07-21T14:43:00Z --- # Schema @@ -34,9 +34,16 @@ timestamp: 2026-07-21T14:18:00Z # First-admin bootstrap -When the `user` table is empty of admins, `POST /api/v1/auth/register-first-admin` -is the only endpoint that can create one. After that, the endpoint -returns `409 SYSTEM_INITIALIZED`. +When the `user` table is empty of admins, the dedicated +`POST /api/v1/setup/create-admin` endpoint (see +[Setup Endpoint](/api/setup.md)) creates the very first admin and +auto-issues a session. Once any admin row exists the endpoint becomes +inert and returns `404 NOT_FOUND`, so the route is effectively hidden +in production. + +The legacy `POST /api/v1/auth/register-first-admin` route is preserved +for compatibility but the canonical first-admin flow now lives in the +`SetupModule`. # See also diff --git a/docs/guides/bootstrap.md b/docs/guides/bootstrap.md new file mode 100644 index 0000000..2e5b4d8 --- /dev/null +++ b/docs/guides/bootstrap.md @@ -0,0 +1,105 @@ +--- +type: guide +title: First-Run Bootstrap +description: How a fresh HIPCTF instance is initialized by the very first administrator. +tags: [guide, bootstrap, first-admin, onboarding, tester] +timestamp: 2026-07-21T14:43:00Z +--- + +# When this flow runs + +A brand-new deployment shows a non-dismissible modal asking the operator +to create the very first admin user. This is gated by the +`initialized` flag returned from `GET /api/v1/bootstrap`: + +| `initialized` | User experience | +|---------------|---------------------------------------------------------------| +| `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. | + +The `authGuard` enforces the redirect on the client side; the backend +independently rejects `/api/v1/setup/create-admin` with `404 NOT_FOUND` +once any admin row exists, so the flow is safe even if the SPA is +bypassed. + +# How to access (tester steps) + +1. Start the stack against an empty database: + ``` + npm run db:reset && npm run dev + ``` +2. Open the browser at `http://localhost:3000/`. +3. The app redirects to `/bootstrap` and shows the + **"Welcome to HIPCTF — Create the first administrator"** modal. +4. Fill in the three fields and submit: + - **Username** — 3–32 chars, letters/digits/`.`/`-`/`_` only. Helper + text is shown via `data-testid="setup-username-error"` when the + pattern fails. + - **Password** — must satisfy the policy shown directly below the + field (default: 12 chars with upper, lower, digit, symbol). + - **Confirm Password** — must equal the password above (verified by + the group-level `passwordMatchValidator`; mismatch shows + `data-testid="setup-confirm-error"`). +5. Click **Create admin** (`data-testid="setup-submit"`). + +# Expected behavior + +* On success the modal disappears, the app stores the access token via + `AuthService.setSession`, calls `BootstrapService.markInitialized()`, + and navigates to `/challenges`. +* On `USERNAME_TAKEN` the form shows a red alert: + `"Username already exists"`. The **Retry** button is intentionally + hidden because the username must be changed first. +* On any other failure (network, `WEAK_PASSWORD`, `VALIDATION_FAILED`, + `RATE_LIMITED`) the alert shows `"Setup failed, please retry"` and a + **Retry** link (`data-testid="setup-retry"`) re-submits the same + payload. + +The modal **cannot** be dismissed by the user: + +* Clicking the backdrop is a no-op (`swallowBackdrop`). +* Pressing **Esc** is swallowed by the `keydown.escape` host listener. + +# Visual elements + +| Element | Selector | Purpose | +|--------------------------------|----------------------------------|-----------------------------------------------| +| Modal overlay | `.overlay` | Full-screen backdrop. | +| Modal card | `.modal[role="dialog"]` | Contains the form; stops click propagation. | +| Username input | `[data-testid="setup-username"]` | Reactive control with length/pattern rules. | +| Password input | `[data-testid="setup-password"]` | Reactive control. | +| Confirm input | `[data-testid="setup-password-confirm"]` | Reactive control. | +| Submit button | `[data-testid="setup-submit"]` | Disabled while submitting or invalid. | +| Server error alert | `[data-testid="setup-server-error"]` | Displays `serverError()`. | +| Retry button | `[data-testid="setup-retry"]` | Re-submits; absent when the error is non-retryable. | +| Password policy hint | `.hint` | Live description from `BootstrapService.passwordPolicy()`. | + +# Behind the scenes (architecture map) + +| Step | Where | What happens | +|------|--------------------------------------------------------|-------------------------------------------------------------------------| +| 1 | `BootstrapService.load()` | Calls `GET /api/v1/bootstrap`, sets `initialized = false`. | +| 2 | `authGuard` | Sees `initialized() === false`, redirects to `/bootstrap`. | +| 3 | `SetupCreateAdminComponent` | Renders the modal; reactive form enforces username + password policy + passwordMatch. | +| 4 | `SetupCreateAdminService.ensureCsrf()` | Preflights `GET /api/v1/auth/csrf` so the response sets the `csrf` cookie. | +| 5 | `POST /api/v1/setup/create-admin` | `SetupService.createAdmin` runs in a TypeORM transaction; creates the user with `role='admin'`; mints session via `AuthService.createSession`. | +| 6 | `setRefreshCookie` | Sets the `rt` HttpOnly cookie. | +| 7 | Component on success | `AuthService.setSession`, `BootstrapService.markInitialized()`, navigate to `/challenges`. | +| 8 | Subsequent `authGuard` checks | `initialized === true`; require auth instead. | + +# What the operator sees after init + +Once the first admin exists, `/api/v1/bootstrap` returns +`initialized: true`. The app now: + +* Skips `/bootstrap` entirely. +* Routes unauthenticated users to `/login`. +* Routes authenticated users to `/` (Home) which itself redirects to + `/challenges` once admin features are wired. + +# See also + +- [Setup Endpoint](/api/setup.md) +- [Frontend Structure](/architecture/frontend-structure.md) +- [Backend Module Map](/architecture/backend-modules.md) +- [Bootstrap payload source of truth](`/architecture/overview.md`) \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 7ff8748..6a007f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,6 +40,8 @@ they need. CSRF, and error envelope. * [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and first-admin registration. +* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin` + used only while no administrator exists. * [Admin Endpoints](/api/admin.md) - User management endpoints (admin role required). * [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE @@ -49,7 +51,7 @@ they need. # Guides * [First-Run Bootstrap](/guides/bootstrap.md) - How a fresh instance is - initialized by the very first admin. + initialized by the very first admin via the non-dismissible modal. * [Theming](/guides/theming.md) - How themes are loaded and applied. * [Event Window](/guides/event-window.md) - How the event window and live countdown work.