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.tsalready 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.tshas only/,/login,/bootstrap,/admin, plus the/shell child route for admin. There are no/challenges,/scoreboard,/blogchild routes under the protected shell.backend/src/modules/auth/auth.controller.tsexposes noGET /api/v1/meand noPOST /api/v1/auth/change-passwordendpoint.backend/src/modules/system/system.controller.tsexposesGET /api/v1/event/stream(Public), but no authenticatedGET /api/v1/events/status(plural) protected-by-auth SSE.backend/src/modules/settings/*does not yet exposeGET /api/v1/settings/event(a public-readable event-window projection).- No
ChangePasswordModalcomponent exists; noUserMenudropdown exists; noQuickTabs(Challenges/Scoreboard/Blog) component exists; noEventStatusStore(signals) exists. AuthServicehas nochangePassword()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 inbackend/src/modules/<feature>/<feature>.service.ts. Validation usesZodValidationPipe(zod schemas indto/*.dto.ts). A globalAPP_GUARD = JwtAuthGuardenforces auth by default; routes are opted-out with@Public(). CSRF is enforced on unsafe methods viaCsrfMiddlewaremounted inmain.ts;@SkipCsrf()opts out. Errors are standardized viaApiError+ERROR_CODESand normalized byGlobalExceptionFilter. - Frontend: Angular 17.3 standalone components,
provideHttpClient(withInterceptors([...])), Angular Signals,OnPushchange detection,@if/@forcontrol flow. Smart/dumb component split. State lives in services usingsignal()/computed()(e.g.AuthService,BootstrapService). Lazy loading vialoadComponent. FunctionalCanActivateFnroute guards. Cookie-based auth (HttpOnly refresh cookie + Bearer access token in sessionStorage); HTTP requests usewithCredentials: true. - Tests: Jest with two projects (
backend,frontend) in/repo/tests/jest.config.js. Tests live in/repo/tests/backend/**/*.spec.tsand/repo/tests/frontend/**/*.spec.ts. Run vianpm testfrom repo root. Backend tests typically bootstrapAppModulewithTest.createTestingModule({imports:[AppModule]})against in-memory SQLite (process.env.DATABASE_PATH=':memory:'). Frontend tests usejsdomwithjest.setup.ts.
Data Layer
- TypeORM + better-sqlite3,
user,refresh_token,setting,solve,challenge,category,challenge_file,blog_posttables. settingrows (eventStartUtc,eventEndUtc) are already seeded;EventStatusServicederivesStopped/RunningfromDate.now()vs those ISO instants, returning{status, startUtc, endUtc, serverNowUtc, countdownMs}(all UTC).- No schema migrations are required for Job 857. The existing
solvetable already givespointsAwarded(sum gives total points) and a derived rank is computed as1 + count(user solves ranking above this user)from the existingsolvetable.
Test Framework & Structure
- Jest (workspaces: backend + frontend). Single command:
npm test(wired through rootpackage.json). - Backend specs are Nest
INestApplicationintegration tests OR plainnew 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.tsfiles, 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 existingEventStatusService,SseHubService,JwtAuthGuard,CsrfMiddleware,@Public(),@Roles()andSettingsServiceare reused.setup.shdoes not need changes.
2. Impacted Files
Backend — To Modify
backend/src/modules/auth/auth.controller.ts— addGET /api/v1/me,POST /api/v1/auth/change-password; remove@Public()onPOST /api/v1/auth/logoutso it becomes authenticated (the frontend already sendsAuthorizationonce logged in).backend/src/modules/auth/auth.service.ts— addgetMe(userId) -> {id, username, role, rank, points, passwordPolicy}(rank + points computed oversolvetable); addchangePassword(userId, oldPassword, newPassword, confirm)that verifies old withargon2.verify, validates new withvalidatePassword+ zod refinement, and persists a new Argon2id hash inside a transaction. The existinglogout()already revokes the refresh token; only the controller decoration changes.backend/src/modules/auth/dto/auth.dto.ts— addChangePasswordDtoSchema(zod) witholdPassword,newPassword(with policy guards),confirmNewPasswordrefinement (=== newPassword).backend/src/modules/system/system.controller.ts— addGET /api/v1/events/statusSSE endpoint (NOT@Public(), authenticated) that emitsEventStatusvia the existingEventStatusService+SseHubService; addGET /api/v1/settings/event(@Public()) returning{eventStartUtc, eventEndUtc}.backend/src/common/services/event-status.service.ts— extend interface to add anunconfiguredbranch (wheneventStartUtc/eventEndUtcsettings are missing/invalid) and a 4-statestatefield withserverNowUtc,eventStartUtc,eventEndUtc,secondsToStart,secondsToEndto match the Job's required payload shape. Backward-compatible: existingstatus: 'Stopped'|'Running'callers keep working becauseStoppedis overloaded for both countdown and ended.backend/src/common/errors/error-codes.ts— addINVALID_OLD_PASSWORD,PASSWORD_POLICY,PASSWORDS_DO_NOT_MATCH.backend/src/app.module.ts— none required; the new pieces sit insideAuthModule/SystemModule.
Backend — To Create
backend/src/modules/users/users-rank.service.ts— pure providerrankOfUser(manager, userId)returning{ rank, points }by summingpointsAwardedfromsolveand counting distinct users ahead. Registered inUsersModule.
Frontend — To Modify
frontend/src/app/app.routes.ts— keep/,/bootstrap,/login. Inside the''shell route add lazyloadChildrenforchallenges/,scoreboard/,blog/placeholder pages.frontend/src/app/core/services/auth.service.ts— addchangePassword({oldPassword, newPassword, confirmNewPassword})andlogout(); addme()(GET /api/v1/me) and extendCurrentUserwith optionalrank/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— purepasswordMatchValidator.frontend/src/app/features/shell/change-password/password-feedback.ts— pureformatChangePasswordError()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/meshape + 401.tests/backend/rank-points.spec.ts— pure unit test forrankOfUser().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 forderiveActiveSectionFromUrl.
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)returningEventStatePayload = {state: 'countdown'|'running'|'stopped'|'unconfigured', serverNowUtc, eventStartUtc, eventEndUtc, secondsToStart, secondsToEnd}. getStatus(now)is preserved for backward compatibility and computes its legacystatus/countdownMsfrom the newstate(Stoppedfor countdown,Runningfor running,Stoppedwith0for 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/pointscomputed viaUsersRankService.
c. Add POST /api/v1/auth/change-password
- Authenticated, CSRF enforced.
- Body via
ZodValidationPipe(ChangePasswordDtoSchema). - Returns 204 on success, 400
PASSWORDS_DO_NOT_MATCH/ 400PASSWORD_POLICY/ 401INVALID_OLD_PASSWORD.
d. Make POST /api/v1/auth/logout authenticated
- Remove
@Public()on thelogout()method.
e. AuthService.changePassword() method
File: backend/src/modules/auth/auth.service.ts
changePassword(userId, dto):- Validate
newPassword === confirmNewPassword. - Reject when
oldPassword === newPasswordwithPASSWORD_POLICY. - Verify old via
argon2.verify. - Validate new via
validatePassword()(rethrow withPASSWORD_POLICYcode). - Re-hash with Argon2id and persist.
- Revoke all existing refresh tokens for the user.
- Validate
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
dataframe:{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 discriminatedChangePasswordResult = ChangePasswordSuccess | ChangePasswordFailure.- Extend
CurrentUserwith optionalrank?: number | nullandpoints?: number.
c. EventStatusStore
File: frontend/src/app/core/services/event-status.store.ts
- Signals
state,serverNowUtc,eventStartUtc,eventEndUtc, privateanchorDeltaMs. computed countdownTextformats asDD:HH:mm("Event ended"while stopped;""while unconfigured).applyServerStatus(payload),start(createSource),stop()lifecycle. Cleanup viainject(DestroyRef).
d. UserStore
File: frontend/src/app/core/services/user.store.ts
- Signals
user,loading,error.computed rankTextformats"Rank: <r> - Points: <p>". loadMe()callsAuthService.me().
e. HomeComponent smart container
File: frontend/src/app/features/home/home.component.ts
- Injects
EventStatusStore,UserStore,AuthService,Router. - Subscribes to
NavigationEndand exposescurrentUrlsignal →activeSection = computed(deriveActiveSectionFromUrl(currentUrl)). ngOnInit()callsUserStore.loadMe()and starts the SSE consumer viaEventStatusStore.start(() => new EventSource('/api/v1/events/status', { withCredentials: true })).ngOnDestroy()callsEventStatusStore.stop().
f. Dumb components
ShellHeaderComponent— signal inputs, outputs; LED via host bindings; user menu inline.QuickTabsComponent— signal inputstabs/active,tabChangeoutput.ChangePasswordModalComponent— three reactive controls withpasswordMatchValidator; emitssubmit({oldPassword,newPassword,confirmNewPassword})andcancel; 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 bytests/backend/sse-flatten.spec.ts. Auth viaAuthorization: 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
FakeEventSourceimplementingEventSourceLike.
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 throughSseHubServiceso the existing 1-second tick (and any future scheduler) drives both the publicevent/streamAND the new authenticatedevents/status. - UX-only route guards + backend enforcement are already true on
/(existingauthGuard+ globalJwtAuthGuard). 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 useswithCredentials: trueon every call. - Error codes:
INVALID_OLD_PASSWORD,PASSWORD_POLICY,PASSWORDS_DO_NOT_MATCHare added toERROR_CODES; mapped to stable HTTP statuses (401/400/400). - Persistence: No new data on
/datais required for this job. The/data/hipctfdirectory created bysetup.shcontinues 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()returningObservable<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:
- Adding
ChangePasswordFailureto the existingimport { 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'; - Using the typed local
failurefor 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 (
tscwas 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.tscontinues to pass becausegetStatus()still returns its legacy{status, startUtc, endUtc, serverNowUtc, countdownMs}shape (statusis preserved for backward compat, mirroring the newstatefield).
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-resetmode). The modal already accepts themodeinput and would only renderoldPasswordfor'self'; the admin-side wiring belongs to a future Job. - Theming of the LED colors. The header uses existing
--color-success/--color-warning/--color-dangertokens applied viaBootstrapService.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.