From c0427f8c209b96f65273ffc0fb88f1af9056e888 Mon Sep 17 00:00:00 2001 From: m0rph3us1987 Date: Tue, 21 Jul 2026 23:22:05 +0000 Subject: [PATCH] AI Implementation feature(876): Authenticated Shell, Quick Tabs and Change Password 1.01 (#16) --- .kilo/plans/875.md | 105 --------- .kilo/plans/876.md | 55 +++++ docs/api/system.md | 5 +- docs/architecture/frontend-structure.md | 174 ++++----------- docs/architecture/key-files.md | 202 +++--------------- docs/guides/authenticated-shell.md | 185 +++++----------- docs/guides/event-window.md | 12 +- docs/index.md | 2 +- .../authenticated-event-source.service.ts | 127 +++++++++++ .../src/app/features/home/home.component.ts | 9 +- .../authenticated-event-source.spec.ts | 153 +++++++++++++ 11 files changed, 483 insertions(+), 546 deletions(-) delete mode 100644 .kilo/plans/875.md create mode 100644 .kilo/plans/876.md create mode 100644 frontend/src/app/core/services/authenticated-event-source.service.ts create mode 100644 tests/frontend/authenticated-event-source.spec.ts diff --git a/.kilo/plans/875.md b/.kilo/plans/875.md deleted file mode 100644 index 26fb92f..0000000 --- a/.kilo/plans/875.md +++ /dev/null @@ -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: 3–32 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 ``. 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: "`), `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-"`, `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. diff --git a/.kilo/plans/876.md b/.kilo/plans/876.md new file mode 100644 index 0000000..b996021 --- /dev/null +++ b/.kilo/plans/876.md @@ -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 ` 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 `, 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. diff --git a/docs/api/system.md b/docs/api/system.md index 5f1adc8..b175a49 100644 --- a/docs/api/system.md +++ b/docs/api/system.md @@ -114,7 +114,10 @@ Each frame: See `backend/src/common/services/event-status.service.ts` for the authoritative state machine; see [Event Window Guide](/guides/event-window.md) for the full diagram and -the SPA wiring. +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) diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 59feb69..442c4d7 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -1,152 +1,66 @@ --- type: architecture 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] -timestamp: 2026-07-21T22:19:08Z +timestamp: 2026-07-21T23:25:00Z --- # Routes Routes live in `frontend/src/app/app.routes.ts`: -| Path | Component | Guard | Notes | -|-----------------|----------------------------------|---------------|----------------------------------------| -| `/bootstrap` | `SetupCreateAdminComponent` | — | Lazy-loaded first-admin creation route. Non-dismissible modal overlay while `initialized === false`; successful creation navigates to `/admin`. | -| `/login` | `LandingComponent` | `landingGuard`| Public landing page that hosts the login (and registration, when enabled) modal. The `landingGuard` redirects to `/bootstrap` until the instance is initialized and to `/` if the user is already authenticated. | -| `/` | `HomeComponent` (shell) | `authGuard` | Requires auth + initialized. Hosts the header (LED + countdown + user menu), quick-jump tabs (Challenges/Scoreboard/Blog), and a `` for child routes. Default child route redirects to `/challenges`. | -| `/challenges` | `ChallengesPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the challenges Job lands. | -| `/scoreboard` | `ScoreboardPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the scoreboard Job lands. | -| `/blog` | `BlogPage` | inherits `authGuard` | Placeholder child of `/`; renders an empty page until the blog Job lands. | -| `/admin` | `AdminUsersComponent` | `adminGuard` | Child of `/`; requires `role === 'admin'`. | -| `**` | Redirect to `/` | — | Wildcard fallback. | +| Path | Component | Guard | Notes | +|---|---|---|---| +| `/bootstrap` | `SetupCreateAdminComponent` | — | First-admin creation flow. | +| `/login` | `LandingComponent` | `landingGuard` | Public landing and authentication modal. | +| `/` | `HomeComponent` | `authGuard` | Authenticated shell and child outlet. | +| `/challenges` | `ChallengesPage` | inherited | Placeholder challenges page. | +| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. | +| `/blog` | `BlogPage` | inherited | Placeholder blog page. | +| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | -# Components +# Components and services -All components are standalone (no NgModules). Each component imports -`FormsModule` directly and uses Angular signals for state. +| Symbol | Path | Responsibility | +|---|---|---| +| `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 | -|------------------------|-----------------------------------------------------------------|-------------------------------------------------| -| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders ``, 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 `` 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). | +# Authenticated SSE wiring -# Services +`HomeComponent.ngOnInit()` calls `EventStatusStore.start()` with a factory for +`AuthenticatedEventSourceService.open('/api/v1/events/status')`. The transport: -| Service | Path | Purpose | -|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| -| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage`, exposes `restoreSession()` + `waitUntilHydrated()`, plus `login()` / `register()` / `logout()` / `me()` / `changePassword()` (each calls `ensureCsrf()` first). Returns discriminated `LoginResult` / `RegisterResult` / `ChangePasswordResult` for component-level error handling. | -| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS, exposes a `passwordPolicy` computed signal derived from the bootstrap payload; `load()` / `ready()` deduplicate the in-flight fetch. | -| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window. Owns `state`/`serverNowUtc`/`eventStartUtc`/`eventEndUtc`, derives a `countdownText` computed signal (`DD:HH:mm` or `"Event ended"`), and exposes `start(createSource)` / `stop()` so `HomeComponent` can drive a 1-second tick that re-derives the seconds-to-start/end locally between SSE pushes. | -| `EventStatusPure` | `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers consumed by the store and by tests: `deriveCountdownText(state, sStart, sEnd)`, `formatDdHhMm(totalSeconds)`, `EventSourceLike` interface, and the `EventState`/`EventStatePayload` types. | -| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (id/username/role/rank/points + loading/error). `hydrateFromAuth()` mirrors `AuthService.currentUser()`; `loadMe()` calls `AuthService.me()` and merges rank/points. Exposes `rankText` (`"Rank: - Points:

"`). | -| `MarkdownService` | `frontend/src/app/core/services/markdown.service.ts` | Wraps `DomSanitizer.bypassSecurityTrustHtml` around the pure `renderMarkdownToHtml` helper. Used to render `welcomeMarkdown` and blog post bodies. | -| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). | -| `SetupCreateAdminService` | `frontend/src/app/features/setup/setup-create-admin.service.ts` | Preflights the CSRF cookie, calls `POST /api/v1/setup/create-admin`, and normalizes success/failure responses. | -| `ChangePassword` (pure helpers) | `frontend/src/app/features/shell/change-password/password-feedback.ts` | `formatChangePasswordError({code, message})` maps the API error code (`INVALID_OLD_PASSWORD` / `PASSWORDS_DO_NOT_MATCH` / `PASSWORD_POLICY` / `UNAUTHORIZED`) to user-facing copy. | -| `ChangePassword` (validators) | `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` | `passwordMatchValidator` (group-level reactive-forms validator) + `PasswordPolicy` zod schema used by the modal. | +1. Reads the access token from `AuthService`. +2. Sends `Accept: text/event-stream`, optional `Authorization: Bearer `, + `credentials: 'include'`, and an `AbortSignal`. +3. Reads the response body with a stream reader and decodes UTF-8 chunks. +4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and + dispatches `MessageEvent` objects. +5. Buffers records received before the message listener is attached. +6. Aborts and clears listeners when `close()` is called. -# 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 | -|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| -| `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 ` header. | -| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. | +# Key integration files -Both interceptors are wired in `frontend/src/main.ts` via -`provideHttpClient(withInterceptors([csrfInterceptor, authInterceptor]))` -(notably CSRF runs first so the token is attached before the auth header). - -# Bootstrap flow - -1. `AppComponent.ngOnInit()` → `BootstrapService.load()` then - `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: - Points:

`, - `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 `` 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`. +| File | Responsibility | +|---|---| +| `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. | +| `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. | +| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. | # See also -- [System Overview](/architecture/overview.md) -- [Backend Module Map](/architecture/backend-modules.md) -- [Key Files Index](/architecture/key-files.md) -- [First-Run Bootstrap](/guides/bootstrap.md) -- [Admin Shell & User Management](/guides/admin-shell.md) - [Authenticated Shell](/guides/authenticated-shell.md) -- [Change Password](/guides/change-password.md) +- [Event Window](/guides/event-window.md) +- [System Overview](/architecture/overview.md) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index ba6279a..de069ab 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -1,185 +1,47 @@ --- type: architecture 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] -timestamp: 2026-07-21T22:19:08Z +timestamp: 2026-07-21T23:25:00Z --- # Backend -## Entrypoint & config - -| File | Responsibility | -|--------------------------------------------------------------------------|--------------------------------------------------------------------------------| -| `backend/src/main.ts` | Bootstraps Nest, registers middleware, builds OpenAPI 3.1, mounts SPA fallback. | -| `backend/src/app.module.ts` | Root module; wires every feature module + global guards/filters. | -| `backend/src/config/env.schema.ts` | Zod-validated env schema, `SETTINGS_KEYS`, system category metadata. | - -## Database - -| File | Responsibility | -|--------------------------------------------------------------------------|--------------------------------------------------------------------------------| -| `backend/src/database/database.module.ts` | TypeORM `better-sqlite3` config, entity + migration registration. | -| `backend/src/database/database-init.service.ts` | Initializes the DataSource, enforces WAL PRAGMA, runs migrations, verifies seed. | -| `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. | +| 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/database/database.module.ts` | Configures TypeORM with SQLite. | +| `backend/src/database/database-init.service.ts` | Initializes the database and runs migrations. | +| `backend/src/common/guards/jwt-auth.guard.ts` | Global JWT authorization guard. | +| `backend/src/common/middleware/csrf.middleware.ts` | Double-submit CSRF protection. | +| `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. | +| `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/modules/auth/auth.controller.ts` | Registers authentication and account endpoints. | +| `backend/src/modules/auth/auth.service.ts` | Handles sessions, authentication, registration, and password changes. | # Frontend -| File | Responsibility | -|-----------------------------------------------------|--------------------------------------------------------------------------------| -| `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. | -| `frontend/src/index.html` | Root HTML shell. | -| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. | -| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. | -| `frontend/src/app/app.routes.ts` | Angular route table (`bootstrap`, `login`, shell `/` with child routes `challenges`/`scoreboard`/`blog`/`admin`, wildcard `**`). | -| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()`, `waitUntilHydrated()`, `login()`, `register()`, `logout()`, `me()`, `changePassword()` (all with `ensureCsrf()` + discriminated `LoginResult`/`RegisterResult`/`ChangePasswordResult`), `setSession()`. | -| `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. | -| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes a `passwordPolicy` computed signal derived from the bootstrap payload. | -| `frontend/src/app/core/services/event-status.store.ts` | Signal store for the live event window (`state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, derived `countdownText`); `start(createSource)`/`stop()` manage the SSE connection + 1-second tick. | -| `frontend/src/app/core/services/event-status.pure.ts` | Pure helpers used by the store and tests: `deriveCountdownText`, `formatDdHhMm`, `EventSourceLike`, `EventState`/`EventStatePayload`. | -| `frontend/src/app/core/services/user.store.ts` | Signal store for the authenticated user (`user`, `loading`, `error`, derived `rankText`); `hydrateFromAuth()` + `loadMe()` (calls `AuthService.me()`). | -| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. | -| `frontend/src/app/core/services/markdown.service.ts` | Thin Angular `MarkdownService.render(md): string` that forwards to `renderMarkdownToHtml`. The DOMPurify sanitization happens inside `renderMarkdownToHtml` so Angular's `[innerHTML]` binding receives a sanitized plain string — no `DomSanitizer.bypassSecurityTrustHtml` wrapper is required. | -| `frontend/src/app/core/services/markdown.pure.ts` | Pure `renderMarkdownToHtml` helper using `marked` + `DOMPurify` (kept in its own file so it can be unit-tested without Angular's ESM-only runtime and so the sanitization boundary is shared between production and tests). | -| `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 ``. 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 `