Files
HIPCTF2/.kilo/plans/857.md
T

21 KiB

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/<feature>/<feature>.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.tsdumb presentational component.
  • frontend/src/app/features/shell/tabs/quick-tabs.component.tsdumb presentational component.
  • frontend/src/app/features/shell/user-menu/user-menu.component.tsdumb dropdown component.
  • frontend/src/app/features/shell/change-password/change-password-modal.component.tsdumb 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: <r> - Points: <p>".
  • 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 <token>.
  • 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<MessageEvent> 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<ChangePasswordResult> where:

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:

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:

async submitChangePassword(payload: ChangePasswordSubmitPayload): Promise<void> {
  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:
    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:

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.