9.9 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| guide | Per-User `solvedByMe` Across the Session Boundary | 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. |
|
2026-07-23T04:05: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:
ChallengesStore.setMyUserId(id)— now returns the previous id, and whenever the cached state belongs to a different or unknown user, the store callsreset()before recording the new id. This catches the "orphan board loaded before the firstsetMyUserId" path (SSR, hot reload, page mount before auth hydration).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 allsolveListeners.HomeComponent.onLogout()andHomeComponent.handleSessionInvalidated()— both now injectChallengesStoreand callchallengesStore.reset()alongside the existinguserStore.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 / SSE401/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
runningstate and at least one challenge (alpha) that has been pre-solved by PlayerA.
User switch via menu logout
- Sign in as PlayerA and navigate to
/challenges. - Confirm
alphashows the✓glyph (.checkinside[data-testid="challenge-card-alpha"]) and the success border. - Open the user menu (
[data-testid="user-menu-trigger"]) and click Logout. - The SPA navigates to
/login; theChallengesStorecache is cleared. - Sign in as PlayerB (a different account that has not solved
alpha). - Navigate to
/challenges. Confirmalphashows:120 ptsand4 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
- Open the SPA in Tab A and Tab B, both signed in as PlayerA
on
/challenges.alphashows✓. - From the server side, revoke PlayerA's refresh-cookie/session (e.g. an
admin row-edit or a manual
refresh_tokendeletion). EventStatusStore's SSE reconnect returns401.Tab Aclears state and navigates to/login. TheChallengesStorecache inTab Ais reset.- 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
solvedByMeon PlayerB's view during the transition.
Third-party solve does not flip solvedByMe
- Sign in as PlayerB. Confirm
alphashowssolvedByMe = false. - From a second browser session, sign in as PlayerA and solve
alpha. The SSEsolveframe is broadcast. - 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.livePointsandsolveCountmay 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 | [data-testid="challenge-check-<uuid>"] (.check glyph, aria-hidden="true") inside [data-testid="challenge-card-<uuid>"] |
Absent for PlayerB if PlayerA solved it. The button's aria-pressed is also false and data-solved="false". |
| Solved border | [data-testid="challenge-card-<uuid>"] (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. |