--- type: guide title: Per-User `solvedByMe` Across the Session Boundary description: How the `ChallengesStore` signal cache guarantees that PlayerA's solved-by-me flags never leak into PlayerB's view across logout/login, including SSE-unauthorized and peer-logout invalidation. tags: [guide, challenges, solvedByMe, session, logout, store, tester] timestamp: 2026-07-23T03:45:00Z --- # What this feature does `ChallengesStore` is a `providedIn: 'root'` Angular singleton that survives route navigation and SPA reloads. Its board cache holds a `BoardResponse` whose cards carry a per-user `solvedByMe: boolean` flag (the `✓` glyph on the card and the `You solved this challenge.` modal banner). Because `solvedByMe` is **server-computed from the JWT-authenticated user** (`backend/src/modules/challenges/challenges.service.ts:75-125`), the backend has always returned the correct value per request. The risk was purely on the frontend: if PlayerA loaded the board, then logged out, and PlayerB logged in **before** the new board response arrived, PlayerB could briefly see PlayerA's `solvedByMe` flags (or worse, `applySolveEvent` from a third-party solve could overwrite PlayerB's `solvedByMe` because the store still believed it was PlayerA). This change closes that gap at three coordinated points: 1. `ChallengesStore.setMyUserId(id)` — now returns the previous id, and whenever the cached state belongs to a different or unknown user, the store calls `reset()` before recording the new id. This catches the "orphan board loaded before the first `setMyUserId`" path (SSR, hot reload, page mount before auth hydration). 2. `ChallengesStore.reset()` — new public method; stops the SSE source, clears the one-second tick interval, and wipes `_board`, `_selectedDetail`, `_myUserId`, `_error`, `_loading`, the countdown reload handler, and all `solveListeners`. 3. `HomeComponent.onLogout()` and `HomeComponent.handleSessionInvalidated()` — both now inject `ChallengesStore` and call `challengesStore.reset()` alongside the existing `userStore.reset()`. This guarantees the board cache is cleared on the way out, regardless of whether logout went through the GUI menu or was triggered by a peer-tab broadcast / SSE `401`/`403`. Additionally, the mutation paths (`applySolveEvent`, `applySubmitResponse`) stopped OR-ing `solvedByMe`. They now take the server's authoritative value verbatim, so a third-party solve cannot falsely flip a player's `solvedByMe` from `false` to `true` even within a single session. # Why a new concept doc The fix spans three files that previously did not coordinate (`ChallengesStore`, `HomeComponent`, `ChallengesPage`) and adds a public `reset()` method to a previously opaque singleton. A tester verifying the per-user invariant should not have to diff the store to learn the reset rules, and a developer wiring a new "logout-adjacent" feature should not have to guess that the challenges board also needs to be flushed. # Tester flows > The following assume a configured event window with the event in > `running` state and at least one challenge (`alpha`) that has been > pre-solved by PlayerA. ## User switch via menu logout 1. Sign in as **PlayerA** and navigate to `/challenges`. 2. Confirm `alpha` shows the `✓` glyph (`.check` inside `[data-testid="challenge-card-alpha"]`) and the success border. 3. Open the user menu (`[data-testid="user-menu-trigger"]`) and click **Logout**. 4. The SPA navigates to `/login`; the `ChallengesStore` cache is cleared. 5. Sign in as **PlayerB** (a different account that has not solved `alpha`). 6. Navigate to `/challenges`. Confirm `alpha` shows: * `120 pts` and `4 solves` (the global counter — unchanged). * **No** `✓` glyph and **no** success border on the card. * **No** `[data-testid="modal-solved-banner"]` (`You solved this challenge.`) when the card is opened. ## User switch via peer-tab SSE unauthorized 1. Open the SPA in **Tab A** and **Tab B**, both signed in as PlayerA on `/challenges`. `alpha` shows `✓`. 2. From the server side, revoke PlayerA's refresh-cookie/session (e.g. an admin row-edit or a manual `refresh_token` deletion). 3. `EventStatusStore`'s SSE reconnect returns `401`. `Tab A` clears state and navigates to `/login`. The `ChallengesStore` cache in `Tab A` is reset. 4. In **Tab B**, the user menu can also be used to sign out as PlayerB (or wait for the broadcast from Tab A). Confirm the board does not briefly show PlayerA's `solvedByMe` on PlayerB's view during the transition. ## Third-party solve does not flip `solvedByMe` 1. Sign in as **PlayerB**. Confirm `alpha` shows `solvedByMe = false`. 2. From a second browser session, sign in as **PlayerA** and solve `alpha`. The SSE `solve` frame is broadcast. 3. In PlayerB's tab, confirm `alpha`'s `✓` does **not** appear, the border does **not** flip to success, and the modal does **not** show the solved banner. `livePoints` and `solveCount` may still update (those are global, not per-user). ## Negative cases | Action | Expected | |---|---| | Reload `/challenges` as the same PlayerA | The board cache is preserved across the SPA reload (signal singletons survive), so `alpha` still shows `✓` after the reload completes its `load()`. | | Navigate away from `/challenges` and back | The singleton keeps the cache; `alpha` still shows `✓`. | | Have PlayerB solve `alpha` for the first time | The submit response drives `applySubmitResponse`; `solvedByMe` flips to `true`, `✓` appears, and the card border flips to success. | | Logout PlayerB then log PlayerB back in (same user) | The store is reset on logout, so the new `load()` fetches a fresh board and `solvedByMe` is recomputed by the server. | # Visual / interactive elements There are **no new UI controls**. The existing card and modal selectors are the regression targets: | Element | Selector | After user-switch | |---|---|---| | Solved check | `.check` inside `[data-testid="challenge-card-"]` | **Absent** for PlayerB if PlayerA solved it. | | Solved border | `[data-testid="challenge-card-"]` (border color) | Default border color, not success. | | Modal solved banner | `[data-testid="modal-solved-banner"]` | **Absent** for PlayerB. | | Awarded banner | `[data-testid="modal-awarded-banner"]` | **Absent** (no award yet for PlayerB). | # How it works (developer map) ## `ChallengesStore` singleton lifecycle `ChallengesStore` is `providedIn: 'root'`, so a single instance is shared by every consumer (`ChallengesPage`, `HomeComponent`, any future feature that injects it). The store never calls `reset()` itself based on time — it only resets in response to explicit calls or to a `setMyUserId` change. ### `setMyUserId(id: string | null): string | null` Returns the previous user id (or `null`). If the store currently holds cached board or detail data **and** the previous id differs from the new id, it calls `reset()` first. The four cases: | Previous `_myUserId` | New `id` | Cached state present? | Action | |---|---|---|---| | `null` | `playerB` | No | Just record the id. | | `null` | `playerB` | Yes (orphan) | **Reset**, then record. *(new — closes the bug)* | | `playerA` | `playerA` | Yes | Just record (re-mount path; keeps cache). | | `playerA` | `playerB` | Yes | **Reset**, then record. | | `playerA` | `null` | Yes | **Reset**, then record `null`. | ### `reset(): void` New public method. Calls `stop()` (closes the SSE source, clears the 1-second tick interval), then nulls every signal and clears `solveListeners` and `countdownZeroHandler`. Idempotent. ### Mutation paths take server values verbatim `applySolveEvent` previously computed `solvedByMe: card.solvedByMe || isMine`. This was wrong because if `card.solvedByMe` was `true` from a stale cache and `isMine` was `false` (a third party solved), it would leave the flag `true`. It is now `solvedByMe: isMine ? true : card.solvedByMe`, so a non-mine event can never promote `false` → `true`. `applySubmitResponse` no longer ORs either — it takes `response.challenge.solvedByMe` directly, since the server is authoritative. ## `ChallengesPage` mount `ChallengesPage.ngOnInit()` now calls `this.store.setMyUserId(user?.id ?? null)` (passing `null` explicitly when there is no user, instead of the previous `if (user?.id)` guard). This ensures the store gets the call even on the unauthenticated edge case so the cache-reset decision runs once. ## `HomeComponent` session-boundary reset `HomeComponent` injects `ChallengesStore` and calls `challengesStore.reset()` in two places: | Trigger | Method | |---|---| | User clicked Logout in the menu | `onLogout()` (after `await this.auth.logout()`, before `navigateByUrl('/login')`) | | Peer-tab broadcast or SSE `401`/`403` | `handleSessionInvalidated()` (alongside `userStore.reset()` and the modal/menu close) | The `ChallengesStore` is reset **before** navigation so the SPA never renders `/challenges` with a stale board. ## Test coverage | File | What it pins down | |---|---| | `tests/frontend/challenges.store.reset.spec.ts` | New: `reset()` clears board / detail / myUserId / SSE; `setMyUserId` flushes when switching; **regression for orphan board** (cached board with no recorded user id is cleared when PlayerB's id is set); third-party solve does not flip `solvedByMe`; `applySubmitResponse` takes the server value verbatim. | | `tests/frontend/challenges.store.spec.ts` | Updated: the "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()`, so they exercise the realistic page-mount sequence. | # See also - [Challenges Board](/guides/challenges-board.md) - [Challenges Endpoints](/api/challenges.md) - [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) - [Frontend Structure](/architecture/frontend-structure.md) - [Key Files Index](/architecture/key-files.md)