Files
HIPCTF2/docs/guides/challenges-per-user-state.md
T
2026-07-23 03:47:06 +00:00

9.7 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.
guide
challenges
solvedByMe
session
logout
store
tester
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-<uuid>"] Absent for PlayerB if PlayerA solved it.
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 falsetrue. 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