docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-21 23:22:03 +00:00
parent 76c04f9ea0
commit 18af97cea5
6 changed files with 145 additions and 435 deletions
+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