8.2 KiB
Implementation Plan: Job 908 — Clear challenge state at the session boundary
1. Architectural Reconnaissance
- Codebase style & conventions: TypeScript monorepo using NestJS (backend) + Angular 20 (frontend SPA),
providedIn: 'root'signal-based stores,ChangeDetectionStrategy.OnPush, standalone components, RxJS for HTTP and SSE; backend uses TypeORM with SQLite. The repo follows the docs in/repo/docs/(esp.architecture/frontend-structure.md,guides/challenges-board.md,api/challenges.md). - Data Layer: SQLite via TypeORM. The board endpoint already filters
solvedByMeper caller'scurrentUserId(backend/src/modules/challenges/challenges.service.ts:75-125). The backend is correct —solvedByMeis computed from the JWT-authenticated user. The bug is purely on the frontend. - Test Framework & Structure: Jest 29 via
ts-jest(configs intests/jest.config.js). Tests live intests/frontend/(jsdom) andtests/backend/(node). Run withnpm testfrom repo root. Frontend tests favor plain TS classes / stores with manual fakes (no TestBed harness needed for the unit in question — seetests/frontend/challenges.store.spec.tspattern). - Required Tools & Dependencies: No new packages. The fix is purely a code change inside existing modules.
2. Impacted Files
- To Modify:
frontend/src/app/features/challenges/challenges.store.ts—setMyUserId()resets whenever the cached state belongs to an unknown user (i.e. when_board !== nullandprev === nullorprev !== id). Add a small helperclearIfStaleOrDifferent(id)so the rule is consistent.frontend/src/app/features/home/home.component.ts— injectChallengesStoreand callchallengesStore.reset()inonLogout()(afterauth.logout()) and inhandleSessionInvalidated()(before navigating to/login).tests/frontend/challenges.store.reset.spec.ts— add a regression test covering the new rule: a preloaded board with no recorded user id is cleared when PlayerB's id is set. Also keep the existing user-switch test.
- To Create: (none)
3. Proposed Changes
3.1 Root cause
ChallengesStore is a providedIn: 'root' singleton that survives logout/login. The current setMyUserId() only resets when prev !== null && prev !== id, so two real-world cases still leak PlayerA's per-user state into PlayerB's view:
- PlayerB logs in fresh on a server that has never seen them before
setMyUserIdran.prev === null(no user id has been recorded yet) so the guard skipsreset(). If the previous user (PlayerA) had loaded a board (now stale in memory), PlayerB may flash the PlayerA board during the moment before theirload()resolves. In an SSR or hot-reload scenario whereChallengesPageis constructed before any user has been associated with the singleton, the very firstsetMyUserIdcall must clear any pre-existing board. - Session invalidation (logout, SSE 401/403, peer tab logout) clears the auth state but leaves the singleton cache.
HomeComponent.handleSessionInvalidated()andHomeComponent.onLogout()navigate to/loginwithout tellingChallengesStoreto drop the cached board.
3.2 Backend changes
None.
3.3 Frontend changes — frontend/src/app/features/challenges/challenges.store.ts
-
Update
setMyUserId(id)so it resets whenever the cached state is for an unknown or different user. Concretely:setMyUserId(id: string | null): string | null { const prev = this._myUserId(); const hasCachedUserData = this._board() !== null || this._selectedDetail() !== null; // Reset whenever the cached state belongs to a different or unknown user. if (hasCachedUserData && prev !== id) { this.reset(); } this._myUserId.set(id); return prev; }Notes:
- When
prev === id(same user re-mounts), we keep the cached board — this preserves the existing tests' expectation that a re-mount does not refetch. - When
prev === null && id !== nulland no board is cached, we just record the id without reset (matches the singleton's first-use path). - When
prev === null && id !== nulland a board IS cached (orphan state), we reset — this is the new case the test must cover. - When
prev !== null && prev !== id, we reset — already covered today. - When
id === null(logout path) and the store has cached data, we also reset.
- When
-
Keep
reset()as is — it already stops SSE, clears_board,_selectedDetail,_myUserId,_error,_loading, dropscountdownZeroHandler, and emptiessolveListeners.
3.4 Frontend changes — frontend/src/app/features/home/home.component.ts
-
Inject
ChallengesStorealongside the existinguserStoreandauthinjections inHomeComponent. -
In
onLogout()— afterawait this.auth.logout()and before navigation, callthis.challengesStore.reset():async onLogout(): Promise<void> { await this.auth.logout(); this.userMenuOpen.set(false); this.userStore.reset(); this.challengesStore.reset(); await this.router.navigateByUrl('/login'); } -
In
handleSessionInvalidated()— reset the challenges store alongsideuserStore.reset(), before the navigation guard:private async handleSessionInvalidated( _reason: 'peer-logout' | 'sse-unauthorized', ): Promise<void> { this.userStore.reset(); this.challengesStore.reset(); this.changePasswordOpen.set(false); this.userMenuOpen.set(false); if (this.navigatingToLogin) return; if (this.router.url.startsWith('/login')) return; this.navigatingToLogin = true; try { await this.router.navigateByUrl('/login'); } finally { this.navigatingToLogin = false; } }
This double-coverage (HomeComponent at the session boundary + setMyUserId at the page mount) means the store is always cleared at least once on the way out, regardless of whether logout went through the GUI or a peer-tab broadcast.
3.5 Accessibility / UX confirmation
ChallengeCardComponentbinds[class.solved]="card.solvedByMe"and the<span *ngIf="card.solvedByMe" class="check" aria-label="Solved">✓</span>. After the fix, PlayerB'ssolvedByMeis per-user and falsy for Alpha Cipher. The generic "Solved ✓" therefore disappears from PlayerB's accessibility snapshot. The card retains120 pts,4 solves, and the difficulty pill.
4. Test Strategy
- Target Unit Test File:
tests/frontend/challenges.store.reset.spec.ts(update existing spec). Existing tests coverreset()and the user-switch path; we add the new "unknown-user" case. - Mocking Strategy: Pure store tests using the hand-rolled API fake pattern from
tests/frontend/challenges.store.spec.ts(new ChallengesStore(api as any, { onDestroy: () => undefined } as any)). No HTTP, no Angular TestBed; no DOM visuals. - Key assertions (minimal — only the core success path and one regression per change):
- NEW: preloaded board with no recorded user id is cleared when PlayerB's id is set.
- Load a PlayerA-shaped board (
solvedByMe: true) with no priorsetMyUserId()call. - Call
setMyUserId('playerB'). - Assert
store.board() === null,findCard('alpha') === null, andstore.selectedChallengeDetail() === null. - This is the regression that closes the original bug.
- Load a PlayerA-shaped board (
- Existing:
setMyUserIdwith a different id flushes the previous board — keep as is. - Existing:
reset()clears board, selectedDetail, myUserId and stops any SSE — keep as is. - Existing:
applySubmitResponsealways takes the server authoritativesolvedByMe— keep as is.
- NEW: preloaded board with no recorded user id is cleared when PlayerB's id is set.
- Single command from root:
npm test -- --testPathPattern=challenges.storeruns the updated spec via the existing frontend Jest project; the existingnpm testcontinues to run the whole suite.
5. Required Tools & Dependencies
- System tools: Node 20+ (already required by
setup.sh), SQLite viabetter-sqlite3(already installed via root workspaces). - Package dependencies: None new. The fix uses only existing
@angular/core(signal,DestroyRef) and TS types. setup.shimpact: None — no new script, no new env var, no new migration needed.