AI Implementation feature(876): Authenticated Shell, Quick Tabs and Change Password 1.01 #16

Merged
m0rph3us1987 merged 2 commits from feature-876-1784675030680 into dev 2026-07-21 23:22:06 +00:00
11 changed files with 483 additions and 546 deletions
-105
View File
@@ -1,105 +0,0 @@
# Implementation Plan: Authenticated Shell, Quick Tabs and Change Password
**[ALREADY_IMPLEMENTED]**
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** NestJS 10 (backend) + Angular 17 standalone components (frontend). Both written in TypeScript with strict types. Frontend uses Angular signals + `input()`/`output()` APIs (no NgModules). Tests are pure Jest (no TestBed for most suites) and target the success path; integration tests for HTTP routes use `supertest` against a bootstrapped `INestApplication`. Per the project `package.json`, the single command `npm test` runs every suite (backend + frontend) from the repo root.
- **Data Layer:** SQLite via `better-sqlite3` + TypeORM. Single database file mounted at `/data/hipctf/db.sqlite` (WAL journal mode). Schema is owned by migrations under `backend/src/database/migrations/`; the seed migration (`SeedSystemData1700000000100`) inserts the six system `category` rows and the eight default `setting` rows but **does not create any user accounts**. Argon2id is used for password hashing (`backend/src/common/utils/password-policy.ts`).
- **Test Framework & Structure:** Jest 29 with two projects in `tests/jest.config.js``backend` (node env, runs `tests/backend/*.spec.ts`) and `frontend` (jsdom env, runs `tests/frontend/*.spec.ts`). Per the job's "Important Rule Concerning Tests": minimal, focused suites that live under `tests/`, exercise only the core success + key error paths, and never require UI interaction. `npm test` (root) runs all of them.
- **Required Tools & Dependencies:** No new system tools, CLI utilities, or packages are needed — the shell, quick-tabs, change-password modal and the `/api/v1/auth/{login,me,change-password}` routes are already wired and tested. The only new requirement is **runtime/test-fixture data**: a known `player1` account that the inspector (and an automated smoke test) can sign in with.
## 2. Already-Implemented Surface (Job is satisfied by the existing code)
After auditing the repo, every piece of behavior listed in the Job description — and every behavior called out by the linked docs — is already implemented and unit-tested. Below is the evidence the implementer (or reviewer) needs to verify each Item.
### Already implemented — Backend
| Feature | File | Notes |
|---|---|---|
| `POST /api/v1/auth/login` (CSRF-enforced since `csrf-protected-routes.spec.ts` regression) | `backend/src/modules/auth/auth.controller.ts:62` | Returns `{accessToken, expiresIn, user}`; sets the `rt` HttpOnly cookie via `setRefreshCookie`. |
| `GET /api/v1/auth/me` (returns `{id, username, role, rank, points}`) | `backend/src/modules/auth/auth.controller.ts:114` (`AuthService.getMe``UsersRankService.rankOfUser`) | |
| `POST /api/v1/auth/change-password` (204 on success) | `backend/src/modules/auth/auth.controller.ts:135` | All four documented error codes (`INVALID_OLD_PASSWORD`, `PASSWORDS_DO_NOT_MATCH`, `PASSWORD_POLICY`, `UNAUTHORIZED`); revokes every active `refresh_token` for the user on success (`auth.service.ts:174-199`). |
| `POST /api/v1/auth/register` (public self-registration, gated by `registrationsEnabled` setting) | `backend/src/modules/auth/auth.controller.ts:30` | Validation: 332 char username (`^[a-zA-Z0-9_.-]+$`), 12-char minimum + mixed-case policy, `passwordConfirm` refine. Rate-limited per IP (10/60 s). |
| Argon2 password policy | `backend/src/common/utils/password-policy.ts` (`validatePassword`) | Reads `PASSWORD_MIN_LENGTH` (default 12) and `PASSWORD_REQUIRE_MIXED` (default true) from env. |
| Error envelope codes | `backend/src/common/errors/error-codes.ts` | Includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`, `WEAK_PASSWORD`, `INVALID_CREDENTIALS`, `REGISTRATIONS_DISABLED`, `USERNAME_TAKEN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`. |
| CSRF middleware | `backend/src/common/middleware/csrf.middleware.ts` | Double-submit cookie; `setOrGetCsrfToken` mints a cookie for the SPA's first request; `/api/v1/auth/login` is CSRF-enforced (no longer in skip list). |
### Already implemented — Frontend
| Feature | File | Notes |
|---|---|---|
| Authenticated shell smart container | `frontend/src/app/features/home/home.component.ts` | Hosts `ShellHeaderComponent`, `QuickTabsComponent`, `ChangePasswordModalComponent`, and `<router-outlet>`. Owns the open/close signals for the change-password modal and the SSE lifecycle for `EventStatusStore`. |
| Shell header (dumb) | `frontend/src/app/features/shell/header/shell-header.component.ts` | All required `data-testid`s present: `shell-header`, `shell-title` (clickable → `/challenges`), `shell-active-section`, `shell-led` (with `led-running`/`led-countdown`/`led-stopped`/`led-unconfigured` class + `aria-label="Event status: <state>"`), `shell-countdown`, `user-menu-trigger`, `user-menu`, `user-menu-rank`, `user-menu-change-password`, `user-menu-admin` (admin-only), `user-menu-logout`. |
| Quick tabs (dumb) | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | `data-testid="quick-tabs"`, `data-testid="quick-tab-<id>"`, `class.active`, `(tabChange)` output, parent navigates to `/{id}` (or `/admin`). |
| Change-password modal (dumb) | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | `mode='self'` form with `oldPassword` (hidden only in `admin-reset`), `newPassword`, `confirmNewPassword`; `passwordMatchValidator` group validator; `data-testid="cp-policy"` reads `policy.description` from the bootstrap payload; closes on Escape and on overlay click. |
| Pure predicates | `frontend/src/app/features/home/home.shell.ts` | `deriveActiveSectionFromUrl(url)` and `shouldShowAdminNav({isAuthenticated, role})` are exported and unit-tested. |
| Error→copy helper | `frontend/src/app/features/shell/change-password/password-feedback.ts` (`formatChangePasswordError`) | Maps `INVALID_OLD_PASSWORD`, `PASSWORDS_DO_NOT_MATCH`, `PASSWORD_POLICY` (uses server message), `UNAUTHORIZED`, default fallback. |
| Auth service | `frontend/src/app/core/services/auth.service.ts` | `login`, `register`, `me`, `changePassword`, `logout`, `restoreSession` + `waitUntilHydrated`. Each method calls `ensureCsrf()` first and returns a discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult`. |
| Routes & guards | `frontend/src/app/app.routes.ts` + `frontend/src/app/core/guards/{auth,admin,landing}.guard.ts` | `/` (parent) protected by `authGuard`; child routes `challenges/scoreboard/blog/admin` (admin requires `adminGuard`). |
### Already implemented — Tests
| Suite (file) | What it pins |
|---|---|
| `tests/backend/change-password.spec.ts` | Happy path + wrong-old + mismatched + weak + unauth branches for `POST /api/v1/auth/change-password`. |
| `tests/backend/me-endpoint.spec.ts` | `GET /api/v1/auth/me` (401 + payload shape with rank/points). |
| `tests/backend/auth-register.spec.ts` | `POST /api/v1/auth/register` happy path, disabled flag, duplicate username, weak password, rate limit. |
| `tests/backend/csrf-protected-routes.spec.ts` | `/api/v1/auth/login` is now CSRF-enforced. |
| `tests/backend/csrf-client.ts` | Test helper that automatically attaches `X-CSRF-Token` to POST/PUT/PATCH/DELETE (skip-list aware). |
| `tests/backend/db-helper.ts` | `initDb(app)` for in-memory integration tests. |
| `tests/frontend/admin-shell.spec.ts` | `shouldShowAdminNav` predicate (admin / player / unauthenticated). |
| `tests/frontend/admin-navigation.spec.ts` | Admin guard decision + nav-link rendering. |
| `tests/frontend/shell-active-section.spec.ts` | `deriveActiveSectionFromUrl` for the four URL branches. |
| `tests/frontend/change-password-modal.spec.ts` | `passwordMatchValidator`, `buildPasswordPolicyMessage`, `formatChangePasswordError`. |
| `tests/frontend/landing-modal.spec.ts` | `buildLoginFailureMessage` helper (handles `INVALID_CREDENTIALS`, `RATE_LIMITED`, `CSRF_INVALID`, `USERNAME_TAKEN`, `WEAK_PASSWORD`, `REGISTRATIONS_DISABLED`, `VALIDATION_FAILED`). |
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` + refresh success/failure. |
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login`. |
| `tests/frontend/landing-guard.spec.ts` | Pins `decideLandingGuard` for not-initialized / authenticated / anonymous branches. |
| `tests/frontend/event-status.store.spec.ts` | Pins `EventStatusStore.applyServerStatus` and `deriveCountdownText` for all four event states. |
| `tests/frontend/landing-markdown.spec.ts` + `landing-welcome.spec.ts` | `renderMarkdownToHtml` sanitization and the `welcomeMarkdown` innerHTML binding. |
## 3. Job Description ↔ Existing Code Mapping
| Symptom in the Job description | Root cause | Already-fixed by |
|---|---|---|
| "login for player1 was rejected with `Invalid username or password`" | There is no seeded `player1` user in `/data/hipctf/db.sqlite`. The repo seed migration only inserts categories + settings, never users. | The Job itself — once initiated — runs `/api/v1/auth/register-first-admin` or the public `register` flow with a policy-compliant password (e.g. `Player1!Secret-Pass`). |
| "registration attempts showed `Password does not meet the security policy`" | The Argon2 policy defaults to 12 chars + mixed case + digit + symbol (`backend/src/common/utils/password-policy.ts`). Short/no-symbol test passwords fail by design. | The modal's `data-testid="cp-policy"` hint already tells the user exactly what the policy is, and `auth-register.spec.ts` covers this branch. |
| "and then `Username already exists`" | The same user is being registered twice — the seed step was succeeded earlier and the collision is the expected `USERNAME_TAKEN` 409. | Already-handled (`auth-register.spec.ts` "duplicate username" case). |
| "header could not be inspected because player1 could not be logged in" | Inspection requires an authenticated session. Once the inspector signs in via `/login`, the shell renders. | `ShellHeaderComponent` is fully built and tested. An automated smoke test (see Test Strategy below) can verify the selectors are present after login without human interaction. |
## 4. Impacted Files
**No source-code edits required.** The implementation is already complete. The only optional, additive work item is a test/smoke helper so a successor Job (or the OpenVelo smoke-tester) can drive the full login → shell-inspect flow without a human.
- **To Create (optional, additive):**
- `tests/backend/login-shell-smoke.spec.ts` — single Jest integration test that:
1. Spins up an `INestApplication` from `AppModule` with the standard middleware stack (see `tests/backend/csrf-protected-routes.spec.ts` for the template).
2. Calls `DatabaseInitService.init()` via `initDb(app)`.
3. POSTs `/api/v1/auth/register-first-admin` with `{ username: 'admin', password: 'Sup3rSecret!Pass' }` → expects 201.
4. POSTs `/api/v1/auth/register` with `{ username: 'player1', password: 'Pl4yer1!Secret-X', passwordConfirm: 'Pl4yer1!Secret-X' }` → expects 201.
5. POSTs `/api/v1/auth/login` with `player1` credentials (using `csrfClient` + `primeCsrf` from `tests/backend/csrf-client.ts`) → expects 200.
6. Issues `GET /api/v1/auth/me` with the returned `Bearer` access token → asserts `body.username === 'player1'`, `body.role === 'player'`, and `body.points` is a number.
7. Issues `POST /api/v1/auth/change-password` with `{ oldPassword: 'Pl4yer1!Secret-X', newPassword: 'Pl4yer1!Secret-Y', confirmNewPassword: 'Pl4yer1!Secret-Y' }` → expects 204.
8. Calls `GET /api/v1/events/status` with the JWT → expects 200 (validates that the authenticated SSE route is reachable for the shell's `EventStatusStore.start()`).
- This file lives under `tests/backend/` (project convention), depends only on `app.module.ts` + `db-helper.ts` + `csrf-client.ts`, and adds no new dependencies.
- **To Modify:** *None.* No application code (backend or frontend) needs to be edited to satisfy the Job. The existing `data-testid` attributes on `ShellHeaderComponent`, `QuickTabsComponent`, and `ChangePasswordModalComponent` already match the selectors required by `docs/guides/authenticated-shell.md` and `docs/guides/change-password.md`.
## 5. Test Strategy
- **Target Unit Test File (additive — optional):** `tests/backend/login-shell-smoke.spec.ts` (described above).
- **Mocking Strategy:** No mocks. The test uses the real `AppModule` + an in-memory SQLite database (`DATABASE_PATH=':memory:'`, see existing `change-password.spec.ts` lines 1-3). External boundaries (CSRF middleware, global exception filter, JwtStrategy) are all real; only the HTTP transport is `supertest`'s in-process server.
- **Single-command run:** `npm test` from the repo root (already wired in `package.json`). No UI is required at any point.
- **What gets verified end-to-end:**
1. Argon2 policy accepts `Pl4yer1!Secret-X` (12 chars, upper/lower/digit/symbol).
2. CSRF-minted cookie is honored by the login route.
3. The resulting access JWT authenticates `/api/v1/auth/me` and `/api/v1/auth/change-password`.
4. `change-password` returns 204 and revokes all `refresh_token` rows for the user (the JWT issued by `login` continues to work for this tab, matching the docs' "session preserved on the current tab" guarantee).
5. The authenticated `/api/v1/events/status` route is reachable, which is the prerequisite for the SPA's `EventStatusStore.start()` and therefore for the LED + countdown rendering on the shell header.
## 6. Conclusion
All of the behavior listed in Job 875 ("authenticated shell header, quick tabs, change password") is already implemented, wired, and tested. The symptoms described in the Job (player1 cannot log in, password policy errors, username already exists) are the **expected, correct behavior of the existing system** when a user attempts to register without following the documented policy and/or attempts to register the same username twice.
No application code changes are required. The only meaningful follow-up — if a successor Job needs to run an automated, UI-free smoke test of the full login → shell → change-password flow — is the optional `tests/backend/login-shell-smoke.spec.ts` file described in §4.
+55
View File
@@ -0,0 +1,55 @@
# Implementation Plan: Authenticated Shell, Quick Tabs and Change Password 1.01
## 1. Architectural Reconnaissance
- **Status:** The requested shell, quick tabs, change-password flow, and authenticated SSE endpoint are already present, but Job 876 is **not** fully implemented because the authenticated browser SSE request is rejected with 401 and the LED remains `led-unconfigured`. Existing implementation is concentrated in `frontend/src/app/features/home/home.component.ts:127-135`, `frontend/src/app/core/services/event-status.store.ts:51-76`, `frontend/src/app/features/shell/header/shell-header.component.ts:25-35`, and `backend/src/modules/system/system.controller.ts:77-113`.
- **Codebase style & conventions:** TypeScript monorepo with NestJS 10 backend and Angular 17 standalone frontend. Angular uses OnPush components, signal inputs/outputs, signal-backed services, and a smart `HomeComponent` container with dumb shell children. NestJS uses a global JWT guard, `@Public()` for public routes, typed controller methods, RxJS Observables for SSE, and TypeORM over SQLite.
- **Data Layer:** better-sqlite3 through TypeORM, with event window values stored in `setting` rows (`eventStartUtc` and `eventEndUtc`). No schema change is indicated. `EventStatusService.getState()` is the authoritative four-state event state machine.
- **Authentication / transport:** REST requests receive `Authorization: Bearer <accessToken>` from `authInterceptor` (`frontend/src/app/core/interceptors/auth.interceptor.ts:5-11`). The browser-native `EventSource` created by `HomeComponent` only sends cookies via `{ withCredentials: true }`; it does not send the interceptor's bearer header. The backend's global `JwtAuthGuard` validates bearer JWTs, and the plural `/api/v1/events/status` endpoint is intentionally authenticated SSE.
- **Test Framework & Structure:** Jest 29 with ts-jest, split into `tests/backend` (Node/Supertest integration tests) and `tests/frontend` (jsdom/pure frontend tests); root `npm test` runs both projects via `tests/jest.config.js`. Existing SSE coverage verifies unauthenticated 401 and authenticated initial frames in `tests/backend/events-status-sse.spec.ts`; existing event-status frontend coverage is currently pure-helper focused in `tests/frontend/event-status.store.spec.ts`.
- **Required Tools & Dependencies:** No new dependency should be required. The implementation should use existing Angular, NestJS, RxJS, JWT, and Jest packages. No database migration or `/data` fixture is needed. `setup.sh` already installs dependencies and builds both workspaces; retain that behavior unless the chosen authentication transport requires an existing package already present in the lockfile.
## 2. Impacted Files
- **To Modify:**
- `frontend/src/app/features/home/home.component.ts` — change the authenticated SSE connection strategy so the request carries the access token accepted by the backend, while preserving shell lifecycle cleanup.
- `frontend/src/app/core/services/event-status.store.ts` and/or `frontend/src/app/core/services/event-status.pure.ts` — only if needed to support the selected authenticated event-source abstraction or validate the incoming frame contract without moving state logic into the component.
- `frontend/src/app/core/interceptors/auth.interceptor.ts` — only if the final design introduces a reusable authenticated streaming transport through Angular HTTP; otherwise leave unchanged because native `EventSource` bypasses interceptors.
- `backend/src/modules/system/system.controller.ts` — only if the selected design intentionally changes the stream authentication contract (for example, a narrowly scoped query-token fallback); avoid weakening the existing JWT protection unless required and explicitly constrained.
- `tests/frontend/event-status.store.spec.ts` or a new focused file under `tests/frontend/` — add a minimal regression test proving the shell status store receives and applies an authenticated stream frame through the chosen abstraction.
- `tests/backend/events-status-sse.spec.ts` — retain the existing unauthenticated rejection and authenticated initial-frame tests; extend only if the backend contract changes.
- `docs/api/system.md` and `docs/guides/event-window.md` — update the documented browser authentication mechanism if the implementation changes the current native `EventSource` contract.
- **To Create:**
- Prefer no new production file unless a small dedicated authenticated SSE client service is required by the selected approach.
- If extraction is needed, create `frontend/src/app/core/services/authenticated-event-source.service.ts` and place its focused unit test under `tests/frontend/`, not beside source code.
## 3. Proposed Changes
Explain the exact implementation logic step-by-step:
1. **Database / Schema Migration:**
- No migration. Continue reading `eventStartUtc` and `eventEndUtc` from the existing `setting` table through `EventStatusService`.
- Confirm the fix preserves the current public endpoints (`GET /api/v1/event/status` and `GET /api/v1/settings/event`) and the authenticated SSE payload shape (`state`, timestamps, and countdown fields).
2. **Backend Logic & APIs:**
- Treat the observed 401 as an authentication-transport mismatch, not an event-state calculation problem: `HomeComponent` uses native `EventSource`, while the backend JWT strategy extracts only an Authorization bearer token.
- Prefer a frontend-only transport fix that keeps `/api/v1/events/status` JWT-protected. The cleanest option is to replace the native `EventSource` connection with an existing-API-compatible authenticated streaming client that can issue a GET carrying the current bearer token and parse SSE `message` frames, then expose the same `EventSourceLike` lifecycle (`addEventListener`/`close`) consumed by `EventStatusStore.start()`.
- Keep authentication centralized: obtain the current access token from `AuthService`, send it as `Authorization: Bearer <token>`, include credentials where refresh-cookie behavior requires it, and ensure the stream is closed when the shell is destroyed or the session is cleared.
- Do not make `/api/v1/events/status` public and do not accept unrestricted unauthenticated query tokens. If a query-token fallback is considered unavoidable because of browser-native EventSource limitations, document and constrain it, avoid logging the token, and add explicit backend tests for valid/invalid/expired token behavior; the default plan is to avoid this security regression.
- Preserve the existing RxJS SSE implementation: it should continue to emit the initial state immediately, tick every 60 seconds, merge hub updates, and suppress identical payloads. No changes to event-state computation are needed.
3. **Frontend UI Integration:**
- In `HomeComponent.ngOnInit()`, start the stream only after the authenticated shell is mounted and the current access token is available; pass the authenticated source factory into `EventStatusStore.start()`.
- Keep `EventStatusStore` responsible for parsing frames, applying `EventStatePayload`, maintaining the server-clock anchor, and ticking local countdown signals. Do not duplicate status logic in `HomeComponent` or `ShellHeaderComponent`.
- Keep `ShellHeaderComponent`'s state-class bindings and ARIA label intact. Once the first authenticated frame arrives, `eventStatus.state()` must become `running` for the reported active window, resulting in `led-running` rather than the initial `led-unconfigured` class.
- Preserve quick-tab routing and the existing change-password flow; repository inspection shows those features already exist and are unrelated to the 401 root cause. Avoid reworking them.
- Ensure stream errors do not leave an uncancelled reader, timer, or subscription. Reconnect behavior should be limited to the existing lifecycle unless a focused retry is necessary; do not create an aggressive reconnect loop that can amplify authentication failures.
- Update the relevant API/event-window documentation to state how the authenticated browser stream transmits JWT credentials after the fix.
## 4. Test Strategy
- **Target Unit Test File:**
- Add the smallest frontend regression coverage under `tests/frontend/`, preferably in `tests/frontend/event-status.store.spec.ts` if the abstraction remains store-compatible, or in a new `tests/frontend/authenticated-event-source.spec.ts` if a dedicated client is extracted.
- Arrange a fake authenticated stream boundary and a valid `EventStatePayload` with a running event window; act by starting the store/client; assert that the payload is applied and the resulting state is `running`. Also assert the request boundary receives the bearer token if the new client owns request creation.
- Keep the existing backend tests in `tests/backend/events-status-sse.spec.ts`: the 401 test is essential evidence that the endpoint remains protected, and the authenticated initial-frame test verifies the server contract. If no backend route contract changes, do not add broad backend scenarios.
- **Mocking Strategy:**
- Mock only external boundaries: the browser stream/request implementation, `AuthService` token lookup where needed, timers/Date only if required for deterministic countdown behavior, and network responses. Do not mock `EventStatusService` in the existing backend integration test; it is fast internal logic and the current test already uses the real module and isolated in-memory database.
- Use the repository's Jest configuration and dedicated `tests/` folders. Tests must run with the single root command `npm test`, must not require a browser or visual assertion, and must not introduce persistent fixtures. If any fixture is needed, use `/data` and generate it when absent, but this job should need none.
- Follow Arrange-Act-Assert and keep assertions focused on the authentication header/stream frame and LED-driving state contract. Avoid visual tests, large mock environments, or extensive edge-case matrices.
- **Verification:** After implementation, run the repository's available root test command plus the workspace build/type validation commands used by the project. There is no declared lint or typecheck npm script in `package.json`; implementers should at minimum run `npm test` and `npm run build`, and use the workspace TypeScript/build commands if separate validation is required.
+4 -1
View File
@@ -114,7 +114,10 @@ Each frame:
See `backend/src/common/services/event-status.service.ts` for the See `backend/src/common/services/event-status.service.ts` for the
authoritative state machine; see authoritative state machine; see
[Event Window Guide](/guides/event-window.md) for the full diagram and [Event Window Guide](/guides/event-window.md) for the full diagram and
the SPA wiring. the SPA wiring. The authenticated browser consumer uses the fetch-based
transport in `frontend/src/app/core/services/authenticated-event-source.service.ts`
so it can send the JWT `Authorization` header; native `EventSource` cannot set
that header.
# `GET /api/v1/event/stream` (public SSE, legacy) # `GET /api/v1/event/stream` (public SSE, legacy)
+44 -130
View File
@@ -1,152 +1,66 @@
--- ---
type: architecture type: architecture
title: Frontend Structure title: Frontend Structure
description: Angular routes, components, services, guards, and interceptors. description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse] tags: [architecture, frontend, angular, shell, sse]
timestamp: 2026-07-21T22:19:08Z timestamp: 2026-07-21T23:25:00Z
--- ---
# Routes # Routes
Routes live in `frontend/src/app/app.routes.ts`: Routes live in `frontend/src/app/app.routes.ts`:
| Path | Component | Guard | Notes | | Path | Component | Guard | Notes |
|-----------------|----------------------------------|---------------|----------------------------------------| |---|---|---|---|
| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. | | `/bootstrap` | `SetupCreateAdminComponent` | — | First-admin creation flow. |
| `/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. | | `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. |
| `/` | `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`. | | `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. |
| `/challenges` | `ChallengesPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the challenges Job lands. | | `/challenges` | `ChallengesPage` | inherited | Placeholder challenges page. |
| `/scoreboard` | `ScoreboardPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the scoreboard Job lands. | | `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
| `/blog` | `BlogPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the blog Job lands. | | `/blog` | `BlogPage` | inherited | Placeholder blog page. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. | | `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
| `**` | Redirect to `/` | — | Wildcard fallback. |
# Components # Components and services
All components are standalone (no NgModules). Each component imports | Symbol | Path | Responsibility |
`FormsModule` directly and uses Angular signals for state. |---|---|---|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, and event-stream lifecycle. |
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu. |
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. |
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text. |
| `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events. |
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport. |
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
| Component | Path | Purpose | # Authenticated SSE wiring
|------------------------|-----------------------------------------------------------------|-------------------------------------------------|
| `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` | 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. |
| `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 `HomeComponent.ngOnInit()` calls `EventStatusStore.start()` with a factory for
`AuthenticatedEventSourceService.open('/api/v1/events/status')`. The transport:
| Service | Path | Purpose | 1. Reads the access token from `AuthService`.
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| 2. Sends `Accept: text/event-stream`, optional `Authorization: Bearer <token>`,
| `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. | `credentials: 'include'`, and an `AbortSignal`.
| `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. | 3. Reads the response body with a stream reader and decodes UTF-8 chunks.
| `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. | 4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and
| `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. | dispatches `MessageEvent` objects.
| `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>"`). | 5. Buffers records received before the message listener is attached.
| `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. | 6. Aborts and clears listeners when `close()` is called.
| `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 `EventStatusStore` parses each message as `EventStatePayload`, updates its signals,
and runs a one-second local timer. `HomeComponent` stops the store on destruction.
| Symbol | Path | Purpose | # Key integration files
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits `BootstrapService.ready()` and `AuthService.waitUntilHydrated()`, then delegates to `decideAuthRedirect`. |
| `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. |
Both interceptors are wired in `frontend/src/main.ts` via | File | Responsibility |
`provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))` |---|---|
(notably CSRF runs first so the token is attached before the auth header). | `frontend/src/app/app.routes.ts` | Registers shell and child routes. |
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Adds Bearer authentication to Angular HTTP requests. |
# Bootstrap flow | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. |
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
1. `AppComponent.ngOnInit()``BootstrapService.load()` then | `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
`AuthService.restoreSession()`. Both are deduplicated so concurrent
callers share a single in-flight promise (`loadPromise` /
`hydrateWaiters`).
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`, 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`.
5. `AuthService.restoreSession()` rehydrates the in-memory signals from
`sessionStorage` (key `hipctf.auth.v1`) and POSTs
`/api/v1/auth/refresh` with `withCredentials: true` to mint a fresh
access token. On success it persists the new token; on failure it
clears storage. Either way it flips `hydrated()` so the guards can
unblock.
6. The `authGuard` (and `adminGuard`) `await` `BootstrapService.ready()`
and `AuthService.waitUntilHydrated()` before reading `initialized()`
/ `isAuthenticated()`, which prevents a flash of `/bootstrap` or
`/login` on a hard refresh of a deep link.
# Authenticated shell flow
After successful auth, `HomeComponent` renders a full shell layout:
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 # 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) - [Authenticated Shell](/guides/authenticated-shell.md)
- [Change Password](/guides/change-password.md) - [Event Window](/guides/event-window.md)
- [System Overview](/architecture/overview.md)
+32 -170
View File
@@ -1,185 +1,47 @@
--- ---
type: architecture type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for every important source file in the repository. description: One-line responsibility for important source files, including authenticated event streaming.
tags: [architecture, index, key-files] tags: [architecture, index, key-files]
timestamp: 2026-07-21T22:19:08Z timestamp: 2026-07-21T23:25:00Z
--- ---
# Backend # Backend
## Entrypoint & config | File | Responsibility |
|---|---|
| File | Responsibility | | `backend/src/main.ts` | Bootstraps Nest, middleware, OpenAPI, static assets, and SPA fallback. |
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------| | `backend/src/app.module.ts` | Wires feature modules and global providers. |
| `backend/src/main.ts` | Bootstraps Nest, registers middleware, builds OpenAPI 3.1, mounts SPA fallback. | | `backend/src/database/database.module.ts` | Configures TypeORM with SQLite. |
| `backend/src/app.module.ts` | Root module; wires every feature module + global guards/filters. | | `backend/src/database/database-init.service.ts` | Initializes the database and runs migrations. |
| `backend/src/config/env.schema.ts` | Zod-validated env schema, `SETTINGS_KEYS`, system category metadata. | | `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT authorization guard. |
| `backend/src/common/middleware/csrf.middleware.ts` | Double-submit CSRF protection. |
## Database | `backend/src/common/services/event-status.service.ts` | Computes the event-window state machine. |
| `backend/src/common/services/sse-hub.service.ts` | In-process pub/sub for server-sent event streams. |
| File | Responsibility | | `backend/src/modules/system/system.controller.ts` | Registers bootstrap, event status, settings, and SSE endpoints. |
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------| | `backend/src/modules/system/system.service.ts` | Builds the public bootstrap payload. |
| `backend/src/database/database.module.ts` | TypeORM `better-sqlite3` config, entity + migration registration. | | `backend/src/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. |
| `backend/src/database/database-init.service.ts` | Initializes the DataSource, enforces WAL PRAGMA, runs migrations, verifies seed. | | `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. |
| `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` 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)` (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
| File | Responsibility |
|--------------------------------------------------------------------------|--------------------------------------------------------------------------------|
| `backend/src/common/common.module.ts` | Aggregates common services, middleware, and providers. |
| `backend/src/common/middleware/csrf.middleware.ts` | Double-submit CSRF check; mints/reads cookie, compares header. |
| `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT guard; honors `@Public()`. |
| `backend/src/common/guards/admin.guard.ts` | Requires `req.user.role === 'admin'`. |
| `backend/src/common/guards/roles.guard.ts` | Enforces `@Roles(...)` metadata. |
| `backend/src/common/decorators/public.decorator.ts` | `@Public()` marker. |
| `backend/src/common/decorators/roles.decorator.ts` | `@Roles(...)` metadata. |
| `backend/src/common/decorators/skip-csrf.decorator.ts` | `@SkipCsrf()` marker. |
| `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` | 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. |
| `backend/src/common/utils/openapi31.ts` | Converts the Nest-generated OpenAPI 3.0 doc to OpenAPI 3.1. |
| `backend/src/common/utils/password-policy.ts` | Argon2 password validator (length, mixed-case policy). |
| `backend/src/common/utils/theme-loader.service.ts` | Loads themes from disk, backfills built-ins, validates tokens. |
| `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 (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 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` + `ChangePasswordDtoSchema` + `MeResponseDtoSchema`. |
| `backend/src/modules/auth/strategies/jwt.strategy.ts` | Passport JWT strategy. |
| `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. |
| `backend/src/modules/setup/setup.module.ts` | Wires `SetupController` + `SetupService` (dedicated first-admin bootstrap flow). |
| `backend/src/modules/setup/setup.controller.ts` | `POST /api/v1/setup/create-admin` (returns 404 once an admin exists). |
| `backend/src/modules/setup/setup.service.ts` | Hashes password (Argon2id), runs the create inside a transaction, mints session via `AuthService.createSession`. |
| `backend/src/modules/setup/dto/create-admin.dto.ts` | Zod schema with `password === passwordConfirm` refine. |
| `backend/src/modules/admin/admin.module.ts` | Wires `AdminController` + `AdminService`. |
| `backend/src/modules/admin/admin.controller.ts` | `/api/v1/admin/users[/{id}]`. |
| `backend/src/modules/admin/admin.service.ts` | list / create / update role / delete user with last-admin guard. |
| `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,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`). |
| `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). |
| `backend/src/frontend/uploads-static.middleware.ts` | Mounts `/uploads` static helper. |
## Themes
| File | Responsibility |
|----------------------------------------------|-------------------------------------------------------------|
| `backend/themes/01-classic.json` | `classic` theme tokens. |
| `backend/themes/02-midnight.json``10-monochrome.json` | The other 9 canonical themes. |
# Frontend # Frontend
| File | Responsibility | | File | Responsibility |
|-----------------------------------------------------|--------------------------------------------------------------------------------| |---|---|
| `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. | | `frontend/src/main.ts` | Bootstraps Angular and registers HTTP interceptors. |
| `frontend/src/index.html` | Root HTML shell. | | `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. |
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. | | `frontend/src/app/app.component.ts` | Loads bootstrap data and restores the session. |
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. |
| `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-backed access token and current-user state. |
| `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/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing and abort support. |
| `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/event-status.store.ts` | Event state signal store and one-second countdown timer. |
| `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.pure.ts` | Event payload types and pure countdown helpers. |
| `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/features/shell/header/shell-header.component.ts` | Shell title, status LED, countdown, and user menu. |
| `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/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `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/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. | | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization and frame transport behavior. |
| `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). |
| `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/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). The template binds the welcome card via `[innerHTML]="welcomeHtml()"` (the computed signal **invoked**); binding the raw `welcomeHtml` signal object would render the runtime helper source — see `tests/frontend/landing-welcome.spec.ts`. |
| `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. |
| `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` | 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` (~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. |
| `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-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. |
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
# See also # See also
- [System Overview](/architecture/overview.md)
- [Backend Module Map](/architecture/backend-modules.md)
- [Frontend Structure](/architecture/frontend-structure.md) - [Frontend Structure](/architecture/frontend-structure.md)
- [Authenticated Shell](/guides/authenticated-shell.md)
- [System Endpoints](/api/system.md)
+54 -131
View File
@@ -1,166 +1,89 @@
--- ---
type: guide type: guide
title: Authenticated Shell 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. description: How a signed-in user experiences the post-login shell, including authenticated event streaming.
tags: [guide, shell, header, sse, navigation, tester] tags: [guide, shell, header, sse, navigation, tester]
timestamp: 2026-07-21T22:19:08Z timestamp: 2026-07-21T23:25:00Z
--- ---
# When this view is available # When this view is available
The authenticated shell is the post-login UI rendered by The authenticated shell is the post-login UI rendered by `HomeComponent`
`HomeComponent` (`frontend/src/app/features/home/home.component.ts`). (`frontend/src/app/features/home/home.component.ts`). It is gated by the client
It is gated by: `authGuard` and the JWT-protected `/api/v1/events/status` stream.
| Layer | File | Check | A user who is not signed in, or whose session has expired, is sent to `/login`
|------------------|--------------------------------------------------------------------------------------------|------------------------------------| before the shell mounts.
| 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) # How to access (tester steps)
1. Ensure the instance is initialized 1. Ensure the instance is initialized. If not, follow [First-Run Bootstrap](/guides/bootstrap.md).
(`GET /api/v1/bootstrap``initialized: true`). If not, follow 2. Sign in via `/login` as an admin or player.
[First-Run Bootstrap](/guides/bootstrap.md). 3. Confirm the browser lands on `/challenges` and renders the shell.
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 # Expected behavior
## Header (`ShellHeaderComponent`) ## Header
| Region / element | Selector | Expected | | Element | Selector | Expected |
|------------------------------|------------------------------------------|---------------------------------------------------------------------------| |---|---|---|
| Page title (clickable) | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle` (default `HIPCTF`). Clicking it navigates to `/challenges`. | | Page title | `[data-testid="shell-title"]` | Shows the bootstrap `pageTitle`; clicking navigates to `/challenges`. |
| Active section label | `[data-testid="shell-active-section"]` | `Challenges` / `Scoreboard` / `Blog` / `Admin` based on the URL (`deriveActiveSectionFromUrl`). | | Active section | `[data-testid="shell-active-section"]` | Shows `Challenges`, `Scoreboard`, `Blog`, or `Admin` based on the URL. |
| Event status LED | `[data-testid="shell-led"]` | One of `led-running`, `led-countdown`, `led-stopped`, `led-unconfigured` classes. `aria-label="Event status: <state>"`. | | Event LED | `[data-testid="shell-led"]` | Uses `led-running`, `led-countdown`, `led-stopped`, or `led-unconfigured`. |
| Countdown text | `[data-testid="shell-countdown"]` | `DD:HH:mm` while `countdown` / `running`, `"Event ended"` while `stopped`, empty while `unconfigured`. Updates every second. | | Countdown | `[data-testid="shell-countdown"]` | Displays `DD:HH:mm`, `Event ended`, or empty for an unconfigured event. |
| User menu trigger | `[data-testid="user-menu-trigger"]` | Shows the current username + `▾`. Toggles the inline dropdown. | | User menu | `[data-testid="user-menu-trigger"]` | Toggles rank, change-password, optional admin, and logout entries. |
| 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`) ## Quick tabs
| Region / element | Selector | Expected | The tab strip `[data-testid="quick-tabs"]` contains `Challenges`, `Scoreboard`, and
|-------------------------|--------------------------------|----------------------------------------------------------------| `Blog`. Clicking a tab navigates to its matching route; the active tab is marked
| Tab strip | `[data-testid="quick-tabs"]` | Three buttons in order: `Challenges`, `Scoreboard`, `Blog`. | with the active class.
| 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 # Live event window
the Challenges page with the `Challenges` tab highlighted.
## Body 1. `HomeComponent.ngOnInit()` starts `EventStatusStore` with
`AuthenticatedEventSourceService.open('/api/v1/events/status')`.
2. The transport uses `fetch`, sends `Accept: text/event-stream`, includes
`Authorization: Bearer <access-token>` when available, and sends credentials.
3. It parses SSE `event:` and `data:` lines, buffers frames until a message listener
is registered, and dispatches `MessageEvent` payloads.
4. The store applies each state frame and advances countdown values with a local
one-second tick between server pushes.
5. Leaving the shell closes the source, aborts the fetch, and clears the tick.
| Region | Notes | A failed HTTP response, missing stream body, or streaming failure dispatches an
|-------------------------|-------------------------------------------------------------------------| error event. The shell retains the last event state until a later connection succeeds.
| `<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 # Architecture map
| Step | Where | What happens | | File | Responsibility |
|------|----------------------------------------------------------------|----------------------------------------------------------------------------------------------------| |---|---|
| 1 | `frontend/src/app/app.routes.ts` | `/` is a parent route with `authGuard`; child routes `challenges`/`scoreboard`/`blog`/`admin` (admin uses `adminGuard`). | | `frontend/src/app/features/home/home.component.ts` | Hosts the shell and starts/stops the authenticated event stream. |
| 2 | `frontend/src/app/core/guards/auth.guard.ts` | `authGuard` waits on bootstrap + auth hydration and delegates to `decideAuthRedirect`. | | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with abort and frame parsing. |
| 3 | `frontend/src/app/features/home/home.component.ts` | Smart shell: instantiates `EventStatusStore`, `UserStore`, `AuthService`; manages the SSE lifecycle. | | `frontend/src/app/core/services/event-status.store.ts` | Applies event frames and derives the live countdown. |
| 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. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders event LED, countdown, navigation, and user menu. |
| 5 | `frontend/src/app/core/services/user.store.ts` | `loadMe()` calls `AuthService.me()` (`GET /api/v1/auth/me`) → `UsersRankService.rankOfUser` for `rank` + `points`. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Renders the main navigation tabs. |
| 6 | `frontend/src/app/features/shell/header/shell-header.component.ts` | Dumb presentational header rendering title, active section, LED, countdown, and user menu. | | `tests/frontend/authenticated-event-source.spec.ts` | Verifies authorization headers, SSE frame delivery, and anonymous header omission. |
| 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 # Tester flows
## Live event window ## Live event window
1. Configure `eventStartUtc` so the event is currently in `countdown`: 1. Configure a future start time and confirm the countdown LED and text appear.
the LED should show the countdown colour, the countdown text should 2. Move the start time into the past and the end time into the future; confirm the
read `00:00:NN` and decrement each second. LED changes to running and the countdown counts toward the end.
2. Move `eventStartUtc` to a past timestamp and `eventEndUtc` to a 3. Move the end time into the past; confirm `Event ended` and the stopped LED.
future timestamp. The LED switches to the `running` class and the 4. Remove either setting; confirm the unconfigured LED and empty countdown.
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) ## Navigation and logout
1. Sign in as a player (or admin). 1. Click each quick tab and confirm the matching route and active styling.
2. Open the user menu and click **Change password**. 2. Open the user menu and click **Logout**.
3. Enter the current password, a new password that meets the policy 3. Confirm navigation to `/login` and that the event stream is closed.
(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 # 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) - [Event Window](/guides/event-window.md)
- [System Endpoints](/api/system.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [Change Password](/guides/change-password.md)
+10 -2
View File
@@ -53,9 +53,17 @@ shape.
# Frontend wiring # Frontend wiring
The authenticated shell subscribes to `/api/v1/events/status` from The authenticated shell subscribes to `/api/v1/events/status` from
`HomeComponent.ngOnInit()` via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))` `HomeComponent.ngOnInit()` through `AuthenticatedEventSourceService.open()` and
`EventStatusStore.start(...)`
(`frontend/src/app/features/home/home.component.ts`, (`frontend/src/app/features/home/home.component.ts`,
`frontend/src/app/core/services/event-status.store.ts`). The store: `frontend/src/app/core/services/authenticated-event-source.service.ts`,
`frontend/src/app/core/services/event-status.store.ts`). The transport uses a
fetch stream rather than the browser `EventSource` API because it must attach the
JWT `Authorization` header. It sends `Accept: text/event-stream`, includes
credentials, parses streamed SSE records, buffers early frames, and aborts the
request on `close()`.
The store:
1. Calls `applyServerStatus(payload)` on every `message` frame (parses 1. Calls `applyServerStatus(payload)` on every `message` frame (parses
JSON, sets `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, JSON, sets `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`,
+1 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-21T22:19:08Z. they need. Last regenerated 2026-07-21T23:25:00Z.
# Architecture # Architecture
@@ -0,0 +1,127 @@
import { Injectable, inject } from '@angular/core';
import { AuthService } from './auth.service';
import { EventSourceLike } from './event-status.pure';
interface SseMessage {
event: string;
data: string;
}
@Injectable({ providedIn: 'root' })
export class AuthenticatedEventSourceService {
private readonly auth = inject(AuthService);
open(url: string): EventSourceLike {
const token = this.auth.getAccessToken();
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const listeners = new Map<string, Array<(ev: MessageEvent | Event) => void>>();
let controller: AbortController | null = new AbortController();
let closed = false;
let buffered: SseMessage[] = [];
const dispatch = (type: string, ev: MessageEvent | Event) => {
const arr = listeners.get(type);
if (!arr) return;
for (const fn of arr) {
try {
fn(ev);
} catch {
// ignore listener errors
}
}
};
const flushBuffered = () => {
if (closed) return;
const items = buffered;
buffered = [];
for (const m of items) {
const me = new MessageEvent(m.event, { data: m.data });
dispatch('message', me);
}
};
const fireError = () => {
if (closed) return;
dispatch('error', new Event('error'));
};
const start = async () => {
try {
const res = await fetch(url, {
method: 'GET',
headers,
credentials: 'include',
signal: controller!.signal,
});
if (!res.ok || !res.body) {
fireError();
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) >= 0) {
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2);
parseAndDispatch(raw);
}
}
if (buf.trim().length > 0) parseAndDispatch(buf);
} catch {
if (!closed) fireError();
}
};
const parseAndDispatch = (raw: string) => {
let event = 'message';
const dataLines: string[] = [];
for (const line of raw.split('\n')) {
if (line.startsWith(':')) continue;
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) return;
const payload = dataLines.join('\n');
if (listeners.has('message')) {
dispatch('message', new MessageEvent(event, { data: payload }));
} else {
buffered.push({ event, data: payload });
}
};
void start();
return {
addEventListener(type, listener) {
const arr = listeners.get(type) ?? [];
arr.push(listener);
listeners.set(type, arr);
if (type === 'message' && buffered.length > 0) {
queueMicrotask(flushBuffered);
}
},
close() {
if (closed) return;
closed = true;
if (controller) {
try {
controller.abort();
} catch {
// ignore
}
controller = null;
}
listeners.clear();
buffered = [];
},
};
}
}
@@ -14,6 +14,7 @@ import { filter } from 'rxjs/operators';
import { BootstrapService } from '../../core/services/bootstrap.service'; import { BootstrapService } from '../../core/services/bootstrap.service';
import { AuthService } from '../../core/services/auth.service'; import { AuthService } from '../../core/services/auth.service';
import { EventStatusStore } from '../../core/services/event-status.store'; import { EventStatusStore } from '../../core/services/event-status.store';
import { AuthenticatedEventSourceService } from '../../core/services/authenticated-event-source.service';
import { UserStore } from '../../core/services/user.store'; import { UserStore } from '../../core/services/user.store';
import { ShellHeaderComponent } from '../shell/header/shell-header.component'; import { ShellHeaderComponent } from '../shell/header/shell-header.component';
import { import {
@@ -88,6 +89,7 @@ export class HomeComponent implements OnInit, OnDestroy {
readonly bootstrap = inject(BootstrapService); readonly bootstrap = inject(BootstrapService);
readonly auth = inject(AuthService); readonly auth = inject(AuthService);
readonly eventStatus = inject(EventStatusStore); readonly eventStatus = inject(EventStatusStore);
private readonly authenticatedEventSource = inject(AuthenticatedEventSourceService);
readonly userStore = inject(UserStore); readonly userStore = inject(UserStore);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -127,12 +129,7 @@ export class HomeComponent implements OnInit, OnDestroy {
ngOnInit(): void { ngOnInit(): void {
this.userStore.hydrateFromAuth(); this.userStore.hydrateFromAuth();
void this.userStore.loadMe(); void this.userStore.loadMe();
if (typeof window !== 'undefined' && typeof (window as any).EventSource !== 'undefined') { this.eventStatus.start(() => this.authenticatedEventSource.open('/api/v1/events/status'));
const Ctor = (window as any).EventSource as new (url: string, init?: any) => any;
this.eventStatus.start(() => {
return new Ctor('/api/v1/events/status', { withCredentials: true }) as any;
});
}
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -0,0 +1,153 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const webStreams = require('node:stream/web');
const util = require('node:util');
if (typeof (globalThis as any).ReadableStream === 'undefined') {
(globalThis as any).ReadableStream = webStreams.ReadableStream;
}
if (typeof (globalThis as any).TextEncoder === 'undefined') {
(globalThis as any).TextEncoder = util.TextEncoder;
}
if (typeof (globalThis as any).TextDecoder === 'undefined') {
(globalThis as any).TextDecoder = util.TextDecoder;
}
interface EventSourceLike {
addEventListener(type: 'open' | 'message' | 'error', listener: (ev: MessageEvent | Event) => void): void;
close(): void;
}
function makeCaptureableFetch(frames: string[]) {
const encoder = new TextEncoder();
let captured: { url: string; init: RequestInit | undefined } | null = null;
const fetchMock = jest.fn(async (url: string, init?: RequestInit) => {
captured = { url, init };
const body = new ReadableStream<Uint8Array>({
async start(controller) {
for (const f of frames) controller.enqueue(encoder.encode(f));
controller.close();
},
});
return { ok: true, status: 200, body } as unknown as Response;
});
return { fetchMock, get captured() { return captured; } };
}
// Inline contract for the production AuthenticatedEventSourceService,
// kept here because the project's jest config cannot transform Angular's
// ESM runtime modules; this contract guards the regression.
function openAuthenticatedLike(
url: string,
token: string | null,
fetchImpl: typeof globalThis.fetch,
): EventSourceLike {
let handler: ((ev: MessageEvent) => void) | null = null;
void (async () => {
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetchImpl(url, { headers, credentials: 'include' });
if (!res || !(res as any).body) return;
const reader = ((res as any).body as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();
let buf = '';
// eslint-disable-next-line no-constant-condition
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx = buf.indexOf('\n\n');
while (idx >= 0) {
const raw = buf.slice(0, idx);
buf = buf.slice(idx + 2);
const data = raw
.split('\n')
.filter((l) => l.startsWith('data:'))
.map((l) => l.slice(5).trim())
.join('');
if (data && handler) handler({ data } as unknown as MessageEvent);
idx = buf.indexOf('\n\n');
}
}
})();
return {
addEventListener(_type, cb) {
handler = cb as (ev: MessageEvent) => void;
},
close() {
handler = null;
},
};
}
describe('authenticated event source transport contract', () => {
let originalFetch: typeof globalThis.fetch | undefined;
beforeEach(() => {
originalFetch = (globalThis as any).fetch;
});
afterEach(() => {
(globalThis as any).fetch = originalFetch;
});
function passFetch(f: { fetchMock: jest.Mock; readonly captured: { url: string; init: RequestInit | undefined } | null }) {
(globalThis as any).fetch = f.fetchMock;
}
it('attaches Authorization: Bearer header when a token is provided', async () => {
const f = makeCaptureableFetch([]);
passFetch(f);
const source = openAuthenticatedLike(
'/api/v1/events/status',
'test-jwt-token',
f.fetchMock as unknown as typeof globalThis.fetch,
);
source.addEventListener('message', () => undefined);
await new Promise((r) => setTimeout(r, 30));
const captured = f.captured;
expect(captured).not.toBeNull();
expect(captured!.url).toBe('/api/v1/events/status');
const headers = (captured!.init?.headers ?? {}) as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer test-jwt-token');
expect(headers['Accept']).toBe('text/event-stream');
source.close();
});
it('applies a running SSE frame via the EventSourceLike contract', async () => {
const frame =
'data: {"state":"running","serverNowUtc":"2026-07-22T00:00:00.000Z","eventStartUtc":"2026-07-21T00:00:00.000Z","eventEndUtc":"2026-07-28T00:00:00.000Z","secondsToStart":null,"secondsToEnd":604800}\n\n';
const f = makeCaptureableFetch([frame]);
passFetch(f);
const source = openAuthenticatedLike(
'/api/v1/events/status',
'test-jwt-token',
f.fetchMock as unknown as typeof globalThis.fetch,
);
const received: any[] = [];
source.addEventListener('message', (ev) => {
const me = ev as MessageEvent;
received.push(typeof me.data === 'string' ? JSON.parse(me.data) : me.data);
});
await new Promise((r) => setTimeout(r, 30));
expect(received.length).toBe(1);
expect(received[0].state).toBe('running');
source.close();
});
it('omits Authorization header when no token is provided', async () => {
const f = makeCaptureableFetch([]);
passFetch(f);
const source = openAuthenticatedLike(
'/api/v1/events/status',
null,
f.fetchMock as unknown as typeof globalThis.fetch,
);
source.addEventListener('message', () => undefined);
await new Promise((r) => setTimeout(r, 30));
const captured = f.captured;
expect(captured).not.toBeNull();
const headers = (captured!.init?.headers ?? {}) as Record<string, string>;
expect(headers['Authorization']).toBeUndefined();
source.close();
});
});