docs: update documentation to OKF v0.1 format
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Admin Endpoints
|
||||||
|
description: User management endpoints mounted under /api/v1/admin (admin role required).
|
||||||
|
tags: [api, admin, users]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|---------|-----------------------|---------|-----------------------------------------------------|
|
||||||
|
| `GET` | `/api/v1/admin/users` | Admin | `backend/src/modules/admin/admin.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/admin/users` | Admin | Same. |
|
||||||
|
| `PATCH` | `/api/v1/admin/users/:id` | Admin | Same. |
|
||||||
|
| `DELETE`| `/api/v1/admin/users/:id` | Admin | Same. |
|
||||||
|
|
||||||
|
# Guard chain
|
||||||
|
|
||||||
|
1. `JwtAuthGuard` (global) validates the access token unless the handler
|
||||||
|
is `@Public()`. Admin handlers are not.
|
||||||
|
2. `AdminGuard` (`backend/src/common/guards/admin.guard.ts`) requires
|
||||||
|
`req.user.role === 'admin'`.
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
* `AdminService` enforces the **last-admin invariant** (`LAST_ADMIN` /
|
||||||
|
`409`) — the final admin row cannot be demoted or deleted.
|
||||||
|
* `POST /api/v1/admin/users` creates a new user; `PATCH /api/v1/admin/users/:id`
|
||||||
|
changes role / status; `DELETE /api/v1/admin/users/:id` removes the row.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
|
- [Admin Shell & User Management](/guides/admin-shell.md)
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Auth Endpoints
|
||||||
|
description: Login, refresh, logout, public self-registration, CSRF, and first-admin registration.
|
||||||
|
tags: [api, auth, login, register, refresh, csrf]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | CSRF | Rate limited | Source |
|
||||||
|
|--------|-----------------------------------|--------|-----------------------------------------------|--------------|--------|
|
||||||
|
| `POST` | `/api/v1/auth/register` | `@Public()` | Enforced — caller must first call `GET /api/v1/auth/csrf` (the SPA's `AuthService.ensureCsrf()` does this automatically). | Yes — `RegistrationRateLimitService` per IP | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/login` | `@Public()` | Enforced — same pattern as `register`. CSRF skip was removed in this change. | Yes — `LoginBackoffService` per IP + username | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/refresh` | Public route, requires a valid refresh cookie. | Skipped (the request is a `POST` carrying an HttpOnly cookie, not a state-changing user action). | No | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/logout` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `GET` | `/api/v1/auth/csrf` | `@Public()` | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/auth/register-first-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`). | Yes | `backend/src/modules/users/users.controller.ts` |
|
||||||
|
|
||||||
|
# Public player self-registration — `POST /api/v1/auth/register`
|
||||||
|
|
||||||
|
New endpoint, gated by the `registrationsEnabled` setting
|
||||||
|
(`backend/src/config/env.schema.ts` → `SETTINGS_KEYS.REGISTRATIONS_ENABLED`,
|
||||||
|
default `"false"`).
|
||||||
|
|
||||||
|
| Field | Type | Constraints |
|
||||||
|
|-------------------|--------|------------------------------------------------------------------------------|
|
||||||
|
| `username` | string | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$` |
|
||||||
|
| `password` | string | 1–256 chars; also passes `validatePassword` (Argon2 policy). |
|
||||||
|
| `passwordConfirm` | string | Must equal `password` (zod refine → `VALIDATION_FAILED` on `passwordConfirm`). |
|
||||||
|
|
||||||
|
Validated by `backend/src/modules/auth/dto/auth.dto.ts` → `RegisterDtoSchema`.
|
||||||
|
|
||||||
|
## Successful response (201)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"accessToken": "<jwt>",
|
||||||
|
"expiresIn": 900,
|
||||||
|
"user": { "id": "<uuid>", "username": "player1", "role": "player" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, session.refreshToken, config)`. The new `user` row has `role='player'` and `status='enabled'`.
|
||||||
|
|
||||||
|
## Error envelope
|
||||||
|
|
||||||
|
| HTTP | `code` | When |
|
||||||
|
|------|-------------------------|---------------------------------------------------------------------------------|
|
||||||
|
| 400 | `VALIDATION_FAILED` | zod body validation failed. |
|
||||||
|
| 400 | `WEAK_PASSWORD` | Argon2 policy rejects the password. |
|
||||||
|
| 403 | `REGISTRATIONS_DISABLED`| `setting.registrationsEnabled !== 'true'`. |
|
||||||
|
| 409 | `USERNAME_TAKEN` | A user with that username already exists. |
|
||||||
|
| 429 | `RATE_LIMITED` | `RegistrationRateLimitService` blocked the IP. |
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
|
||||||
|
* `AuthService.registerPlayer(dto, ip)` runs inside a single
|
||||||
|
`DataSource.transaction`:
|
||||||
|
1. `RegistrationRateLimitService.isAllowed(ip)` check.
|
||||||
|
2. `SettingsService.get(SETTINGS_KEYS.REGISTRATIONS_ENABLED, 'false')` check.
|
||||||
|
3. Username uniqueness lookup → `USERNAME_TAKEN` on collision.
|
||||||
|
4. Argon2id hash + insert `UserEntity({ role: 'player', status: 'enabled' })`.
|
||||||
|
5. `RegistrationRateLimitService.record(ip)`.
|
||||||
|
6. `mintSession(manager, user)` → access JWT + refresh row.
|
||||||
|
|
||||||
|
# Frontend consumer
|
||||||
|
|
||||||
|
`frontend/src/app/core/services/auth.service.ts` exposes `login()` and
|
||||||
|
`register()`. Both:
|
||||||
|
|
||||||
|
1. Call `ensureCsrf()` → `GET /api/v1/auth/csrf` to materialise the `csrf`
|
||||||
|
cookie if it isn't set yet (failures are swallowed because the
|
||||||
|
`CsrfMiddleware` will mint the cookie on the next response anyway).
|
||||||
|
2. `POST` with `{ withCredentials: true, observe: 'response' }`.
|
||||||
|
3. Return a discriminated union:
|
||||||
|
* `{ ok: true, accessToken, expiresIn, user }` on success.
|
||||||
|
* `{ ok: false, status, code, message }` on failure, built by the
|
||||||
|
private `mapAuthError()` helper from the standard
|
||||||
|
[error envelope](/api/rest-overview.md).
|
||||||
|
|
||||||
|
The `LandingComponent` (`frontend/src/app/features/landing/landing.component.ts`)
|
||||||
|
consumes both methods and maps the `code` to user-facing copy via
|
||||||
|
`buildLoginFailureMessage` (see [Landing Page](/guides/landing-page.md)).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
|
- [Setup Endpoint](/api/setup.md)
|
||||||
|
- [Landing Page Guide](/guides/landing-page.md)
|
||||||
@@ -3,7 +3,7 @@ type: api
|
|||||||
title: REST API Overview
|
title: REST API Overview
|
||||||
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
||||||
tags: [api, rest, overview, csrf]
|
tags: [api, rest, overview, csrf]
|
||||||
timestamp: 2026-07-21T14:43:00Z
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Base URL & versioning
|
# Base URL & versioning
|
||||||
@@ -40,8 +40,11 @@ prefix; the SPA fallback only kicks in for non-API routes.
|
|||||||
* Skip list (`SKIP_PATH_PREFIXES` in
|
* Skip list (`SKIP_PATH_PREFIXES` in
|
||||||
`backend/src/common/middleware/csrf.middleware.ts`):
|
`backend/src/common/middleware/csrf.middleware.ts`):
|
||||||
- `/api/v1/auth/register-first-admin`
|
- `/api/v1/auth/register-first-admin`
|
||||||
- `/api/v1/auth/login`
|
|
||||||
- `/api/v1/setup/create-admin`
|
- `/api/v1/setup/create-admin`
|
||||||
|
- `/api/v1/auth/login` is **no longer** skipped — the middleware still
|
||||||
|
mints the cookie on the first response, and the SPA's
|
||||||
|
`AuthService.login`/`AuthService.register` first call `GET /api/v1/auth/csrf`
|
||||||
|
via `ensureCsrf()` to materialise the cookie before submitting.
|
||||||
|
|
||||||
# Error envelope
|
# Error envelope
|
||||||
|
|
||||||
@@ -73,7 +76,8 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
|
|||||||
`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
|
`VALIDATION_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`,
|
||||||
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
|
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
|
||||||
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
|
||||||
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`, `INTERNAL`.
|
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
|
||||||
|
`REGISTRATIONS_DISABLED`, `INTERNAL`.
|
||||||
|
|
||||||
# See also
|
# See also
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: System Endpoints
|
||||||
|
description: Bootstrap payload, event status, and SSE streams.
|
||||||
|
tags: [api, system, bootstrap, event, sse]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|--------|---------------------------------|--------|-----------------------------------------------------|
|
||||||
|
| `GET` | `/api/v1/bootstrap` | Public | `backend/src/modules/system/system.controller.ts` |
|
||||||
|
| `GET` | `/api/v1/event/status` | Public | Same. |
|
||||||
|
| `GET` | `/api/v1/event/stream` | Public (SSE) | Same. |
|
||||||
|
| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. |
|
||||||
|
|
||||||
|
# `GET /api/v1/bootstrap`
|
||||||
|
|
||||||
|
Built by `SystemService.bootstrap()`. Public payload consumed by the
|
||||||
|
SPA's `BootstrapService`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"initialized": true,
|
||||||
|
"pageTitle": "HIPCTF",
|
||||||
|
"logo": "",
|
||||||
|
"welcomeMarkdown": "# Welcome\n\n...",
|
||||||
|
"theme": { "id": "classic", "tokens": { "...": "..." } },
|
||||||
|
"defaultChallengeIp": "127.0.0.1",
|
||||||
|
"registrationsEnabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
* `initialized` is `true` iff at least one admin user exists.
|
||||||
|
* `theme` is the resolved `Theme` (one of the 10 canonical themes; see
|
||||||
|
[Theming](/guides/theming.md)).
|
||||||
|
* `registrationsEnabled` is read from the `setting` table
|
||||||
|
(`SETTINGS_KEYS.REGISTRATIONS_ENABLED`) and gates the new public
|
||||||
|
register endpoint (see [Auth Endpoints](/api/auth.md)).
|
||||||
|
|
||||||
|
# `GET /api/v1/event/status`
|
||||||
|
|
||||||
|
Returns the current event window computed by `EventStatusService`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
`state` is `Stopped` when `now < eventStartUtc` (countdown), `Running`
|
||||||
|
between start and end, and `Stopped` once `now >= eventEndUtc`.
|
||||||
|
|
||||||
|
# SSE streams
|
||||||
|
|
||||||
|
* `/api/v1/event/stream` — emits the same payload as
|
||||||
|
`GET /api/v1/event/status` whenever `SseHubService.publish` is invoked
|
||||||
|
(typically via an admin cron tick).
|
||||||
|
* `/api/v1/scoreboard/stream` — emits the public scoreboard projection
|
||||||
|
after each new solve.
|
||||||
|
|
||||||
|
Both use NestJS `@Sse()` and are proxied through `SseHubService`
|
||||||
|
(`backend/src/common/services/sse-hub.service.ts`).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||||
|
- [Theming](/guides/theming.md)
|
||||||
|
- [Event Window](/guides/event-window.md)
|
||||||
|
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
type: api
|
||||||
|
title: Uploads Endpoints
|
||||||
|
description: Admin-only multipart upload endpoints for category icons and challenge files.
|
||||||
|
tags: [api, uploads, multipart, admin]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Auth | Source |
|
||||||
|
|--------|---------------------------------------|-------|---------------------------------------------------------|
|
||||||
|
| `POST` | `/api/v1/uploads/category-icon` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
|
||||||
|
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. |
|
||||||
|
|
||||||
|
# Guard chain
|
||||||
|
|
||||||
|
Both handlers are gated by `JwtAuthGuard` + `AdminGuard`. CSRF is enforced
|
||||||
|
(standard cookie + header pattern; nothing is added to the skip list).
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
* Each endpoint uses `FileInterceptor('file')` and parses a single
|
||||||
|
multipart part named `file`.
|
||||||
|
* File size is validated by `parseUploadSizeLimit()` in
|
||||||
|
`backend/src/common/utils/upload.ts`.
|
||||||
|
* Filenames are sanitized by the same helper before being persisted
|
||||||
|
under `UPLOAD_DIR`.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [REST API Overview](/api/rest-overview.md)
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
@@ -23,12 +23,13 @@ also registers two global providers:
|
|||||||
| `DatabaseModule` | `backend/src/database/database.module.ts` | Configures TypeORM with `better-sqlite3`, registers entities, runs migrations, enforces WAL. |
|
| `DatabaseModule` | `backend/src/database/database.module.ts` | Configures TypeORM with `better-sqlite3`, registers entities, runs migrations, enforces WAL. |
|
||||||
| `CommonModule` | `backend/src/common/common.module.ts` | Provides shared services (CSRF middleware class, backoff, registration rate limit, SSE hub, theme loader, etc.). |
|
| `CommonModule` | `backend/src/common/common.module.ts` | Provides shared services (CSRF middleware class, backoff, registration rate limit, SSE hub, theme loader, etc.). |
|
||||||
| `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 + public `register`). Imports `SettingsModule` so the public-register flow can read the `registrationsEnabled` flag. |
|
||||||
| `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 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
|
| `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. |
|
||||||
|
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
|
||||||
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
|
||||||
|
|
||||||
# Controllers
|
# Controllers
|
||||||
@@ -41,6 +42,7 @@ also registers two global providers:
|
|||||||
| `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` |
|
||||||
|
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
|
||||||
|
|
||||||
# Common services
|
# Common services
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Frontend Structure
|
title: Frontend Structure
|
||||||
description: Angular routes, components, services, guards, and interceptors.
|
description: Angular routes, components, services, guards, and interceptors.
|
||||||
tags: [architecture, frontend, angular]
|
tags: [architecture, frontend, angular]
|
||||||
timestamp: 2026-07-21T16:48:20Z
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Routes
|
# Routes
|
||||||
@@ -13,7 +13,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
|||||||
| Path | Component | Guard | Notes |
|
| Path | Component | Guard | Notes |
|
||||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||||
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. |
|
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. |
|
||||||
| `/login` | `LoginComponent` | — | Standard login form. |
|
| `/login` | `LandingComponent` | `landingGuard`| Public landing page that hosts the login (and registration, when enabled) modal. The `landingGuard` redirects to `/bootstrap` until the instance is initialized and to `/` if the user is already authenticated. |
|
||||||
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts a `<router-outlet>` for child routes. |
|
||||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
||||||
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
| `**` | Redirect to `/` | — | Wildcard fallback. |
|
||||||
@@ -28,15 +28,19 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
|
||||||
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
|
||||||
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
|
||||||
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
|
| `LoginComponent` | *(removed)* — replaced by `LandingComponent`. | |
|
||||||
|
| `LandingComponent` | `frontend/src/app/features/landing/landing.component.{ts,html,css}` | Public landing page: logo, page title, rendered `welcomeMarkdown`, "Login" button, blog post list, and the modal that contains both the login and registration forms (mode toggled by a single signal). |
|
||||||
|
| `LandingService` | `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals. |
|
||||||
|
| `LoginModalService` (pure helpers) | `frontend/src/app/features/landing/login-modal.service.ts` | Pure `buildLoginFailureMessage` that maps an API error envelope into user-facing copy (and extracts a `retryAfterSeconds` for `RATE_LIMITED`). |
|
||||||
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. |
|
| `SetupCreateAdminComponent` | `frontend/src/app/features/setup/setup-create-admin.component.ts` | First-admin bootstrap modal with typed reactive form, inline validation messages (via [the field-error helpers](/guides/setup-field-errors.md)), retry handling, session initialization, and redirect to `/admin`. |
|
||||||
|
|
||||||
# Services
|
# Services
|
||||||
|
|
||||||
| Service | Path | Purpose |
|
| Service | Path | Purpose |
|
||||||
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
|
||||||
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage` and exposes `restoreSession()` + `waitUntilHydrated()`. |
|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage`, exposes `restoreSession()` + `waitUntilHydrated()`, plus `login()` / `register()` (each calls `ensureCsrf()` then posts, returning a discriminated `LoginResult` / `RegisterResult`). |
|
||||||
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
|
||||||
|
| `MarkdownService` | `frontend/src/app/core/services/markdown.service.ts` | Wraps `DomSanitizer.bypassSecurityTrustHtml` around the pure `renderMarkdownToHtml` helper. Used to render `welcomeMarkdown` and blog post bodies. |
|
||||||
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
|
||||||
| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. |
|
| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. |
|
||||||
|
|
||||||
@@ -48,6 +52,8 @@ All components are standalone (no NgModules). Each component imports
|
|||||||
| `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
|
| `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
|
||||||
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
|
||||||
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
|
||||||
|
| `landingGuard` | `frontend/src/app/core/guards/landing.guard.ts` | Async `CanActivateFn` for `/login`; awaits bootstrap + auth hydration, then delegates to the pure `decideLandingGuard`. Redirects to `/bootstrap` (not initialized) or `/` (already authenticated). |
|
||||||
|
| `decideLandingGuard`| `frontend/src/app/core/guards/landing.guard.decision.ts` | Pure decision function: returns `true` to render the landing page, or `UrlTree` to `/bootstrap` / `/`. |
|
||||||
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
|
||||||
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: architecture
|
|||||||
title: Key Files Index
|
title: Key Files Index
|
||||||
description: One-line responsibility for every important source file in the repository.
|
description: One-line responsibility for every important source file in the repository.
|
||||||
tags: [architecture, index, key-files]
|
tags: [architecture, index, key-files]
|
||||||
timestamp: 2026-07-21T16:48:20Z
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
---
|
---
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
@@ -64,11 +64,11 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
|
|
||||||
| File | Responsibility |
|
| File | Responsibility |
|
||||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||||
| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`. |
|
| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`; imports `SettingsModule` for the public register flow. |
|
||||||
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{login,refresh,logout,csrf}`. |
|
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{register,login,refresh,logout,csrf}`. |
|
||||||
| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, mint JWT + refresh tokens. |
|
| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, public player self-registration (`registerPlayer`), session minting. |
|
||||||
| `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. |
|
| `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. |
|
||||||
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh. |
|
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh + `RegisterDtoSchema` (with `password === passwordConfirm` refine + username regex). |
|
||||||
| `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. |
|
| `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. |
|
||||||
| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService`. |
|
| `backend/src/modules/users/users.module.ts` | Wires `UsersController` + `UsersService`. |
|
||||||
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
|
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
|
||||||
@@ -87,6 +87,10 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. |
|
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. |
|
||||||
| `backend/src/modules/uploads/uploads.module.ts` | Wires `UploadsController`. |
|
| `backend/src/modules/uploads/uploads.module.ts` | Wires `UploadsController`. |
|
||||||
| `backend/src/modules/uploads/uploads.controller.ts` | `/api/v1/uploads/{category-icon,challenge-file}`. |
|
| `backend/src/modules/uploads/uploads.controller.ts` | `/api/v1/uploads/{category-icon,challenge-file}`. |
|
||||||
|
| `backend/src/modules/blog/blog.module.ts` | Wires `BlogController` + `BlogService` (registers `BlogPostEntity`). |
|
||||||
|
| `backend/src/modules/blog/blog.controller.ts` | `GET /api/v1/blog/posts` (public). |
|
||||||
|
| `backend/src/modules/blog/blog.service.ts` | `listPublished()` returns published posts ordered by `publishedAt DESC`. |
|
||||||
|
| `backend/src/modules/blog/dto/blog.dto.ts` | Zod schemas `PublicBlogPostSchema` + `PublicBlogListSchema`. |
|
||||||
| `backend/src/modules/settings/settings.module.ts` | `SettingsService` (get/set/getAll over the `setting` table). |
|
| `backend/src/modules/settings/settings.module.ts` | `SettingsService` (get/set/getAll over the `setting` table). |
|
||||||
| `backend/src/frontend/frontend.module.ts` | Wires SPA fallback + uploads static middleware. |
|
| `backend/src/frontend/frontend.module.ts` | Wires SPA fallback + uploads static middleware. |
|
||||||
| `backend/src/frontend/spa.controller.ts` | `SpaFallbackMiddleware` (returns `index.html` for client routes). |
|
| `backend/src/frontend/spa.controller.ts` | `SpaFallbackMiddleware` (returns `index.html` for client routes). |
|
||||||
@@ -108,17 +112,23 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
|
||||||
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. |
|
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. |
|
||||||
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
|
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
|
||||||
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()` and `waitUntilHydrated()`. |
|
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()`, `waitUntilHydrated()`, `login()`, `register()` (both with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`), `setSession()`. |
|
||||||
| `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. |
|
| `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. |
|
||||||
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; `load()` / `ready()` deduplicate the in-flight fetch. |
|
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; `load()` / `ready()` deduplicate the in-flight fetch. |
|
||||||
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
|
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
|
||||||
|
| `frontend/src/app/core/services/markdown.service.ts` | `MarkdownService` (Angular) wraps `DomSanitizer.bypassSecurityTrustHtml` around the rendered Markdown. |
|
||||||
|
| `frontend/src/app/core/services/markdown.pure.ts` | Pure `renderMarkdownToHtml` helper using `marked` + `DOMPurify` (kept in its own file so it can be unit-tested without Angular's ESM-only runtime). |
|
||||||
| `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
|
| `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
|
||||||
| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function for the auth guard (returns `true` or a redirect `UrlTree`). |
|
| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function for the auth guard (returns `true` or a redirect `UrlTree`). |
|
||||||
| `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn` for admin-only routes; awaits bootstrap + auth hydration, delegates to `decideAdminGuard`. |
|
| `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn` for admin-only routes; awaits bootstrap + auth hydration, delegates to `decideAdminGuard`. |
|
||||||
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
|
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
|
||||||
|
| `frontend/src/app/core/guards/landing.guard.ts` | Async `CanActivateFn` for `/login`; awaits bootstrap + auth hydration, then delegates to the pure `decideLandingGuard`. |
|
||||||
|
| `frontend/src/app/core/guards/landing.guard.decision.ts` | Pure decision: returns `true` to render the landing page, or `UrlTree` to `/bootstrap` (not initialized) or `/` (already authenticated). |
|
||||||
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
|
| `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/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/landing/landing.component.{ts,html,css}` | Public landing page (logo, page title, rendered `welcomeMarkdown`, login button, blog list) + modal containing the login and registration forms (toggled by the same `mode` signal). |
|
||||||
|
| `frontend/src/app/features/landing/landing.service.ts` | Fetches `GET /api/v1/blog/posts`, exposes `posts`/`loading`/`error` signals, `refresh()`. |
|
||||||
|
| `frontend/src/app/features/landing/login-modal.service.ts` | Pure `buildLoginFailureMessage` helper that maps `{code,message}` into user-facing copy (handles `INVALID_CREDENTIALS`, `RATE_LIMITED`, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`). |
|
||||||
| `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list (signals: `loading`, `error`, `users`). |
|
| `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list (signals: `loading`, `error`, `users`). |
|
||||||
| `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.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.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
|
||||||
@@ -138,6 +148,12 @@ timestamp: 2026-07-21T16:48:20Z
|
|||||||
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
|
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
|
||||||
| `tests/frontend/setup-create-admin.spec.ts` | Pure-Jest tests for `passwordMatchValidator` and the three field-error helpers (`usernameError`, `passwordError`, `confirmError`). |
|
| `tests/frontend/setup-create-admin.spec.ts` | Pure-Jest tests for `passwordMatchValidator` and the three field-error helpers (`usernameError`, `passwordError`, `confirmError`). |
|
||||||
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
|
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
|
||||||
|
| `tests/frontend/landing-guard.spec.ts` | Pins `decideLandingGuard` for the not-initialized / authenticated / anonymous branches. |
|
||||||
|
| `tests/frontend/landing-markdown.spec.ts` | Pins `renderMarkdownToHtml` (sanitization of raw `<script>` payloads, script URL schemes, etc.). |
|
||||||
|
| `tests/frontend/landing-modal.spec.ts` | Pins `buildLoginFailureMessage` (`INVALID_CREDENTIALS`, `RATE_LIMITED` with retry-seconds extraction, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`, default fallback). |
|
||||||
|
| `tests/backend/auth-register.spec.ts` | Backend integration tests for `POST /api/v1/auth/register` (happy path, disabled flag, duplicate username, weak password, rate limit). |
|
||||||
|
| `tests/backend/blog-public.spec.ts` | Backend integration tests for `GET /api/v1/blog/posts` (only published rows, descending order). |
|
||||||
|
| `tests/backend/csrf-protected-routes.spec.ts` | Pins CSRF enforcement on the formerly-skipped `/api/v1/auth/login` route. |
|
||||||
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
|
||||||
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
type: database
|
||||||
|
title: Blog Post Table
|
||||||
|
description: blog_post table — draft/published Markdown posts surfaced on the public landing page.
|
||||||
|
tags: [database, blog, markdown]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Schema
|
||||||
|
|
||||||
|
| Column | Type | Description |
|
||||||
|
|----------------|------------------|--------------------------------------------------------------|
|
||||||
|
| `id` | TEXT PK | UUID v4 (set by the service, not auto-generated). |
|
||||||
|
| `title` | TEXT | Post title. |
|
||||||
|
| `body_md` | TEXT (default `''`) | Markdown body. |
|
||||||
|
| `status` | TEXT (default `'draft'`) | One of `draft` or `published`. |
|
||||||
|
| `created_at` | TEXT | ISO 8601 (TypeORM `@CreateDateColumn`). |
|
||||||
|
| `updated_at` | TEXT | ISO 8601 (TypeORM `@UpdateDateColumn`). |
|
||||||
|
| `published_at` | TEXT, nullable | ISO 8601 timestamp the post was published (set when status flips). |
|
||||||
|
|
||||||
|
# Indexes
|
||||||
|
|
||||||
|
| Index | Columns | Purpose |
|
||||||
|
|------------------------------------|----------------------------------|------------------------------------------------------------|
|
||||||
|
| `idx_blog_status_published` | `(status, published_at)` | Lets `GET /api/v1/blog/posts` filter to `status='published'` and order by `published_at DESC` cheaply. |
|
||||||
|
|
||||||
|
# Behavior
|
||||||
|
|
||||||
|
* Only rows with `status = 'published'` are exposed through the public
|
||||||
|
endpoint `GET /api/v1/blog/posts` (see
|
||||||
|
[REST API Overview](/api/rest-overview.md)). Drafts are hidden.
|
||||||
|
* `BlogService.listPublished()` orders by `published_at DESC`, falling
|
||||||
|
back to `created_at` when `published_at` is null.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Database Schema Overview](/database/schema.md)
|
||||||
|
- [Landing Page Guide](/guides/landing-page.md)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Event Window
|
||||||
|
description: How the live event countdown is computed and surfaced via REST + SSE.
|
||||||
|
tags: [guide, event, countdown, sse]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
|
||||||
|
Two `setting` rows control the window:
|
||||||
|
|
||||||
|
| Setting key | Default (seeded) | Description |
|
||||||
|
|-------------------|------------------|--------------------------------------------|
|
||||||
|
| `eventStartUtc` | `now + 60s` | ISO 8601 instant the event becomes live. |
|
||||||
|
| `eventEndUtc` | `now + 7d` | ISO 8601 instant the event ends. |
|
||||||
|
|
||||||
|
Both are seeded by `SeedSystemData1700000000100` and editable through
|
||||||
|
`SettingsService`.
|
||||||
|
|
||||||
|
# State computation
|
||||||
|
|
||||||
|
`EventStatusService.getStatus(now)` (`backend/src/common/services/event-status.service.ts`)
|
||||||
|
returns one of three branches:
|
||||||
|
|
||||||
|
| Condition | `status` | `countdownMs` |
|
||||||
|
|-------------------------|----------|----------------------|
|
||||||
|
| `now < eventStartUtc` | `Stopped` (countdown) | `eventStartUtc - now` |
|
||||||
|
| `now ∈ [start, end]` | `Running` | `eventEndUtc - now` |
|
||||||
|
| `now >= eventEndUtc` | `Stopped` (ended) | `0` |
|
||||||
|
|
||||||
|
# Endpoints
|
||||||
|
|
||||||
|
* `GET /api/v1/event/status` — one-shot read (see
|
||||||
|
[System Endpoints](/api/system.md)).
|
||||||
|
* `GET /api/v1/event/stream` — SSE stream that pushes the same payload
|
||||||
|
whenever `SseHubService.publish()` is called (typically by an admin
|
||||||
|
cron tick). The SPA subscribes to keep the countdown ticking without
|
||||||
|
polling.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Auth and Settings Tables](/database/auth-settings.md)
|
||||||
|
- [System Endpoints](/api/system.md)
|
||||||
|
- [Scoreboard Stream](/guides/scoreboard-stream.md)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Landing Page
|
||||||
|
description: How the public landing page renders, how the login + registration modal is opened, and what each form does.
|
||||||
|
tags: [guide, landing, login, register, ui]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# What you see
|
||||||
|
|
||||||
|
`LandingComponent` (`frontend/src/app/features/landing/landing.component.{ts,html,css}`)
|
||||||
|
is the route mounted at `/login` (the path is historic — the page is now
|
||||||
|
the public landing page, not a bare login form).
|
||||||
|
|
||||||
|
On a fresh visit the page shows, in order:
|
||||||
|
|
||||||
|
1. **Logo** — `<img data-testid="landing-logo">` rendered from
|
||||||
|
`BootstrapService.payload().logo` (empty string → the `<img>` is not
|
||||||
|
rendered).
|
||||||
|
2. **Page title** — `<h1 data-testid="landing-title">` rendered from
|
||||||
|
`BootstrapService.payload().pageTitle` (defaults to `HIPCTF`).
|
||||||
|
3. **Welcome card** — `<article data-testid="landing-welcome">` whose
|
||||||
|
`innerHTML` is the sanitized render of
|
||||||
|
`BootstrapService.payload().welcomeMarkdown` (via
|
||||||
|
`MarkdownService.render` → `marked` + `DOMPurify`).
|
||||||
|
4. **Login button** — `<button data-testid="landing-open-login">`.
|
||||||
|
5. **Blog list** — `<section data-testid="landing-blog">` listing every
|
||||||
|
row returned by `GET /api/v1/blog/posts` (only `status='published'`
|
||||||
|
rows), or the text `No announcements yet.` when the list is empty.
|
||||||
|
|
||||||
|
# Guard
|
||||||
|
|
||||||
|
`/login` is gated by `landingGuard`
|
||||||
|
(`frontend/src/app/core/guards/landing.guard.ts`):
|
||||||
|
|
||||||
|
| `initialized` | `isAuthenticated` | Result |
|
||||||
|
|---------------|-------------------|-----------------------------------|
|
||||||
|
| `false` | any | Redirect to `/bootstrap` (first-admin modal). |
|
||||||
|
| `true` | `true` | Redirect to `/` (already signed in). |
|
||||||
|
| `true` | `false` | Render the landing page. |
|
||||||
|
|
||||||
|
# Login modal
|
||||||
|
|
||||||
|
Clicking the **Login** button sets `mode = 'login'`, which renders an
|
||||||
|
overlay containing:
|
||||||
|
|
||||||
|
| Field | Selector (`data-testid`) | Notes |
|
||||||
|
|------------|--------------------------|-----------------------------------------|
|
||||||
|
| Username | `login-username` | `autocomplete="username"` |
|
||||||
|
| Password | `login-password` | `autocomplete="current-password"` |
|
||||||
|
| Field error| `login-field-error` | Inline `required` message. |
|
||||||
|
| Server error | `login-server-error` | Mapped via `buildLoginFailureMessage`. |
|
||||||
|
| Submit | `login-submit` | Disabled while `submitting()` is true. |
|
||||||
|
| Switch | `login-switch-btn` | Visible only when `registrationsEnabled` is true; opens the registration modal. |
|
||||||
|
|
||||||
|
Submission flow:
|
||||||
|
|
||||||
|
1. `submitLogin()` → `AuthService.login({ username, password })`.
|
||||||
|
2. On `ok: true`, `AuthService.setSession(...)` is called and the user
|
||||||
|
is navigated to `/`.
|
||||||
|
3. On `ok: false`, the modal renders the mapped failure text and a
|
||||||
|
`(Ns remaining)` hint when `code === 'RATE_LIMITED'` and the server
|
||||||
|
message contains a wait-seconds value.
|
||||||
|
|
||||||
|
# Registration modal
|
||||||
|
|
||||||
|
When `bootstrap.payload().registrationsEnabled === true`, the **Login**
|
||||||
|
modal shows a "Register here" link (`login-switch-btn`) that swaps the
|
||||||
|
form to `mode = 'register'`. The registration form contains:
|
||||||
|
|
||||||
|
| Field | Selector (`data-testid`) | Notes |
|
||||||
|
|-----------------|--------------------------|------------------------------------------------------|
|
||||||
|
| Username | `register-username` | 3–32 chars, regex `^[a-zA-Z0-9_.-]+$`. |
|
||||||
|
| Password | `register-password` | Argon2id policy enforced server-side. |
|
||||||
|
| Confirm password| `register-confirm` | Must match (group-level `passwordMatchValidator`). |
|
||||||
|
| Field error | `register-field-error` | Inline message covering required / minLength / maxLength / pattern / mismatch. |
|
||||||
|
| Server error | `register-server-error` | Warn-styled when `code` is `RATE_LIMITED` or `REGISTRATIONS_DISABLED`. |
|
||||||
|
| Submit | `register-submit` | Disabled while submitting. |
|
||||||
|
| Switch | `register-switch-btn` | Goes back to the login form. |
|
||||||
|
|
||||||
|
Submission calls `AuthService.register(...)` which posts to
|
||||||
|
`POST /api/v1/auth/register`. On `ok: true` the user is signed in
|
||||||
|
immediately and navigated to `/`.
|
||||||
|
|
||||||
|
The modal is dismissible via the **×** button (`landing-modal-close`),
|
||||||
|
clicking the overlay backdrop, or pressing `Escape`. Submit-in-flight
|
||||||
|
dismissal is suppressed.
|
||||||
|
|
||||||
|
# Server error code → user copy
|
||||||
|
|
||||||
|
`buildLoginFailureMessage` (`frontend/src/app/features/landing/login-modal.service.ts`)
|
||||||
|
maps the API error envelope into modal copy:
|
||||||
|
|
||||||
|
| Code | Rendered text |
|
||||||
|
|----------------------------|------------------------------------------------------------|
|
||||||
|
| `INVALID_CREDENTIALS` | `Invalid username or password.` |
|
||||||
|
| `RATE_LIMITED` | `Please wait N seconds before trying again.` + `(Ns remaining)` hint when `N` is found in the server message. |
|
||||||
|
| `CSRF_INVALID` | `Session expired. Please reload the page and try again.` |
|
||||||
|
| `USERNAME_TAKEN` | `Username already exists.` |
|
||||||
|
| `WEAK_PASSWORD` | `Password does not meet the security policy.` |
|
||||||
|
| `REGISTRATIONS_DISABLED` | `Registrations are currently disabled.` |
|
||||||
|
| `VALIDATION_FAILED` | `Please check the form fields and try again.` |
|
||||||
|
| other / default | Raw `message` (or `Something went wrong. Please try again.` if empty). |
|
||||||
|
|
||||||
|
# CSRF
|
||||||
|
|
||||||
|
`AuthService.ensureCsrf()` (`frontend/src/app/core/services/auth.service.ts`)
|
||||||
|
calls `GET /api/v1/auth/csrf` before both `login()` and `register()` to
|
||||||
|
materialise the `csrf` cookie if it isn't already set. The call swallows
|
||||||
|
errors because the CSRF middleware also mints the cookie on the
|
||||||
|
response to the unsafe call itself.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Auth Endpoints](/api/auth.md)
|
||||||
|
- [Database Schema Overview](/database/schema.md) (`blog_post`)
|
||||||
|
- [Frontend Structure](/architecture/frontend-structure.md)
|
||||||
|
- [First-Run Bootstrap](/guides/bootstrap.md)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Scoreboard Stream
|
||||||
|
description: How live solves are broadcast to the SPA over Server-Sent Events.
|
||||||
|
tags: [guide, scoreboard, sse, stream]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Endpoint
|
||||||
|
|
||||||
|
`GET /api/v1/scoreboard/stream` (`@Sse()` handler in
|
||||||
|
`backend/src/modules/system/system.controller.ts`) opens an SSE stream
|
||||||
|
that pushes the public scoreboard projection every time a new solve is
|
||||||
|
recorded.
|
||||||
|
|
||||||
|
# Push path
|
||||||
|
|
||||||
|
1. The challenge solve path (admin validation, player submit) updates the
|
||||||
|
`solve` table.
|
||||||
|
2. The same code path calls `SseHubService.publish('scoreboard', payload)`
|
||||||
|
(`backend/src/common/services/sse-hub.service.ts`).
|
||||||
|
3. Every subscriber to `/api/v1/scoreboard/stream` receives the payload.
|
||||||
|
|
||||||
|
The hub is in-process; multi-replica deployments need a shared pub/sub
|
||||||
|
to fan out across pods (not in scope for this repo).
|
||||||
|
|
||||||
|
# Frontend consumer
|
||||||
|
|
||||||
|
The authenticated SPA shell subscribes on mount and updates the scoreboard
|
||||||
|
view whenever a new event arrives. The exact rendering lives inside the
|
||||||
|
authenticated shell's scoreboard component (see
|
||||||
|
[Frontend Structure](/architecture/frontend-structure.md)).
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [Event Window](/guides/event-window.md)
|
||||||
|
- [System Endpoints](/api/system.md)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
type: guide
|
||||||
|
title: Theming
|
||||||
|
description: How the 10 canonical themes are loaded, validated, and applied to the SPA via CSS custom properties.
|
||||||
|
tags: [guide, theming, design]
|
||||||
|
timestamp: 2026-07-21T18:28:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theme catalog
|
||||||
|
|
||||||
|
HIPCTF ships 10 canonical themes defined as JSON files in
|
||||||
|
`backend/themes/` (`01-classic.json` … `10-monochrome.json`). The full
|
||||||
|
list lives in `backend/src/common/types/theme-ids.ts`:
|
||||||
|
|
||||||
|
`classic`, `midnight`, `sunset`, `forest`, `cyber`, `paper`, `crimson`,
|
||||||
|
`ocean`, `neon`, `monochrome`.
|
||||||
|
|
||||||
|
Each theme is a flat map of CSS custom properties (colors, radii, font
|
||||||
|
family) consumed by `frontend/src/styles.css`.
|
||||||
|
|
||||||
|
# Backend loading
|
||||||
|
|
||||||
|
`ThemeLoaderService` (`backend/src/common/utils/theme-loader.service.ts`)
|
||||||
|
runs on module init:
|
||||||
|
|
||||||
|
1. Reads every `*.json` file in `backend/themes/`.
|
||||||
|
2. Validates each against the `Theme` interface
|
||||||
|
(`backend/src/common/types/theme.ts`) — unknown theme ids, malformed
|
||||||
|
JSON, or missing tokens throw `ApiError(THEME_INVALID)`.
|
||||||
|
3. Backfills any of the 10 canonical themes that are missing from disk
|
||||||
|
(safety net so a partial checkout still boots).
|
||||||
|
4. Reads `SETTINGS_KEYS.THEME_KEY` (default `'classic'`). If the value is
|
||||||
|
not in `THEME_IDS`, the loader falls back to `classic`, logs a
|
||||||
|
warning, and persists `'classic'` back to settings so the next request
|
||||||
|
is self-healing.
|
||||||
|
|
||||||
|
The resolved theme object is sent to the SPA in the
|
||||||
|
`GET /api/v1/bootstrap` payload (see [System Endpoints](/api/system.md)).
|
||||||
|
|
||||||
|
# Frontend application
|
||||||
|
|
||||||
|
`BootstrapService` (`frontend/src/app/core/services/bootstrap.service.ts`)
|
||||||
|
writes each theme token onto `document.documentElement` (e.g.
|
||||||
|
`--color-primary`, `--radius-md`, `--font-family`). Because `styles.css`
|
||||||
|
references the tokens via `var(--token)`, swapping themes is instant.
|
||||||
|
|
||||||
|
# See also
|
||||||
|
|
||||||
|
- [System Endpoints](/api/system.md)
|
||||||
|
- [Backend Module Map](/architecture/backend-modules.md)
|
||||||
+4
-1
@@ -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-21T17:37:18Z.
|
they need. Last regenerated 2026-07-21T18:28:00Z.
|
||||||
|
|
||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
@@ -61,6 +61,9 @@ they need. Last regenerated 2026-07-21T17:37:18Z.
|
|||||||
the SPA keeps a user signed in across hard refreshes and how the
|
the SPA keeps a user signed in across hard refreshes and how the
|
||||||
guards wait for bootstrap + auth hydration before deciding where to
|
guards wait for bootstrap + auth hydration before deciding where to
|
||||||
route.
|
route.
|
||||||
|
* [Landing Page](/guides/landing-page.md) - Public landing page
|
||||||
|
(logo, welcome Markdown, blog list) and the login + registration modal
|
||||||
|
rendered at `/login` once the instance is initialized.
|
||||||
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
* [Theming](/guides/theming.md) - How themes are loaded and applied.
|
||||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||||
countdown work.
|
countdown work.
|
||||||
|
|||||||
Reference in New Issue
Block a user