docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-21 22:26:44 +00:00
parent 8dc8cee769
commit e5720c66dc
11 changed files with 727 additions and 122 deletions
+104 -16
View File
@@ -1,9 +1,9 @@
---
type: api
title: Auth Endpoints
description: Login, refresh, logout, public self-registration, CSRF, first-admin registration, and registration throttling.
tags: [api, auth, login, register, refresh, csrf]
timestamp: 2026-07-21T18:28:00Z
description: Login, refresh, logout, public self-registration, CSRF, /me, change-password, first-admin registration, and registration throttling.
tags: [api, auth, login, register, refresh, csrf, me, change-password]
timestamp: 2026-07-21T22:19:08Z
---
# Endpoints
@@ -14,6 +14,8 @@ timestamp: 2026-07-21T18:28:00Z
| `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/me` | Authenticated (JWT). | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` |
| `POST` | `/api/v1/auth/change-password` | 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` |
@@ -64,27 +66,103 @@ The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, sess
* `AuthService.registerFirstAdmin(username, password, ip)` uses the same limiter before checking whether an administrator already exists.
* The limiter is an in-memory Nest provider in `backend/src/common/services/registration-rate-limit.service.ts`; counters are process-local and reset when the API process restarts.
# `GET /api/v1/auth/me`
Authenticated-only projection of the current user, including derived rank
and total points from the `solve` table.
| HTTP | `code` | When |
|------|-----------------|-----------------------------------------------------|
| 200 | — | Returns `{ id, username, role, rank, points }`. |
| 401 | `UNAUTHORIZED` | No JWT on the request (handled by `JwtAuthGuard`). |
## Successful response (200)
```json
{
"id": "<uuid>",
"username": "player1",
"role": "player",
"rank": 3,
"points": 275
}
```
* `rank` is `null` while the user has 0 points (no rank assigned).
* `points` is `SUM(solve.pointsAwarded)` for that user.
* `rank` is `1 + (count of users with strictly higher points total)`.
## Wiring
`AuthService.getMe(userId)` (`backend/src/modules/auth/auth.service.ts`)
loads the `UserEntity` then delegates the rank/points math to
`UsersRankService.rankOfUser(manager, userId)`
(`backend/src/modules/users/users-rank.service.ts`).
# `POST /api/v1/auth/change-password`
Authenticated-only password change. Requires the current password
(`mode='self'`); the admin-reset variant lives in a later Job.
| Field | Type | Constraints |
|-----------------------|--------|-----------------------------------------------------------------------------------|
| `oldPassword` | string | 1256 chars. Required when `mode='self'`. |
| `newPassword` | string | 1256 chars; passes `validatePassword` (Argon2 policy) AND must differ from old. |
| `confirmNewPassword` | string | Must equal `newPassword` (zod refine → `VALIDATION_FAILED`). |
Validated by `ChangePasswordDtoSchema` in `backend/src/modules/auth/dto/auth.dto.ts`.
## Successful response (204)
No body. The new password hash is persisted inside a `DataSource.transaction`
that also revokes every active `refresh_token` row for the user
(`revokedAt = now`), forcing the user to re-authenticate on other devices.
## Error envelope
| HTTP | `code` | When |
|------|---------------------------|----------------------------------------------------------------------------------------|
| 400 | `VALIDATION_FAILED` | zod body validation failed. |
| 400 | `PASSWORDS_DO_NOT_MATCH` | `newPassword !== confirmNewPassword`. |
| 400 | `PASSWORD_POLICY` | New password equals the old, or fails the Argon2 policy (`PASSWORD_MIN_LENGTH`, mixed-case requirement). |
| 401 | `UNAUTHORIZED` | No JWT on the request. |
| 401 | `INVALID_OLD_PASSWORD` | `argon2.verify(oldPasswordHash, oldPassword)` failed. |
## Wiring
`AuthService.changePassword(userId, dto)` runs inside a single
`DataSource.transaction`:
1. Validate `newPassword === confirmNewPassword``PASSWORDS_DO_NOT_MATCH`.
2. Reject `oldPassword === newPassword``PASSWORD_POLICY`.
3. `argon2.verify(user.passwordHash, dto.oldPassword)``INVALID_OLD_PASSWORD` on miss.
4. `validatePassword(dto.newPassword, config)` → rethrown as `PASSWORD_POLICY` on failure.
5. Re-hash with Argon2id and `manager.save(user)`.
6. UPDATE all `refresh_token` rows for the user where `revokedAt IS NULL`.
# Key Files
| File | Responsibility |
|------|----------------|
| `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, and mints sessions. |
| `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, mints sessions, exposes `getMe()` and `changePassword()`. |
| `backend/src/common/services/registration-rate-limit.service.ts` | Tracks timestamps and enforces the 10-per-minute per-IP limit. |
| `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes. |
| `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration request fields. |
| `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes plus authenticated `/me` and `/change-password`. |
| `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration, login, refresh, `/me`, and `change-password` request fields. |
| `backend/src/modules/users/users-rank.service.ts` | Pure provider computing `{ rank, points }` over the `solve` table for a user. |
| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` (now includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`). |
# 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.
2. `POST` with `{ withCredentials: true, observe: 'response' }`.
3. Return success or the standard [error envelope](/api/rest-overview.md).
The `LandingComponent` consumes both methods and maps failures to user-facing
copy (see [Landing Page](/guides/landing-page.md)).
`frontend/src/app/core/services/auth.service.ts` exposes `login()`,
`register()`, `me()`, `changePassword()`, `logout()`, and
`restoreSession()`. Each writes through the existing CSRF + auth
interceptors and returns a discriminated result type (`LoginResult`,
`RegisterResult`, `ChangePasswordResult`) for component-level error
handling. The authenticated shell's
[change-password modal](/guides/change-password.md) consumes
`changePassword()` and maps failures via
`formatChangePasswordError()` in
`frontend/src/app/features/shell/change-password/password-feedback.ts`.
# Examples
@@ -96,8 +174,18 @@ copy (see [Landing Page](/guides/landing-page.md)).
4. The first 10 attempts are processed normally; the 11th within 60 seconds returns HTTP `429` with code `RATE_LIMITED`.
5. After the rolling window expires, a new attempt is accepted if other requirements pass.
## Tester flow: change-password happy path
1. Sign in via `/login`.
2. Click the username dropdown in the shell header → "Change password".
3. Enter the current password, a new password that meets the policy, and the same new password in "Confirm new password".
4. Click **OK**. The modal closes and the success indicator (modal closes) is visible.
5. The user's existing refresh tokens have been revoked, so a `refresh` from any other tab will return 401 and that tab will be sent to `/login` on the next guard tick.
# See also
- [REST API Overview](/api/rest-overview.md)
- [Setup Endpoint](/api/setup.md)
- [Landing Page Guide](/guides/landing-page.md)
- [Authenticated Shell Guide](/guides/authenticated-shell.md)
- [Change Password Guide](/guides/change-password.md)
+3 -2
View File
@@ -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-21T18:28:00Z
timestamp: 2026-07-21T22:19:08Z
---
# Base URL & versioning
@@ -77,7 +77,8 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
`REGISTRATIONS_DISABLED`, `INTERNAL`.
`REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`,
`PASSWORDS_DO_NOT_MATCH`, `INTERNAL`.
# See also
+93 -25
View File
@@ -1,19 +1,25 @@
---
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
description: Bootstrap payload, public event status, public/authenticated SSE streams, and public event-window timestamps.
tags: [api, system, bootstrap, event, sse, settings]
timestamp: 2026-07-21T22:19:08Z
---
# 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. |
| 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/settings/event` | Public | Same. |
| `GET` | `/api/v1/events/status` | Authenticated (SSE) | Same. |
| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. |
> Path note: the **authenticated** status stream is mounted at the plural
> `/api/v1/events/status` so it can be distinguished from the legacy
> public `/api/v1/event/status` snapshot endpoint.
# `GET /api/v1/bootstrap`
@@ -28,7 +34,12 @@ SPA's `BootstrapService`:
"welcomeMarkdown": "# Welcome\n\n...",
"theme": { "id": "classic", "tokens": { "...": "..." } },
"defaultChallengeIp": "127.0.0.1",
"registrationsEnabled": false
"registrationsEnabled": false,
"passwordPolicy": {
"minLength": 12,
"requireMixed": true,
"description": "At least 12 characters with upper, lower, digit and symbol"
}
}
```
@@ -38,32 +49,89 @@ SPA's `BootstrapService`:
* `registrationsEnabled` is read from the `setting` table
(`SETTINGS_KEYS.REGISTRATIONS_ENABLED`) and gates the new public
register endpoint (see [Auth Endpoints](/api/auth.md)).
* `passwordPolicy` is derived from `PASSWORD_MIN_LENGTH` /
`PASSWORD_REQUIRE_MIXED` env vars (see
[Change Password Guide](/guides/change-password.md)).
# `GET /api/v1/event/status`
# `GET /api/v1/event/status` (legacy public snapshot)
Returns the current event window computed by `EventStatusService`:
Returns the current event window computed by `EventStatusService.getStatus()`:
```json
{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "..." }
{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "...", "serverNowUtc": "..." }
```
`state` is `Stopped` when `now < eventStartUtc` (countdown), `Running`
between start and end, and `Stopped` once `now >= eventEndUtc`.
`status` is `Stopped` while `now < eventStartUtc` (countdown), `Running`
between start and end, and `Stopped` once `now >= eventEndUtc`. This
endpoint is preserved for backward compatibility — new consumers should
use `getState()` (see below).
# SSE streams
# `GET /api/v1/settings/event` (public timestamps)
* `/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.
Returns the raw UTC event-window timestamps without computing state.
Useful for the SPA when only the window endpoints are needed:
Both use NestJS `@Sse()` and are proxied through `SseHubService`
(`backend/src/common/services/sse-hub.service.ts`).
```json
{ "eventStartUtc": "2026-07-21T12:00:00.000Z", "eventEndUtc": "2026-07-28T12:00:00.000Z" }
```
A `null` value for either field means that the underlying `setting` row
is missing or empty; in that case `EventStatusService.getState()` returns
the `unconfigured` state.
# `GET /api/v1/events/status` (authenticated SSE)
Authenticated Server-Sent Events stream that pushes the full
`EventStatePayload` whenever it changes. Protected by the global
`JwtAuthGuard` — unauthenticated clients receive `401` before any frame
is sent.
Each frame:
```json
{
"state": "countdown" | "running" | "stopped" | "unconfigured",
"serverNowUtc": "2026-07-21T12:00:00.000Z",
"eventStartUtc": "2026-07-21T12:00:00.000Z" | null,
"eventEndUtc": "2026-07-28T12:00:00.000Z" | null,
"secondsToStart": 3600,
"secondsToEnd": 604800
}
```
* `state` transitions:
* `unconfigured``eventStartUtc`/`eventEndUtc` settings are missing or unparseable.
* `countdown``now < eventStartUtc`.
* `running``start <= now <= end`.
* `stopped``now > eventEndUtc`.
* The stream emits:
1. An **initial** frame on connect (`defer(getState())`).
2. A **60-second tick** frame so the SPA can update even if no admin
cron publishes through the hub.
3. Any frame pushed via `SseHubService.publish()`.
* Consecutive identical payloads are suppressed via `distinctUntilChanged(JSON.stringify)`.
See `backend/src/common/services/event-status.service.ts` for the
authoritative state machine; see
[Event Window Guide](/guides/event-window.md) for the full diagram and
the SPA wiring.
# `GET /api/v1/event/stream` (public SSE, legacy)
Public, flattened event stream that emits the legacy payload shape
(`status`, `countdownMs`, `startUtc`, `endUtc`, `serverNowUtc`) on a
1-second tick and on hub pushes. Preserved for backward compatibility
with the original landing page status badge.
# `GET /api/v1/scoreboard/stream`
Public SSE stream that pushes a flattened solve payload whenever a new
solve is published (`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)
- [Event Window Guide](/guides/event-window.md)
- [Scoreboard Stream Guide](/guides/scoreboard-stream.md)
- [Authenticated Shell Guide](/guides/authenticated-shell.md)
+5 -5
View File
@@ -3,7 +3,7 @@ type: architecture
title: Backend Module Map
description: NestJS modules, controllers, services, and how they are wired together.
tags: [architecture, backend, nestjs, modules]
timestamp: 2026-07-21T14:43:00Z
timestamp: 2026-07-21T22:19:08Z
---
# Module Map
@@ -23,8 +23,8 @@ 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 + 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). |
| `AuthModule` | `backend/src/modules/auth/auth.module.ts` | `AuthController` (`/api/v1/auth/{register,login,refresh,logout,me,change-password,csrf}`) + `AuthService` (login/refresh/logout/register-first-admin + public `register` + `getMe` + `changePassword`). Imports `SettingsModule` so the public-register flow can read the `registrationsEnabled` flag, and `UsersModule` (forwardRef) for `UsersRankService`. |
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). |
| `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')`. |
@@ -40,7 +40,7 @@ also registers two global providers:
| `UsersController` | `/api/v1/auth/register-first-admin` | Public (bootstrap-only) | `backend/src/modules/users/users.controller.ts` |
| `SetupController` | `/api/v1/setup/create-admin` | Public (bootstrap-only; 409 `SYSTEM_INITIALIZED` once an admin exists) | `backend/src/modules/setup/setup.controller.ts` |
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
| `SystemController` | `/api/v1` | Public | `backend/src/modules/system/system.controller.ts` |
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `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` |
@@ -53,7 +53,7 @@ also registers two global providers:
| `AdminGuard` | `backend/src/common/guards/admin.guard.ts` | Requires authenticated user with `role === 'admin'`. |
| `RolesGuard` | `backend/src/common/guards/roles.guard.ts` | Enforces `@Roles(...)` metadata. |
| `GlobalExceptionFilter` | `backend/src/common/filters/global-exception.filter.ts` | Converts exceptions to standard envelope. |
| `EventStatusService` | `backend/src/common/services/event-status.service.ts` | Computes `Stopped`/`Running` and `countdownMs`. |
| `EventStatusService` | `backend/src/common/services/event-status.service.ts` | Computes the 4-state event state machine (`countdown`/`running`/`stopped`/`unconfigured`) via `getState()` plus the legacy `getStatus()` shape (`Stopped`/`Running` + `countdownMs`). |
| `LoginBackoffService` | `backend/src/common/services/login-backoff.service.ts` | Per-IP+username brute-force throttle. |
| `RegistrationRateLimitService` | `backend/src/common/services/registration-rate-limit.service.ts` | Per-IP rate limit on first-admin registration. |
| `SseHubService` | `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for SSE streams. |
+72 -28
View File
@@ -2,21 +2,24 @@
type: architecture
title: Frontend Structure
description: Angular routes, components, services, guards, and interceptors.
tags: [architecture, frontend, angular]
timestamp: 2026-07-21T18:28:00Z
tags: [architecture, frontend, angular, shell, sse]
timestamp: 2026-07-21T22:19:08Z
---
# Routes
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` | `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. |
| 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` | `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 the header (LED + countdown + user menu), quick-jump tabs (Challenges/Scoreboard/Blog), and a `<router-outlet>` for child routes. Default child route redirects to `/challenges`. |
| `/challenges` | `ChallengesPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the challenges Job lands. |
| `/scoreboard` | `ScoreboardPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the scoreboard Job lands. |
| `/blog` | `BlogPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the blog Job lands. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. |
| `**` | Redirect to `/` | — | Wildcard fallback. |
# Components
@@ -26,23 +29,34 @@ All components are standalone (no NgModules). Each component imports
| Component | Path | Purpose |
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
| `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` | Smart authenticated shell. Hosts the `ShellHeaderComponent`, `QuickTabsComponent`, the `ChangePasswordModalComponent`, and the `<router-outlet>` for child routes. Owns the active section / active tab signals and the SSE lifecycle for `EventStatusStore`. |
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb presentational header. Inputs: `pageTitle`, `activeSection`, `eventState`, `countdownText`, `username`, `rankText`, `canAccessAdmin`, `userMenuOpen`. Outputs: `titleClick`, `usernameMenuToggle`, `rankClick`, `changePasswordClick`, `adminClick`, `logoutClick`. Renders the LED (state class), `DD:HH:mm` countdown, and an inline user menu dropdown (`Change password`, optional `Admin area`, `Logout`). |
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb presentational tab strip. Inputs: `tabs: ShellTab[]`, `active: string`. Output: `tabChange: {id}`. |
| `ChangePasswordModalComponent` | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal with three fields (`oldPassword` only in `mode='self'`), `passwordMatchValidator` group validation, policy hint from `BootstrapService.passwordPolicy()`, and `errorMessage`/`submitting` inputs. Emits `submit(payload)` and `cancel()`. Closes on Escape and on overlay click. |
| `ChallengesPage` | `frontend/src/app/features/challenges/challenges.page.ts` | Placeholder child route. |
| `ScoreboardPage` | `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Placeholder child route. |
| `BlogPage` | `frontend/src/app/features/blog/blog.page.ts` | Placeholder child route. |
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
| `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. |
| `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, `refresh()`. |
| `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`. |
| `HomeShell` (pure helpers) | `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` + `deriveActiveSectionFromUrl(url)` predicates (unit-tested). |
# Services
| Service | Path | Purpose |
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
| `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. |
| `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()` / `logout()` / `me()` / `changePassword()` (each calls `ensureCsrf()` first). Returns discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult` for component-level error handling. |
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS, exposes a `passwordPolicy` computed signal derived from the bootstrap payload; `load()` / `ready()` deduplicate the in-flight fetch. |
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window. Owns `state`/`serverNowUtc`/`eventStartUtc`/`eventEndUtc`, derives a `countdownText` computed signal (`DD:HH:mm` or `"Event ended"`), and exposes `start(createSource)` / `stop()` so `HomeComponent` can drive a 1-second tick that re-derives the seconds-to-start/end locally between SSE pushes. |
| `EventStatusPure` | `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers consumed by the store and by tests: `deriveCountdownText(state, sStart, sEnd)`, `formatDdHhMm(totalSeconds)`, `EventSourceLike` interface, and the `EventState`/`EventStatePayload` types. |
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (id/username/role/rank/points + loading/error). `hydrateFromAuth()` mirrors `AuthService.currentUser()`; `loadMe()` calls `AuthService.me()` and merges rank/points. Exposes `rankText` (`"Rank: <r> - Points: <p>"`). |
| `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. |
| `ChangePassword` (pure helpers) | `frontend/src/app/features/shell/change-password/password-feedback.ts` | `formatChangePasswordError({code, message})` maps the API error code (`INVALID_OLD_PASSWORD` / `PASSWORDS_DO_NOT_MATCH` / `PASSWORD_POLICY` / `UNAUTHORIZED`) to user-facing copy. |
| `ChangePassword` (validators) | `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` (group-level reactive-forms validator) + `PasswordPolicy` zod schema used by the modal. |
# Guards and interceptors
@@ -70,8 +84,9 @@ Both interceptors are wired in `frontend/src/main.ts` via
2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with
`credentials: 'include'`).
3. The payload sets `initialized` (true iff any admin user exists),
`pageTitle`, `logo`, `welcomeMarkdown`, the active `theme`, and the
default challenge IP.
`pageTitle`, `logo`, `welcomeMarkdown`, the active `theme`, the
`defaultChallengeIp`, the `registrationsEnabled` flag, and the
`passwordPolicy` descriptor consumed by the change-password modal.
4. Theme tokens are written to CSS custom properties on
`document.documentElement` (`--color-primary`, `--font-family`,
`--radius-*`, etc.) consumed by `frontend/src/styles.css`.
@@ -86,23 +101,52 @@ Both interceptors are wired in `frontend/src/main.ts` via
/ `isAuthenticated()`, which prevents a flash of `/bootstrap` or
`/login` on a hard refresh of a deep link.
# Home shell flow
# Authenticated shell flow
After successful auth, `HomeComponent` renders a thin shell layout:
After successful auth, `HomeComponent` renders a full shell layout:
1. The header (`shell-header`) shows the page title and signed-in
identity.
2. When `shouldShowAdminNav({isAuthenticated, role})` returns `true`
(see `frontend/src/app/features/home/home.shell.ts`) the admin nav
link (`data-testid="nav-admin"`) is rendered.
3. Child routes (e.g. `/admin``AdminUsersComponent`) render into the
shell's `<router-outlet>`.
4. `AdminUsersComponent` calls `AdminService.listUsers()` on init;
loading, error, and the rendered list are exposed as Angular signals.
1. **`ShellHeaderComponent`** is fed (signal inputs) the page title
from bootstrap, the active section label (computed from the current
router URL), the `EventStatusStore.state()` (used to colour the
LED — `running` / `countdown` / `stopped` / `unconfigured`), and the
`countdownText()` (`DD:HH:mm` or `"Event ended"`). The user menu
trigger opens an inline dropdown with `Rank: <r> - Points: <p>`,
`Change password`, optional `Admin area` (visible only when
`canAccessAdmin()` is true), and `Logout`.
2. **`QuickTabsComponent`** renders `Challenges / Scoreboard / Blog`
tabs. The `active` input is computed from the current URL
(`HomeComponent.activeTabId()`). Clicking a tab emits `tabChange`
and the parent calls `router.navigateByUrl('/' + id)` (or `/admin`
for `id === 'admin'`).
3. The `<router-outlet>` renders whichever child route is active
(`ChallengesPage`, `ScoreboardPage`, `BlogPage`, or `AdminUsersComponent`).
4. On `ngOnInit`, `HomeComponent` calls
`UserStore.hydrateFromAuth()` + `UserStore.loadMe()` (which calls
`AuthService.me()``GET /api/v1/auth/me`) and starts the SSE
consumer via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`.
The store parses each `message` frame, applies the state via
`applyServerStatus(payload)`, and ticks the local
`secondsToStart` / `secondsToEnd` signals every second.
5. On `ngOnDestroy` (and via `DestroyRef.onDestroy` for safety),
`HomeComponent` calls `EventStatusStore.stop()` to close the SSE
source and clear the tick interval.
6. Clicking `Change password` in the header opens the
`ChangePasswordModalComponent` (mode `'self'`). Submitting posts
`/api/v1/auth/change-password` via `AuthService.changePassword()`;
on success the modal closes and the user's existing refresh tokens
are revoked server-side. See the
[Change Password Guide](/guides/change-password.md) for the full
tester flow.
7. Clicking `Logout` calls `AuthService.logout()` (which POSTs
`/api/v1/auth/logout`), resets the `UserStore`, and navigates to
`/login`.
# See also
- [System Overview](/architecture/overview.md)
- [Backend Module Map](/architecture/backend-modules.md)
- [Key Files Index](/architecture/key-files.md)
- [First-Run Bootstrap](/guides/bootstrap.md)
- [Admin Shell & User Management](/guides/admin-shell.md)
- [Authenticated Shell](/guides/authenticated-shell.md)
- [Change Password](/guides/change-password.md)
+39 -19
View File
@@ -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-21T18:45:54Z
timestamp: 2026-07-21T22:19:08Z
---
# Backend
@@ -25,12 +25,12 @@ timestamp: 2026-07-21T18:45:54Z
| `backend/src/database/migrations/1700000000000-InitSchema.ts` | Creates all tables and sets WAL + foreign keys. |
| `backend/src/database/migrations/1700000000100-SeedSystemData.ts` | Seeds the 6 system categories and default settings. |
| `backend/src/database/entities/user.entity.ts` | `user` table (admin/player role, enabled/disabled status). |
| `backend/src/database/entities/setting.entity.ts` | `setting` key/value table. |
| `backend/src/database/entities/setting.entity.ts` | `setting` table. |
| `backend/src/database/entities/category.entity.ts` | `category` table with `system_key` unique index. |
| `backend/src/database/entities/challenge.entity.ts` | `challenge` table (difficulty, decay, protocol, flag, port). |
| `backend/src/database/entities/challenge-file.entity.ts` | `challenge_file` table for attachments. |
| `backend/src/database/entities/solve.entity.ts` | `solve` table with unique `(challenge_id, user_id)`. |
| `backend/src/database/entities/refresh-token.entity.ts` | `refresh_token` table (token_hash + revocation). |
| `backend/src/database/entities/solve.entity.ts` | `solve` table with unique `(challenge_id, user_id)` (used by `UsersRankService`). |
| `backend/src/database/entities/refresh-token.entity.ts` | `refresh_token` table (token_hash + revocation). |
| `backend/src/database/entities/blog-post.entity.ts` | `blog_post` table (draft/published + publishedAt index). |
## Common
@@ -48,7 +48,7 @@ timestamp: 2026-07-21T18:45:54Z
| `backend/src/common/filters/global-exception.filter.ts` | Converts all errors to the standard envelope. |
| `backend/src/common/pipes/zod-validation.pipe.ts` | Validates body/param/query with a zod schema. |
| `backend/src/common/interceptors/transform.interceptor.ts` | Optional response transformer. |
| `backend/src/common/services/event-status.service.ts` | Computes event `Stopped`/`Running` and `countdownMs`. |
| `backend/src/common/services/event-status.service.ts` | 4-state machine (`countdown`/`running`/`stopped`/`unconfigured`) via `getState()`; legacy `getStatus()` shape preserved for backward compatibility. |
| `backend/src/common/services/login-backoff.service.ts` | Per-IP+username failed-login throttle. |
| `backend/src/common/services/registration-rate-limit.service.ts` | Per-IP first-admin registration throttle. |
| `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for event status and scoreboard streams. |
@@ -58,19 +58,20 @@ timestamp: 2026-07-21T18:45:54Z
| `backend/src/common/utils/upload.ts` | Upload size-limit parser + filename sanitizer. |
| `backend/src/common/types/theme.ts`, `theme-ids.ts` | `Theme` interface and the 10 canonical `ThemeId`s. |
| `backend/src/common/errors/api-error.ts` | `ApiError` class (status + machine code + message + details). |
| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` enum. |
| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` enum (now includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`). |
## Modules
| File | Responsibility |
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| `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/auth.module.ts` | Wires `AuthController`, `AuthService`, `JwtStrategy`; imports `SettingsModule` for the public register flow and `UsersModule` (forwardRef) for `UsersRankService`. |
| `backend/src/modules/auth/auth.controller.ts` | `/api/v1/auth/{register,login,refresh,logout,csrf,me,change-password}`. `logout`, `me`, and `change-password` are JWT-protected (CSRF-enforced on `change-password`). |
| `backend/src/modules/auth/auth.service.ts` | Login, refresh, logout, register-first-admin, public player self-registration (`registerPlayer`), session minting, `getMe(userId)` (rank + points), `changePassword(userId, dto)` (revoke-all-refresh-tokens on success). |
| `backend/src/modules/auth/cookie.ts` | `setRefreshCookie` / `clearRefreshCookie` helpers. |
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh + `RegisterDtoSchema` (with `password === passwordConfirm` refine + username regex). |
| `backend/src/modules/auth/dto/auth.dto.ts` | Zod schemas for login + refresh + `RegisterDtoSchema` + `ChangePasswordDtoSchema` + `MeResponseDtoSchema`. |
| `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` + `UsersRankService`. |
| `backend/src/modules/users/users-rank.service.ts` | Pure provider `rankOfUser(manager, userId)` returning `{rank, points}` by summing `pointsAwarded` from `solve` and counting distinct users ahead. |
| `backend/src/modules/users/users.controller.ts` | `/api/v1/auth/register-first-admin`. |
| `backend/src/modules/users/users.service.ts` | `enforceLastAdminInvariant` helper. |
| `backend/src/modules/users/dto/create-first-admin.dto.ts` | Zod schema for first-admin registration. |
@@ -83,8 +84,8 @@ timestamp: 2026-07-21T18:45:54Z
| `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. |
| `backend/src/modules/admin/dto/admin.dto.ts` | Zod schemas for admin endpoints. |
| `backend/src/modules/system/system.module.ts` | Wires `SystemController` + `SystemService` + status + hub services. |
| `backend/src/modules/system/system.controller.ts` | `/api/v1/{bootstrap,event/status}` + SSE `/event/stream`, `/scoreboard/stream`. |
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count. |
| `backend/src/modules/system/system.controller.ts` | `/api/v1/{bootstrap,event/status,event/stream,settings/event,scoreboard/stream}` (public) + authenticated SSE `/events/status` (plural, JWT-protected). |
| `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload from settings + theme + admin count + password policy. |
| `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`). |
@@ -111,10 +112,13 @@ timestamp: 2026-07-21T18:45:54Z
| `frontend/src/index.html` | Root HTML shell. |
| `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()`, `waitUntilHydrated()`, `login()`, `register()` (both with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`), `setSession()`. |
| `frontend/src/app/app.routes.ts` | Angular route table (`bootstrap`, `login`, shell `/` with child routes `challenges`/`scoreboard`/`blog`/`admin`, wildcard `**`). |
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()`, `waitUntilHydrated()`, `login()`, `register()`, `logout()`, `me()`, `changePassword()` (all with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`/`ChangePasswordResult`), `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/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes a `passwordPolicy` computed signal derived from the bootstrap payload. |
| `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window (`state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, derived `countdownText`); `start(createSource)`/`stop()` manage the SSE connection + 1-second tick. |
| `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers used by the store and tests: `deriveCountdownText`, `formatDdHhMm`, `EventSourceLike`, `EventState`/`EventStatePayload`. |
| `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (`user`, `loading`, `error`, derived `rankText`); `hydrateFromAuth()` + `loadMe()` (calls `AuthService.me()`). |
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
| `frontend/src/app/core/services/markdown.service.ts` | Thin Angular `MarkdownService.render(md): string` that forwards to `renderMarkdownToHtml`. The DOMPurify sanitization happens inside `renderMarkdownToHtml` so Angular's `[innerHTML]` binding receives a sanitized plain string — no `DomSanitizer.bypassSecurityTrustHtml` wrapper is required. |
| `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 and so the sanitization boundary is shared between production and tests). |
@@ -134,14 +138,22 @@ timestamp: 2026-07-21T18:45:54Z
| `frontend/src/app/features/setup/setup-create-admin.service.ts` | POSTs to `/api/v1/setup/create-admin` and tags the result with the error code. |
| `frontend/src/app/features/setup/setup-create-admin.validators.ts` | Standalone `passwordMatchValidator` group-level reactive form validator. |
| `frontend/src/app/features/setup/setup-create-admin.field-errors.ts` | Pure `usernameError` / `passwordError` / `confirmError` helpers that turn reactive-form state into the inline validation messages rendered next to each input. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell: header + conditional admin nav + `<router-outlet>`. |
| `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` predicate (unit-tested). |
| `frontend/src/app/features/home/home.component.ts` | Smart authenticated shell: renders `ShellHeaderComponent`, `QuickTabsComponent`, `ChangePasswordModalComponent`, and `<router-outlet>`. Owns the SSE lifecycle (`EventStatusStore.start`/`stop`), the `UserStore.loadMe()` call, and the open/close signals for the change-password modal. |
| `frontend/src/app/features/home/home.shell.ts` | Pure `shouldShowAdminNav({isAuthenticated, role})` and `deriveActiveSectionFromUrl(url)` predicates (unit-tested). |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb header: page title, active-section label, LED (`led-{state}` class), countdown text, user menu trigger, inline user menu dropdown with rank / change-password / admin / logout entries. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb `Challenges / Scoreboard / Blog` tab strip; `tabs`/`active` inputs, `tabChange` output. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal (mode `'self'|'admin-reset'`, `passwordMatchValidator`, policy hint, `errorMessage`/`submitting` inputs). Emits `submit({old,new,confirm})` and `cancel()`. |
| `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` group validator + `PasswordPolicy` zod schema. |
| `frontend/src/app/features/shell/change-password/password-feedback.ts` | Pure `formatChangePasswordError({code, message})` helper that maps the API code to user-facing copy. |
| `frontend/src/app/features/challenges/challenges.page.ts` | Placeholder `/challenges` page. |
| `frontend/src/app/features/scoreboard/scoreboard.page.ts` | Placeholder `/scoreboard` page. |
| `frontend/src/app/features/blog/blog.page.ts` | Placeholder `/blog` page. |
# Tests
| File | Responsibility |
|------------------------------------------------|--------------------------------------------------------------------------------|
| `tests/backend/*.spec.ts` (18 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits. |
| `tests/backend/*.spec.ts` (~25 suites) | Jest tests for backend modules, CSRF, OpenAPI 3.1, migrations, rate limits, the new `/me` + `/change-password` + authenticated SSE flows, and `UsersRankService`. |
| `tests/frontend/theme.spec.ts` | Jest test for theme token application. |
| `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. |
| `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. |
@@ -152,6 +164,14 @@ timestamp: 2026-07-21T18:45:54Z
| `tests/frontend/landing-markdown.spec.ts` | Pins `renderMarkdownToHtml` (sanitization of raw `<script>` payloads, script URL schemes, etc.). |
| `tests/frontend/landing-welcome.spec.ts` | Regression test for the landing welcome binding — asserts the bootstrap payload's `welcomeMarkdown` produces the expected sanitized HTML (h1 + paragraph) and never the Angular runtime helper source string, including for null/empty/hostile payloads. |
| `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/frontend/event-status.store.spec.ts` | Pins `EventStatusStore.applyServerStatus` (state + delta anchor) and `deriveCountdownText` (`countdown` / `running` / `stopped` / `unconfigured`). |
| `tests/frontend/change-password-modal.spec.ts` | Pins the dumb modal's reactive form (validation, submit/cancel emission, error rendering). |
| `tests/frontend/shell-active-section.spec.ts` | Pins `deriveActiveSectionFromUrl` for the four URL branches. |
| `tests/backend/change-password.spec.ts` | Integration coverage for `/api/v1/auth/change-password` (wrong-old / mismatched / weak / unauth branches). |
| `tests/backend/event-status-state.spec.ts` | Pins the 4-state derivation in `EventStatusService.getState()`. |
| `tests/backend/events-status-sse.spec.ts` | Pins the authenticated SSE stream (401 + initial `EventStatePayload`). |
| `tests/backend/me-endpoint.spec.ts` | Pins `/api/v1/auth/me` (401 + payload shape including rank/points). |
| `tests/backend/rank-points.spec.ts` | Pure unit test for `UsersRankService.rankOfUser()`. |
| `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. |
+3 -1
View File
@@ -3,7 +3,7 @@ type: guide
title: Admin Shell & User Management
description: How an authenticated admin navigates the post-login shell and reaches the admin user-management area.
tags: [guide, admin, shell, navigation, tester]
timestamp: 2026-07-21T15:05:00Z
timestamp: 2026-07-21T22:19:08Z
---
# When this view is available
@@ -96,5 +96,7 @@ renders.
- [First-Run Bootstrap](/guides/bootstrap.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Authenticated Shell](/guides/authenticated-shell.md) — header / LED / quick tabs / change-password modal / user menu (the surrounding shell this page renders into).
- [Change Password](/guides/change-password.md)
- [REST API Overview](/api/rest-overview.md)
- [Admin Endpoints](/api/admin.md)
+166
View File
@@ -0,0 +1,166 @@
---
type: guide
title: Authenticated Shell
description: How a signed-in user experiences the post-login shell — header, LED + countdown, quick tabs, user menu, and change-password modal.
tags: [guide, shell, header, sse, navigation, tester]
timestamp: 2026-07-21T22:19:08Z
---
# When this view is available
The authenticated shell is the post-login UI rendered by
`HomeComponent` (`frontend/src/app/features/home/home.component.ts`).
It is gated by:
| Layer | File | Check |
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `''` uses `authGuard`; child routes inherit it. |
| Server route | `backend/src/modules/system/system.controller.ts` (`@Sse('events/status')`) | JWT-required (global `JwtAuthGuard`). 401 on missing/expired token. |
A user who is not signed in (or whose session has expired) is sent to
`/login` by `authGuard` before the shell ever mounts; the SSE stream
will not be opened until a valid JWT is present.
# How to access (tester steps)
1. Ensure the instance is initialized
(`GET /api/v1/bootstrap``initialized: true`). If not, follow
[First-Run Bootstrap](/guides/bootstrap.md).
2. Sign in via `/login` (admin or player).
3. The browser lands on `/` (which redirects to `/challenges`) and
renders the full **authenticated shell**.
# Expected behavior
## Header (`ShellHeaderComponent`)
| Region / element | Selector | Expected |
|------------------------------|------------------------------------------|---------------------------------------------------------------------------|
| Page title (clickable) | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle` (default `HIPCTF`). Clicking it navigates to `/challenges`. |
| Active section label | `[data-testid="shell-active-section"]` | `Challenges` / `Scoreboard` / `Blog` / `Admin` based on the URL (`deriveActiveSectionFromUrl`). |
| Event status LED | `[data-testid="shell-led"]` | One of `led-running`, `led-countdown`, `led-stopped`, `led-unconfigured` classes. `aria-label="Event status: <state>"`. |
| Countdown text | `[data-testid="shell-countdown"]` | `DD:HH:mm` while `countdown` / `running`, `"Event ended"` while `stopped`, empty while `unconfigured`. Updates every second. |
| User menu trigger | `[data-testid="user-menu-trigger"]` | Shows the current username + `▾`. Toggles the inline dropdown. |
| User menu — rank entry | `[data-testid="user-menu-rank"]` | `"Rank: <r> - Points: <p>"` (or empty while rank unknown / 0 points). Clicking navigates to `/scoreboard`. |
| User menu — change password | `[data-testid="user-menu-change-password"]` | Opens the [change-password modal](/guides/change-password.md). |
| User menu — admin area | `[data-testid="user-menu-admin"]` | Visible only when `canAccessAdmin()` is true (admin role). Navigates to `/admin`. |
| User menu — logout | `[data-testid="user-menu-logout"]` | Logs out and navigates to `/login`. See the [auth.md logout flow](/api/auth.md). |
## Quick tabs (`QuickTabsComponent`)
| Region / element | Selector | Expected |
|-------------------------|--------------------------------|----------------------------------------------------------------|
| Tab strip | `[data-testid="quick-tabs"]` | Three buttons in order: `Challenges`, `Scoreboard`, `Blog`. |
| Each tab button | `[data-testid="quick-tab-<id>"]` | `class.active` matches the current `active()` tab. |
| Tab click | emits `tabChange: {id}` | Parent navigates to `/{id}` (or `/admin` when `id === 'admin'`). |
The default child route is `'' → challenges`, so visiting `/` lands on
the Challenges page with the `Challenges` tab highlighted.
## Body
| Region | Notes |
|-------------------------|-------------------------------------------------------------------------|
| `<router-outlet>` | Renders the active child route (`ChallengesPage`, `ScoreboardPage`, `BlogPage`, `AdminUsersComponent`). |
## Live event window
1. On `ngOnInit` `HomeComponent` opens
`new EventSource('/api/v1/events/status', { withCredentials: true })`
via `EventStatusStore.start(...)`.
2. The first frame is the current state; subsequent frames arrive on
transitions and on the server's 60-second tick.
3. The store runs a local 1-second tick so the displayed countdown
advances between server pushes, using the stored clock-skew anchor.
4. On `ngOnDestroy` (and the store's `DestroyRef.onDestroy`) the source
is closed and the tick is cleared.
If the user logs out, the next refresh tick or route change tears the
SSE down. If the SSE errors out, the store simply stops applying
frames; the header LED retains its last class until a new connect
attempt lands a frame.
# Visual elements
| Element | Selector | Purpose |
|----------------------------------|---------------------------------------|-------------------------------------------------------------------------|
| Shell header bar | `[data-testid="shell-header"]` | Holds the title, active section, LED + countdown, and user menu. |
| Tab strip | `[data-testid="quick-tabs"]` | Top-level navigation between the three main pages. |
| Shell body | `.shell-body` | Hosts `<router-outlet>` for child routes. |
# Architecture map
| Step | Where | What happens |
|------|----------------------------------------------------------------|----------------------------------------------------------------------------------------------------|
| 1 | `frontend/src/app/app.routes.ts` | `/` is a parent route with `authGuard`; child routes `challenges`/`scoreboard`/`blog`/`admin` (admin uses `adminGuard`). |
| 2 | `frontend/src/app/core/guards/auth.guard.ts` | `authGuard` waits on bootstrap + auth hydration and delegates to `decideAuthRedirect`. |
| 3 | `frontend/src/app/features/home/home.component.ts` | Smart shell: instantiates `EventStatusStore`, `UserStore`, `AuthService`; manages the SSE lifecycle. |
| 4 | `frontend/src/app/core/services/event-status.store.ts` | `start(createSource)` opens the SSE connection, parses each `message` frame into `applyServerStatus()`, and ticks `secondsToStart`/`secondsToEnd` locally. |
| 5 | `frontend/src/app/core/services/user.store.ts` | `loadMe()` calls `AuthService.me()` (`GET /api/v1/auth/me`) → `UsersRankService.rankOfUser` for `rank` + `points`. |
| 6 | `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb presentational header rendering title, active section, LED, countdown, and user menu. |
| 7 | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Dumb tab strip. Click emits `tabChange`; parent navigates. |
| 8 | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb reactive-form modal opened via the `changePasswordClick` output. See [Change Password Guide](/guides/change-password.md). |
| 9 | `backend/src/modules/auth/auth.controller.ts` | `GET /api/v1/auth/me` returns `{id, username, role, rank, points}`. |
| 10 | `backend/src/modules/users/users-rank.service.ts` | `rankOfUser(manager, userId)``{rank, points}` over the `solve` table. |
| 11 | `backend/src/modules/system/system.controller.ts` | `GET /api/v1/events/status` (JWT) → SSE emitting the full `EventStatePayload`. |
# Tester flows
## Live event window
1. Configure `eventStartUtc` so the event is currently in `countdown`:
the LED should show the countdown colour, the countdown text should
read `00:00:NN` and decrement each second.
2. Move `eventStartUtc` to a past timestamp and `eventEndUtc` to a
future timestamp. The LED switches to the `running` class and the
countdown now reads `DD:HH:mm` to end.
3. Move `eventEndUtc` to a past timestamp. The LED switches to the
`stopped` class and the countdown text reads `Event ended`.
4. Clear one of the two `setting` rows. The LED switches to
`unconfigured` and the countdown text is empty.
## Change-password modal (happy path)
1. Sign in as a player (or admin).
2. Open the user menu and click **Change password**.
3. Enter the current password, a new password that meets the policy
(length + mixed-case), and the same new password in confirmation.
4. Click **OK** → the modal closes. Other tabs that were signed in to
the same account will be forced to `/login` because all refresh
tokens for the user were revoked server-side.
5. See [Change Password Guide](/guides/change-password.md) for the
error branches (wrong-old / mismatched / weak / unauth).
## Admin-only entries
1. Sign in as an admin and confirm the **Admin area** entry appears in
the user menu. Clicking it navigates to `/admin` and renders the
existing `AdminUsersComponent` inside the shell body.
2. Sign in as a player and confirm the **Admin area** entry is hidden.
Navigating directly to `/admin` is intercepted by `adminGuard` and
redirects back to `/`.
# Notes
* The shell deliberately keeps `HomeComponent` as a smart container
with dumb children (`ShellHeaderComponent`, `QuickTabsComponent`,
`ChangePasswordModalComponent`) so they can be unit-tested in
isolation.
* The active-section predicate (`deriveActiveSectionFromUrl`) and the
admin-nav predicate (`shouldShowAdminNav`) live in
`frontend/src/app/features/home/home.shell.ts` and are exported so
they can be unit-tested without Angular DI. See
`tests/frontend/shell-active-section.spec.ts` and
`tests/frontend/admin-shell.spec.ts`.
* The change-password modal renders `mode='self'` from the shell; the
`mode='admin-reset'` variant (where `oldPassword` is hidden) is wired
by the future admin-reset Job.
# See also
- [Admin Shell & User Management](/guides/admin-shell.md)
- [Change Password](/guides/change-password.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Auth Endpoints](/api/auth.md)
- [System Endpoints](/api/system.md)
- [Event Window](/guides/event-window.md)
+155
View File
@@ -0,0 +1,155 @@
---
type: guide
title: Change Password
description: How an authenticated user changes their own password from the shell, and what each error branch looks like.
tags: [guide, change-password, auth, tester]
timestamp: 2026-07-21T22:19:08Z
---
# When this view is available
The change-password modal is part of the [authenticated shell](/guides/authenticated-shell.md).
It is opened from the user menu (trigger `data-testid="user-menu-trigger"`
→ entry `data-testid="user-menu-change-password"`).
| Layer | File | Check |
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | The shell (`/`) uses `authGuard`. |
| Client modal | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Rendered inside the shell by `HomeComponent`. |
| Server route | `backend/src/modules/auth/auth.controller.ts` | `POST /api/v1/auth/change-password` — JWT-protected, CSRF-enforced. |
# How to access (tester steps)
1. Sign in via `/login`.
2. Click the username dropdown in the shell header (`data-testid="user-menu-trigger"`).
3. Click **Change password** (`data-testid="user-menu-change-password"`).
4. The modal overlay appears (`data-testid="change-password-overlay"`) with the modal card (`data-testid="change-password-modal"`).
# Expected behavior
## Inputs (mode = `self`)
| Field | Selector | Constraints |
|------------------------|---------------------------|------------------------------------------------------------------------------|
| Old password | `[data-testid="cp-old"]` | Required when `mode === 'self'`. Hidden in `mode === 'admin-reset'` (future Job). |
| New password | `[data-testid="cp-new"]` | Required. Must satisfy the server-side `validatePassword` (length + mixed-case per `PASSWORD_MIN_LENGTH` / `PASSWORD_REQUIRE_MIXED`). |
| Confirm new password | `[data-testid="cp-confirm"]` | Required. Must equal `newPassword` (group-level `passwordMatchValidator`). |
| Policy hint | `[data-testid="cp-policy"]` | Renders `passwordPolicy.description` from the bootstrap payload (e.g. `"At least 12 characters with upper, lower, digit and symbol"`). |
| Cancel button | `[data-testid="cp-cancel"]` | Disabled while submitting. Emits `cancel`. |
| OK button | `[data-testid="cp-ok"]` | Disabled while submitting. Submits the form. |
The modal closes on Escape (`@HostListener('document:keydown.escape')`)
and on overlay click (the card stops propagation). Cancel is blocked
while `submitting()` is true.
## Inline validation (client-side)
| State | Selector | Message |
|---------------------------------------------|----------------------------------|----------------------------------|
| `newPassword` and `confirmNewPassword` differ | `[data-testid="cp-form-error"]` | `Passwords do not match` |
| API error returned | `[data-testid="cp-error"]` | Mapped via `formatChangePasswordError()` (see below). |
## Successful response
| Outcome | Visible behaviour |
|--------------------------------------------|------------------------------------------------------------------------|
| HTTP 204 | Modal closes (`changePasswordOpen.set(false)`), user stays signed in on this tab. All other refresh tokens for the user are revoked server-side — other tabs will be forced to `/login` on their next guard tick. |
## Error envelope
The frontend maps each error code to user-facing copy via
`formatChangePasswordError({code, message})` in
`frontend/src/app/features/shell/change-password/password-feedback.ts`:
| HTTP | `code` | Modal message |
|------|---------------------------|--------------------------------------------------------------|
| 400 | `PASSWORDS_DO_NOT_MATCH` | "New password and confirmation do not match" |
| 400 | `PASSWORD_POLICY` | The server's policy text (or "New password does not meet the policy requirements" if empty). |
| 400 | `VALIDATION_FAILED` | The server's policy text (fallback "Failed to change password"). |
| 401 | `INVALID_OLD_PASSWORD` | "Old password is incorrect" |
| 401 | `UNAUTHORIZED` | "You must be signed in to change your password" |
| other| (anything else) | Server `message` (or fallback "Failed to change password"). |
# Visual elements
| Element | Selector | Purpose |
|----------------------------------|-------------------------------------------|--------------------------------------------------------|
| Modal overlay | `[data-testid="change-password-overlay"]` | Backdrop; clicking it cancels. |
| Modal card | `[data-testid="change-password-modal"]` | Stops propagation so card clicks don't cancel. |
| Form error (match) | `[data-testid="cp-form-error"]` | Inline error from `passwordMatchValidator`. |
| Server error | `[data-testid="cp-error"]` | Inline error from `formatChangePasswordError()`. |
# Architecture map
| Step | Where | What happens |
|------|-----------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| 1 | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Dumb modal with reactive form, `passwordMatchValidator`, policy hint, `errorMessage`/`submitting` inputs. |
| 2 | `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` group validator + `PasswordPolicy` zod schema. |
| 3 | `frontend/src/app/features/shell/change-password/password-feedback.ts` | `formatChangePasswordError({code, message})` — pure helper unit-tested in `tests/frontend/change-password-modal.spec.ts`. |
| 4 | `frontend/src/app/features/home/home.component.ts` | Smart shell: owns `changePasswordOpen`/`changePasswordSubmitting`/`changePasswordError` signals; on `submit(payload)` calls `AuthService.changePassword()`. |
| 5 | `frontend/src/app/core/services/auth.service.ts` | `changePassword(dto)` runs `ensureCsrf()` then `POST /api/v1/auth/change-password` with `withCredentials: true`; returns `ChangePasswordResult`. |
| 6 | `backend/src/modules/auth/dto/auth.dto.ts` | `ChangePasswordDtoSchema` (zod) — `confirmNewPassword` refines to `=== newPassword`. |
| 7 | `backend/src/modules/auth/auth.service.ts` | `changePassword(userId, dto)` (transaction): mismatch check → `argon2.verify(old)``validatePassword(new)` → re-hash + UPDATE `refresh_token SET revokedAt = now WHERE userId = ? AND revokedAt IS NULL`. |
| 8 | `backend/src/common/utils/password-policy.ts` | `validatePassword(password, config)` enforces `PASSWORD_MIN_LENGTH` and (when enabled) the mixed-case requirement. |
# Tester flows
## Happy path
1. Sign in. Open the user menu → **Change password**.
2. Enter the current password, a new password meeting the policy, and
the same new password in confirmation.
3. Click **OK**. The modal closes; the user remains signed in.
4. Sign-in from another browser session using the same account — the
new password works; the previous password no longer works.
## Wrong old password
1. Sign in, open the modal.
2. Enter an incorrect "Old password", a valid "New password", and the
same new password in confirmation.
3. Click **OK**. The modal stays open and the inline error reads
"Old password is incorrect". No DB write happens.
## Mismatched confirmation
1. Sign in, open the modal.
2. Enter the current password, a valid new password, and a different
confirmation. The `cp-form-error` element appears immediately with
"Passwords do not match" (the OK button stays disabled until the
values match because the reactive form is invalid).
3. Fix the confirmation. The error disappears.
## Weak new password
1. Sign in, open the modal.
2. Enter the current password and a too-short new password (e.g.
`"abc"`). The modal submits; the server returns 400 with
`code=PASSWORD_POLICY`; the inline error reads the server-supplied
policy message.
## Unauthenticated
1. Manually clear the session token in DevTools and submit the modal.
The server returns 401 `UNAUTHORIZED`; the inline error reads
"You must be signed in to change your password". The frontend
then has no usable session, so the next guard tick (e.g. clicking
a tab) sends the user to `/login`.
# Notes
* The change-password modal always runs in `mode='self'` from the
shell. The `mode='admin-reset'` input (where the `oldPassword` field
is hidden) is supported by the modal component and will be wired by
the admin-reset Job.
* The server revokes **all** refresh tokens for the user on success,
so other devices must re-authenticate. The local `sessionStorage`
session on this tab is preserved because the response is 204 (no new
tokens are issued).
# See also
- [Authenticated Shell](/guides/authenticated-shell.md)
- [Auth Endpoints](/api/auth.md) (`GET /api/v1/auth/me` + `POST /api/v1/auth/change-password`)
- [Auth and Settings Tables](/database/auth-settings.md) (`refresh_token` revocation)
- [REST API Overview](/api/rest-overview.md) (error envelope + CSRF)
+71 -18
View File
@@ -1,9 +1,9 @@
---
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
description: How the event window state machine is computed and surfaced via REST + SSE.
tags: [guide, event, countdown, sse, state-machine]
timestamp: 2026-07-21T22:19:08Z
---
# Configuration
@@ -13,33 +13,86 @@ 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. |
| `eventEndUtc` | `now + 7d` | ISO 8601 instant the event ends. |
Both are seeded by `SeedSystemData1700000000100` and editable through
`SettingsService`.
# State computation
# State machine
`EventStatusService.getStatus(now)` (`backend/src/common/services/event-status.service.ts`)
returns one of three branches:
`EventStatusService.getState(now)`
(`backend/src/common/services/event-status.service.ts`) returns one of
four branches:
| Condition | `status` | `countdownMs` |
|-------------------------|----------|----------------------|
| `now < eventStartUtc` | `Stopped` (countdown) | `eventStartUtc - now` |
| `now ∈ [start, end]` | `Running` | `eventEndUtc - now` |
| `now >= eventEndUtc` | `Stopped` (ended) | `0` |
| Condition | `state` | `secondsToStart` | `secondsToEnd` |
|----------------------------------------|------------------|--------------------------|--------------------------|
| `eventStartUtc` / `eventEndUtc` missing or unparseable | `unconfigured` | `null` | `null` |
| `now < eventStartUtc` | `countdown` | `floor((start - now)/s)` | `null` |
| `start <= now <= end` | `running` | `null` | `floor((end - now)/s)` |
| `now > eventEndUtc` | `stopped` | `null` | `null` |
The legacy `EventStatusService.getStatus(now)` shape
(`{status: 'Stopped'|'Running', countdownMs, startUtc, endUtc, serverNowUtc}`)
is preserved for backward compatibility. `Stopped` is overloaded to
mean "still counting down" (`countdownMs = eventStartUtc - now`) when
`state === 'countdown'`, and "ended" (`countdownMs = 0`) when
`state === 'stopped'` / `unconfigured`.
# 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.
| Endpoint | Auth | Shape |
|--------------------------------------------|-------------|-------------------------------------------|
| `GET /api/v1/event/status` | `@Public()` | Legacy `{status, countdownMs, startUtc, endUtc, serverNowUtc}`. |
| `GET /api/v1/event/stream` | `@Public()` | SSE that emits the legacy payload on a 1-second tick + on hub pushes. |
| `GET /api/v1/settings/event` | `@Public()` | Raw `{eventStartUtc, eventEndUtc}` (null when unset). |
| `GET /api/v1/events/status` | Authenticated (JWT) | SSE that emits the full `EventStatePayload` on connect + on a 60-second tick + on hub pushes; consecutive identical payloads suppressed via `distinctUntilChanged(JSON.stringify)`. |
See [System Endpoints](/api/system.md) for the full request/response
shape.
# Frontend wiring
The authenticated shell subscribes to `/api/v1/events/status` from
`HomeComponent.ngOnInit()` via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`
(`frontend/src/app/features/home/home.component.ts`,
`frontend/src/app/core/services/event-status.store.ts`). The store:
1. Calls `applyServerStatus(payload)` on every `message` frame (parses
JSON, sets `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`,
and stores the clock-skew anchor `Date.now() - serverNow`).
2. Runs a local 1-second tick so the `secondsToStart` / `secondsToEnd`
computed signals update between SSE pushes.
3. Exposes `countdownText` (computed): `""` for `unconfigured`,
`"Event ended"` for `stopped`, and `formatDdHhMm(seconds)` for
`countdown` / `running`. The format helper lives in the pure module
`event-status.pure.ts` (`DD:HH:mm`, zero-padded).
The header (`ShellHeaderComponent`) renders an LED with the
`led-{state}` class so the colour reflects the current state (the
exact theme colours are wired in `styles.css` against the existing
`--color-success` / `--color-warning` / `--color-danger` tokens).
`HomeComponent.ngOnDestroy()` (and the `DestroyRef` hook in the store
itself) close the SSE source and clear the tick interval, so leaving
the shell (e.g. by logging out and landing on `/login`) tears the
stream down cleanly.
# Push path
1. The admin cron / settings update writes to `setting.eventStartUtc` /
`setting.eventEndUtc`.
2. `SseHubService.publish('event', payload)` fans the new payload out
to every subscriber of `/api/v1/event/stream` and (via the
`flat()` mapping) `/api/v1/events/status`.
3. Each connected client receives the new state immediately; otherwise
it picks up the next 60-second tick.
The hub is in-process; multi-replica deployments need a shared pub/sub
to fan out across pods (not in scope for this repo).
# See also
- [Auth and Settings Tables](/database/auth-settings.md)
- [System Endpoints](/api/system.md)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Authenticated Shell](/guides/authenticated-shell.md)
+16 -8
View File
@@ -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-21T21:18:00Z.
they need. Last regenerated 2026-07-21T22:19:08Z.
# Architecture
@@ -20,7 +20,8 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
* [Backend Module Map](/architecture/backend-modules.md) - NestJS modules,
controllers, services, and their wiring.
* [Frontend Structure](/architecture/frontend-structure.md) - Angular routes,
components, services, and guards.
components, services, guards, and stores (including the authenticated shell
+ signal stores).
* [Key Files Index](/architecture/key-files.md) - One-line summary of every
important source file in the repository.
@@ -38,14 +39,15 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
* [REST API Overview](/api/rest-overview.md) - Base URL, versioning, auth,
CSRF, and error envelope.
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, and
first-admin registration.
* [Auth Endpoints](/api/auth.md) - Login, refresh, logout, CSRF, `/me`,
`/change-password`, and first-admin registration.
* [Setup Endpoint](/api/setup.md) - Dedicated `POST /api/v1/setup/create-admin`
used only while no administrator exists.
* [Admin Endpoints](/api/admin.md) - User management endpoints
(admin role required).
* [System Endpoints](/api/system.md) - Bootstrap, event status, and SSE
streams.
* [System Endpoints](/api/system.md) - Bootstrap, event status, public SSE
streams, public event-window settings, and the authenticated
`/events/status` SSE stream.
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads.
# Guides
@@ -57,6 +59,12 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
messages on the first-admin modal.
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins
navigate the post-login shell and reach the user-management area.
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page.
* [Change Password](/guides/change-password.md) - How a signed-in user
changes their own password, including every error branch and the
refresh-token revocation behavior.
* [Session Restoration on Reload](/guides/session-restoration.md) - How
the SPA keeps a user signed in across hard refreshes and how the
guards wait for bootstrap + auth hydration before deciding where to
@@ -65,7 +73,7 @@ they need. Last regenerated 2026-07-21T21:18:00Z.
(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.
* [Event Window](/guides/event-window.md) - How the 4-state event
window and live countdown work, including the authenticated SSE stream.
* [Scoreboard Stream](/guides/scoreboard-stream.md) - How live solves are
broadcast to clients.