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
|
||||
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
|
||||
tags: [api, rest, overview, csrf]
|
||||
timestamp: 2026-07-21T14:43:00Z
|
||||
timestamp: 2026-07-21T18:28:00Z
|
||||
---
|
||||
|
||||
# Base URL & versioning
|
||||
@@ -40,8 +40,11 @@ prefix; the SPA fallback only kicks in for non-API routes.
|
||||
* 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`
|
||||
- `/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
|
||||
|
||||
@@ -73,7 +76,8 @@ 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`.
|
||||
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
|
||||
`REGISTRATIONS_DISABLED`, `INTERNAL`.
|
||||
|
||||
# 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. |
|
||||
| `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). |
|
||||
| `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). |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
# Controllers
|
||||
@@ -41,6 +42,7 @@ also registers two global providers:
|
||||
| `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` |
|
||||
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
|
||||
|
||||
# Common services
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: architecture
|
||||
title: Frontend Structure
|
||||
description: Angular routes, components, services, guards, and interceptors.
|
||||
tags: [architecture, frontend, angular]
|
||||
timestamp: 2026-07-21T16:48:20Z
|
||||
timestamp: 2026-07-21T18:28:00Z
|
||||
---
|
||||
|
||||
# Routes
|
||||
@@ -13,7 +13,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
|
||||
| Path | Component | Guard | Notes |
|
||||
|--------------|----------------------------------|---------------|----------------------------------------|
|
||||
| `/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. |
|
||||
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
|
||||
| `**` | 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()`. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
|
||||
# Services
|
||||
|
||||
| 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. |
|
||||
| `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`). |
|
||||
| `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. |
|
||||
| `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}`. |
|
||||
| `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. |
|
||||
| `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
|
||||
description: One-line responsibility for every important source file in the repository.
|
||||
tags: [architecture, index, key-files]
|
||||
timestamp: 2026-07-21T16:48:20Z
|
||||
timestamp: 2026-07-21T18:28:00Z
|
||||
---
|
||||
|
||||
# Backend
|
||||
@@ -64,11 +64,11 @@ timestamp: 2026-07-21T16:48:20Z
|
||||
|
||||
| File | Responsibility |
|
||||
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
|
||||
| `backend/src/modules/auth/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`. |
|
||||
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{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.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`; imports `SettingsModule` for the public register flow. |
|
||||
| `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, public player self-registration (`registerPlayer`), session minting. |
|
||||
| `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/users/users.module.ts` | Wires `UsersController` + `UsersService`. |
|
||||
| `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/uploads/uploads.module.ts` | Wires `UploadsController`. |
|
||||
| `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/frontend/frontend.module.ts` | Wires SPA fallback + uploads static middleware. |
|
||||
| `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/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/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/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/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.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.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/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/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. |
|
||||
@@ -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/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/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/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.
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
guards wait for bootstrap + auth hydration before deciding where to
|
||||
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.
|
||||
* [Event Window](/guides/event-window.md) - How the event window and live
|
||||
countdown work.
|
||||
|
||||
Reference in New Issue
Block a user