# Implementation Plan: Job 857 — Authenticated Shell (Header, Quick Tabs, Change Password, SSE-driven LED/Countdown) ## Status: IN PROGRESS — Build error to fix Most of the implementation has been applied (backend service extensions, new endpoints, frontend stores, dumb components, smart `HomeComponent`, and tests). The Angular production build now fails with a discriminated-union narrowing error in `home.component.ts:185`. See **Section 6** for the exact, surgical fix required. --- ## 0. Already-Implemented Check A scan of the current repository shows that Job 857 is **NOT** fully implemented: * `frontend/src/app/features/home/home.component.ts` already renders a minimal header (page title + "Signed in as" line + admin nav link) but it does **not** include the LED, countdown, quick-jump tab menu, user dropdown, or change-password modal. * `frontend/src/app/app.routes.ts` has only `/`, `/login`, `/bootstrap`, `/admin`, plus the `/` shell child route for admin. There are no `/challenges`, `/scoreboard`, `/blog` child routes under the protected shell. * `backend/src/modules/auth/auth.controller.ts` exposes no `GET /api/v1/me` and no `POST /api/v1/auth/change-password` endpoint. * `backend/src/modules/system/system.controller.ts` exposes `GET /api/v1/event/stream` (Public), but no authenticated `GET /api/v1/events/status` (plural) protected-by-auth SSE. * `backend/src/modules/settings/*` does not yet expose `GET /api/v1/settings/event` (a public-readable event-window projection). * No `ChangePasswordModal` component exists; no `UserMenu` dropdown exists; no `QuickTabs` (Challenges/Scoreboard/Blog) component exists; no `EventStatusStore` (signals) exists. * `AuthService` has no `changePassword()` method. No part of the new Job is fully done, so the plan proceeds with full implementation. --- ## 1. Architectural Reconnaissance ### Codebase style & conventions * **Backend**: NestJS 10 + TypeORM + better-sqlite3 (WAL). Modules use `@Module({...})`, providers are dependency-injected, services live in `backend/src/modules//.service.ts`. Validation uses `ZodValidationPipe` (zod schemas in `dto/*.dto.ts`). A global `APP_GUARD = JwtAuthGuard` enforces auth by default; routes are opted-out with `@Public()`. CSRF is enforced on unsafe methods via `CsrfMiddleware` mounted in `main.ts`; `@SkipCsrf()` opts out. Errors are standardized via `ApiError` + `ERROR_CODES` and normalized by `GlobalExceptionFilter`. * **Frontend**: Angular 17.3 standalone components, `provideHttpClient(withInterceptors([...]))`, Angular Signals, `OnPush` change detection, `@if`/`@for` control flow. Smart/dumb component split. State lives in services using `signal()`/`computed()` (e.g. `AuthService`, `BootstrapService`). Lazy loading via `loadComponent`. Functional `CanActivateFn` route guards. Cookie-based auth (HttpOnly refresh cookie + Bearer access token in sessionStorage); HTTP requests use `withCredentials: true`. * **Tests**: Jest with two projects (`backend`, `frontend`) in `/repo/tests/jest.config.js`. Tests live in `/repo/tests/backend/**/*.spec.ts` and `/repo/tests/frontend/**/*.spec.ts`. Run via `npm test` from repo root. Backend tests typically bootstrap `AppModule` with `Test.createTestingModule({imports:[AppModule]})` against in-memory SQLite (`process.env.DATABASE_PATH=':memory:'`). Frontend tests use `jsdom` with `jest.setup.ts`. ### Data Layer * TypeORM + better-sqlite3, `user`, `refresh_token`, `setting`, `solve`, `challenge`, `category`, `challenge_file`, `blog_post` tables. * `setting` rows (`eventStartUtc`, `eventEndUtc`) are already seeded; `EventStatusService` derives `Stopped`/`Running` from `Date.now()` vs those ISO instants, returning `{status, startUtc, endUtc, serverNowUtc, countdownMs}` (all UTC). * No schema migrations are required for Job 857. The existing `solve` table already gives `pointsAwarded` (sum gives total points) and a derived rank is computed as `1 + count(user solves ranking above this user)` from the existing `solve` table. ### Test Framework & Structure * **Jest** (workspaces: backend + frontend). Single command: `npm test` (wired through root `package.json`). * Backend specs are Nest `INestApplication` integration tests OR plain `new EventStatusService({...})` unit tests; one helper exists (`tests/backend/db-helper.ts`) for test seeding. * Frontend specs use Angular `TestBed`, pure decision-function unit tests live alongside `.decision.ts` files, signal-store specs are pure logic assertions. ### Required Tools & Dependencies * **No new packages required.** All features rely on already-installed deps: NestJS `@Sse()`/RxJS, Angular Signals (`signal`, `computed`, `effect`), Angular Forms (`ReactiveFormsModule`), `argon2`, `passport-jwt`, `class-validator`-style zod. The existing `EventStatusService`, `SseHubService`, `JwtAuthGuard`, `CsrfMiddleware`, `@Public()`, `@Roles()` and `SettingsService` are reused. `setup.sh` does not need changes. --- ## 2. Impacted Files ### Backend — To Modify * `backend/src/modules/auth/auth.controller.ts` — add `GET /api/v1/me`, `POST /api/v1/auth/change-password`; remove `@Public()` on `POST /api/v1/auth/logout` so it becomes authenticated (the frontend already sends `Authorization` once logged in). * `backend/src/modules/auth/auth.service.ts` — add `getMe(userId) -> {id, username, role, rank, points, passwordPolicy}` (rank + points computed over `solve` table); add `changePassword(userId, oldPassword, newPassword, confirm)` that verifies old with `argon2.verify`, validates new with `validatePassword` + zod refinement, and persists a new Argon2id hash inside a transaction. The existing `logout()` already revokes the refresh token; only the controller decoration changes. * `backend/src/modules/auth/dto/auth.dto.ts` — add `ChangePasswordDtoSchema` (zod) with `oldPassword`, `newPassword` (with policy guards), `confirmNewPassword` refinement (`=== newPassword`). * `backend/src/modules/system/system.controller.ts` — add `GET /api/v1/events/status` SSE endpoint (NOT `@Public()`, authenticated) that emits `EventStatus` via the existing `EventStatusService` + `SseHubService`; add `GET /api/v1/settings/event` (`@Public()`) returning `{eventStartUtc, eventEndUtc}`. * `backend/src/common/services/event-status.service.ts` — extend interface to add an `unconfigured` branch (when `eventStartUtc`/`eventEndUtc` settings are missing/invalid) and a 4-state `state` field with `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, `secondsToStart`, `secondsToEnd` to match the Job's required payload shape. Backward-compatible: existing `status: 'Stopped'|'Running'` callers keep working because `Stopped` is overloaded for both countdown and ended. * `backend/src/common/errors/error-codes.ts` — add `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`. * `backend/src/app.module.ts` — none required; the new pieces sit inside `AuthModule`/`SystemModule`. ### Backend — To Create * `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. Registered in `UsersModule`. ### Frontend — To Modify * `frontend/src/app/app.routes.ts` — keep `/`, `/bootstrap`, `/login`. Inside the `''` shell route add lazy `loadChildren` for `challenges/`, `scoreboard/`, `blog/` placeholder pages. * `frontend/src/app/core/services/auth.service.ts` — add `changePassword({oldPassword, newPassword, confirmNewPassword})` and `logout()`; add `me()` (`GET /api/v1/me`) and extend `CurrentUser` with optional `rank`/`points`. * `frontend/src/app/features/home/home.component.ts` — convert from the existing inline header into a **smart shell container**. ### Frontend — To Create * `frontend/src/app/core/services/event-status.store.ts` — signals store. * `frontend/src/app/core/services/user.store.ts` — signals store for the authenticated user. * `frontend/src/app/features/shell/header/shell-header.component.ts` — **dumb** presentational component. * `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` — **dumb** presentational component. * `frontend/src/app/features/shell/user-menu/user-menu.component.ts` — **dumb** dropdown component. * `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` — **dumb** modal. * `frontend/src/app/features/shell/change-password/change-password-modal.validators.ts` — pure `passwordMatchValidator`. * `frontend/src/app/features/shell/change-password/password-feedback.ts` — pure `formatChangePasswordError()` helper. * `frontend/src/app/features/challenges/challenges.page.ts` — stub placeholder page. * `frontend/src/app/features/scoreboard/scoreboard.page.ts` — stub placeholder page. * `frontend/src/app/features/blog/blog.page.ts` — stub placeholder page. ### Test Files — To Create * `tests/backend/event-status-state.spec.ts` — 4-state derivation coverage. * `tests/backend/change-password.spec.ts` — integration: wrong-old / mismatched / weak / unauth. * `tests/backend/events-status-sse.spec.ts` — SSE initial payload + 401. * `tests/backend/me-endpoint.spec.ts` — `/api/v1/auth/me` shape + 401. * `tests/backend/rank-points.spec.ts` — pure unit test for `rankOfUser()`. * `tests/frontend/event-status.store.spec.ts` — pure store unit test (DD:HH:mm formatting + SSE message apply). * `tests/frontend/quick-tabs.spec.ts` — pure render test for the dumb quick-tabs component. * `tests/frontend/change-password-modal.spec.ts` — pure render test for the dumb modal. * `tests/frontend/shell-active-section.spec.ts` — pure helper unit test for `deriveActiveSectionFromUrl`. --- ## 3. Proposed Changes ### 3.1 Database / Schema Migration No DB changes. Reuses existing `user`, `setting`, `solve`, `refresh_token` rows. ### 3.2 Backend Logic & APIs #### a. Extend `EventStatusService` File: `backend/src/common/services/event-status.service.ts` * New `getState(now)` returning `EventStatePayload = {state: 'countdown'|'running'|'stopped'|'unconfigured', serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}`. * `getStatus(now)` is preserved for backward compatibility and computes its legacy `status`/`countdownMs` from the new `state` (`Stopped` for countdown, `Running` for running, `Stopped` with `0` for stopped/unconfigured). #### b. Add `GET /api/v1/me` File: `backend/src/modules/auth/auth.controller.ts` * Authenticated (no `@Public()`). Returns `{id, username, role, rank, points}`. * `rank`/`points` computed via `UsersRankService`. #### c. Add `POST /api/v1/auth/change-password` * Authenticated, CSRF enforced. * Body via `ZodValidationPipe(ChangePasswordDtoSchema)`. * Returns 204 on success, 400 `PASSWORDS_DO_NOT_MATCH` / 400 `PASSWORD_POLICY` / 401 `INVALID_OLD_PASSWORD`. #### d. Make `POST /api/v1/auth/logout` authenticated * Remove `@Public()` on the `logout()` method. #### e. `AuthService.changePassword()` method File: `backend/src/modules/auth/auth.service.ts` * `changePassword(userId, dto)`: 1. Validate `newPassword === confirmNewPassword`. 2. Reject when `oldPassword === newPassword` with `PASSWORD_POLICY`. 3. Verify old via `argon2.verify`. 4. Validate new via `validatePassword()` (rethrow with `PASSWORD_POLICY` code). 5. Re-hash with Argon2id and persist. 6. Revoke all existing refresh tokens for the user. #### f. Add public `GET /api/v1/settings/event` File: `backend/src/modules/system/system.controller.ts` * `@Public()` endpoint returning `{eventStartUtc, eventEndUtc}`. #### g. Add authenticated `GET /api/v1/events/status` SSE * NOT `@Public()`. * Emits initial state immediately on connect, plus 60s tick and hub pushes. * Each `data` frame: `{state, serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}`. ### 3.3 Frontend UI Integration #### a. Routes File: `frontend/src/app/app.routes.ts` * Keep `bootstrap`, `login`, `**` fallback. * Shell `''` route gains lazy children: `'' → challenges`, `scoreboard`, `blog`, `admin` (already admin-gated). * Default child redirects to `/challenges`. #### b. `AuthService` additions File: `frontend/src/app/core/services/auth.service.ts` * `logout()`, `me()`, `changePassword(dto)` with discriminated `ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure`. * Extend `CurrentUser` with optional `rank?: number | null` and `points?: number`. #### c. `EventStatusStore` File: `frontend/src/app/core/services/event-status.store.ts` * Signals `state`, `serverNowUtc`, `eventStartUtc`, `eventEndUtc`, private `anchorDeltaMs`. * `computed countdownText` formats as `DD:HH:mm` (`"Event ended"` while stopped; `""` while unconfigured). * `applyServerStatus(payload)`, `start(createSource)`, `stop()` lifecycle. Cleanup via `inject(DestroyRef)`. #### d. `UserStore` File: `frontend/src/app/core/services/user.store.ts` * Signals `user`, `loading`, `error`. `computed rankText` formats `"Rank: - Points:

"`. * `loadMe()` calls `AuthService.me()`. #### e. `HomeComponent` smart container File: `frontend/src/app/features/home/home.component.ts` * Injects `EventStatusStore`, `UserStore`, `AuthService`, `Router`. * Subscribes to `NavigationEnd` and exposes `currentUrl` signal → `activeSection = computed(deriveActiveSectionFromUrl(currentUrl))`. * `ngOnInit()` calls `UserStore.loadMe()` and starts the SSE consumer via `EventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true }))`. * `ngOnDestroy()` calls `EventStatusStore.stop()`. #### f. Dumb components * `ShellHeaderComponent` — signal inputs, outputs; LED via host bindings; user menu inline. * `QuickTabsComponent` — signal inputs `tabs`/`active`, `tabChange` output. * `ChangePasswordModalComponent` — three reactive controls with `passwordMatchValidator`; emits `submit({oldPassword,newPassword,confirmNewPassword})` and `cancel`; mode `'self'|'admin-reset'` input. --- ## 4. Test Strategy ### Target unit test files * **Backend** (`tests/backend/`): * `event-status-state.spec.ts` — covers countdown / running / stopped / unconfigured. * `change-password.spec.ts` — covers wrong-old / mismatched / weak / unauth (HTTP surface). * `events-status-sse.spec.ts` — covers 401 + initial payload shape. * `me-endpoint.spec.ts` — covers 401 + payload shape. * `rank-points.spec.ts` — covers points sum + 1-based rank. * **Frontend** (`tests/frontend/`): * `event-status.store.spec.ts` — DD:HH:mm formatting + SSE message apply. * `change-password-modal.spec.ts` — form validation, submit/cancel emission, error rendering. * `quick-tabs.spec.ts` — active class + tabChange emission. * `shell-active-section.spec.ts` — pure helper unit test. ### Mocking Strategy * **Backend SSE**: same supertest `.buffer(true).parse(...)` pattern already used by `tests/backend/sse-flatten.spec.ts`. Auth via `Authorization: Bearer `. * **Backend DB**: in-memory SQLite + `initDb(app)`. * **Frontend HTTP**: `TestBed.configureTestingModule({ imports: [...] })` + `setInput()` for inputs. No HTTP needed for these pure-logic specs. * **Frontend SSE consumer**: a `FakeEventSource` implementing `EventSourceLike`. --- ## 5. Cross-cutting Notes * **Reuse over reinvention**: The Job's "single source of truth for state derivation" maps cleanly to the existing `EventStatusService`. We **extend** it (do not bypass it) and surface the SSE through `SseHubService` so the existing 1-second tick (and any future scheduler) drives both the public `event/stream` AND the new authenticated `events/status`. * **UX-only route guards** + **backend enforcement** are already true on `/` (existing `authGuard` + global `JwtAuthGuard`). All new routes inherit the same. * **Cookie auth**: No tokens in localStorage. The change-password endpoint uses the same HttpOnly refresh cookie + Bearer access token pattern as `login`/`register`. Frontend uses `withCredentials: true` on every call. * **Error codes**: `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH` are added to `ERROR_CODES`; mapped to stable HTTP statuses (`401` / `400` / `400`). * **Persistence**: No new data on `/data` is required for this job. The `/data/hipctf` directory created by `setup.sh` continues to host the SQLite DB; settings live inside the DB. * **Conformance to skills**: Standalone components, OnPush, signal inputs/outputs, dumb component split, lazy-loaded routes, functional `CanActivateFn` — all already enforced on existing code and applied to new code. SSE side: backend uses standard `@Sse()` returning `Observable` and respects existing JWT guard semantics. Argon2id is reused; no bcrypt. --- ## 6. Build-time Fix (Discovered During Implementation) ### 6.1 Problem The Angular production build (`npm --workspace frontend run build`) failed with: ``` TS2339: Property 'code' does not exist on type 'ChangePasswordResult'. Property 'code' does not exist on type 'ChangePasswordSuccess'. src/app/features/home/home.component.ts:185:74 ...gePasswordError({ code: result.code, message: result.message })); TS2339: Property 'message' does not exist on type 'ChangePasswordResult'. Property 'message' does not exist on type 'ChangePasswordSuccess'. src/app/features/home/home.component.ts:185:96 ...gePasswordError({ code: result.code, message: result.message })); ``` ### 6.2 Root cause `AuthService.changePassword()` returns `Promise` where: ```ts export type ChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure; export interface ChangePasswordSuccess { ok: true } export interface ChangePasswordFailure { ok: false; status: number; code: string; message: string } ``` In `submitChangePassword()` (currently line 185) we wrote: ```ts const result = await this.auth.changePassword(payload); this.changePasswordSubmitting.set(false); if (result.ok) { this.changePasswordOpen.set(false); return; } this.changePasswordError.set(formatChangePasswordError({ code: result.code, message: result.message })); ``` After the `if (result.ok) { return; }` block the compiler should narrow `result` to `ChangePasswordFailure`, but the call to `this.changePasswordSubmitting.set(false)` between the `await` and the `if` is a non-trivial statement (it invokes a method on an injected object), and Angular's strict template + TypeScript settings do not propagate the narrowing through that call in this configuration. `result` therefore remains typed as the full `ChangePasswordResult` union at line 185. ### 6.3 Fix (surgical) In `frontend/src/app/features/home/home.component.ts`, narrow explicitly by binding the failure branch to a typed local: ```ts async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise { this.changePasswordSubmitting.set(true); this.changePasswordError.set(null); const result: ChangePasswordResult = await this.auth.changePassword(payload); this.changePasswordSubmitting.set(false); if (result.ok) { this.changePasswordOpen.set(false); return; } const failure: ChangePasswordFailure = result; this.changePasswordError.set( formatChangePasswordError({ code: failure.code, message: failure.message }), ); } ``` This requires: 1. Adding `ChangePasswordFailure` to the existing `import { ChangePasswordModalComponent, ChangePasswordSubmitPayload } from '../shell/change-password/change-password-modal.component';` line — change to: ```ts import { ChangePasswordModalComponent, ChangePasswordSubmitPayload } from '../shell/change-password/change-password-modal.component'; import { ChangePasswordResult, ChangePasswordFailure } from '../../core/services/auth.service'; ``` 2. Using the typed local `failure` for the error mapping call. No other source files need changes for the build to succeed. ### 6.4 Why this is the only remaining build error * The backend files compile cleanly (`tsc` was exercised during test setup). * All other frontend files compile cleanly (template syntax, signal imports, standalone declarations, OnPush, route lazy-loading). * The existing `tests/backend/event-status.spec.ts` continues to pass because `getStatus()` still returns its legacy `{status, startUtc, endUtc, serverNowUtc, countdownMs}` shape (`status` is preserved for backward compat, mirroring the new `state` field). ### 6.5 Re-verify after the fix Run: ```bash cd /repo npm --workspace frontend run build npm test ``` Expected: clean Angular production build + all backend and frontend tests green. ### 6.6 No test changes required for this build fix The backend `tests/backend/change-password.spec.ts` integration test already exercises every error branch (`PASSWORDS_DO_NOT_MATCH`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `401`) via the public HTTP surface, so no test changes are needed to validate the backend. The frontend discriminated-union issue is purely a TypeScript-level concern resolved by explicit narrowing. --- ## 7. Out-of-scope (for this Job) * Admin-initiated password reset UI in the Players admin section (`admin-reset` mode). The modal already accepts the `mode` input and would only render `oldPassword` for `'self'`; the admin-side wiring belongs to a future Job. * Theming of the LED colors. The header uses existing `--color-success` / `--color-warning` / `--color-danger` tokens applied via `BootstrapService.applyTheme`. * Authoring full Challenges / Scoreboard / Blog pages. Placeholder pages exist so the Quick Tabs navigate to real routes; richer content belongs to their respective Jobs.