docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-23 03:47:06 +00:00
parent 54f72f88dc
commit 4fd5c37d27
6 changed files with 236 additions and 17 deletions
+3 -3
View File
@@ -25,7 +25,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| Symbol | Path | Responsibility | | Symbol | Path | Responsibility |
|---|---|---| |---|---|---|
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, and peer-invalidation subscription. | | `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Smart shell; owns navigation signals, user hydration, change-password flow, event-stream lifecycle, peer-invalidation subscription, and per-session resets of both `UserStore` and `ChallengesStore` (the latter flushes the per-user board cache on logout / SSE unauthorized / peer-logout). |
| `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. | | `ShellHeaderComponent` | `frontend/src/app/features/shell/header/shell-header.component.ts` | Renders title, active section, event LED/countdown, and user menu; contains component-scoped LED styling. |
| `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. | | `QuickTabsComponent` | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Emits navigation events for Challenges, Scoreboard, and Blog. |
| `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping; wires an optional `onUnauthorized` callback for the new SSE `401`/`403` event. | | `EventStatusStore` | `frontend/src/app/core/services/event-status.store.ts` | Applies event state frames and computes countdown text; re-exports the pure LED color mapping; wires an optional `onUnauthorized` callback for the new SSE `401`/`403` event. |
@@ -33,7 +33,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. | | `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback and exposes `clearAndBroadcast` / `onPeerInvalidation`. |
| `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. | | `UserStore` | `frontend/src/app/core/services/user.store.ts` | Hydrates the signed-in user and rank data. |
| `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. | | `auth-session-events.pure.ts` | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and the namespaced channel / storage-key constants. |
| `ChallengesStore` | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board payload, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. | | `ChallengesStore` | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board payload, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, public `reset()` for the session-boundary flush, and `setMyUserId(id)` (now returns the previous id and resets whenever the cached state belongs to an unknown or different user). |
| `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. | | `ChallengesApiService` | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP wrapper around `/api/v1/challenges/{board,status,:id,:id/solves}` returning `Observable<...>` and translating `HttpErrorResponse` into a typed `ApiErrorEnvelope`. |
| `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). | | `challenges.pure.ts` | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types + helpers for the challenges feature (`BoardCard`, `SolverRow`, `SolveEventPayload`, sorting, parsers, friendly error mapping, `formatDdHhMm`). |
| `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. | | `ChallengeCardComponent` / `CategoryColumnComponent` / `ChallengeModalComponent` | `frontend/src/app/features/challenges/` | Presentational components for the board grid, column, and detail modal. |
@@ -74,7 +74,7 @@ on destruction.
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. | | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Adds Bearer authentication to the browser SSE request, which does not use the Angular HTTP interceptor chain. |
| `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. | | `frontend/src/app/core/services/event-status.pure.ts` | Defines the event payload and transport interface. |
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page; owns gate logic, modal lifecycle, SSE wiring, and the countdown-zero reload. | | `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page; owns gate logic, modal lifecycle, SSE wiring, and the countdown-zero reload. |
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application. | | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store backing `/challenges`: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, public `reset()`, and per-user `setMyUserId` flush. |
| `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. | | `tests/frontend/authenticated-event-source.spec.ts` | Regression coverage for authenticated SSE transport behavior. |
# Event-status pure helpers # Event-status pure helpers
+8 -7
View File
@@ -1,9 +1,9 @@
--- ---
type: architecture type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, and the authenticated player-facing challenges board. description: One-line responsibility for important source and contract-test files, including strict event-window validation, the public bootstrap SSE listener, the challenges full-replace import flow, the authenticated player-facing challenges board, and the per-user `solvedByMe` reset on the session boundary.
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications] tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session]
timestamp: 2026-07-23T01:19:00Z timestamp: 2026-07-23T03:45:00Z
--- ---
# Backend # Backend
@@ -52,7 +52,7 @@ timestamp: 2026-07-23T01:19:00Z
| `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. | | `frontend/src/app/core/services/bootstrap.service.ts` | Caches and exposes bootstrap state, refreshes it after admin updates, and applies the resolved theme. |
| `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. | | `frontend/src/app/core/services/bootstrap.types.ts` | Defines bootstrap/theme payload types and maps theme tokens to CSS custom properties. |
| `frontend/src/app/core/services/admin.service.ts` | Calls admin settings, theme-list, category, and upload APIs. | | `frontend/src/app/core/services/admin.service.ts` | Calls admin settings, theme-list, category, and upload APIs. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services; on logout and on session invalidation also calls `challengesStore.reset()` to flush any per-user board cache before navigating to `/login`. |
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. | | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state; owns the cross-tab invalidation `BroadcastChannel` + `storage`-event fallback. |
| `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. | | `frontend/src/app/core/services/auth-session-events.pure.ts` | Pure cross-tab message encoding/validation and namespaced constants used by the auth broadcast channel. |
| `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing, abort support, and a separate `'unauthorized'` event for `401`/`403`. | | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing, abort support, and a separate `'unauthorized'` event for `401`/`403`. |
@@ -60,7 +60,7 @@ timestamp: 2026-07-23T01:19:00Z
| `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). | | `frontend/src/app/core/services/bootstrap-event.pure.ts` | Pure helpers for the public bootstrap SSE listener: `isBootstrapGeneralFrame` predicate, SSE line parser, and the `makeBootstrapEventSource` factory (fetch + `ReadableStream` opener with frame buffering and idempotent `close()`). |
| `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. | | `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring; exposes `start()`/`stop()`/`closeTransport()` for transport lifecycle and `subscribeReloadAtCountdownZero(handler)` (with `reloadAtCountdownZero(handler)` kept as a back-compat wrapper) so the reload handler is owned by its subscriber rather than the transport. |
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. | | `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers (`formatDdHhMm` returns `DD:HH:mm:ss` and `deriveCountdownText`), and the `LED_COLOR_BY_STATE` map used by the shell LED. |
| `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, and navigates to `/login` when the session is invalidated elsewhere. | | `frontend/src/app/features/home/home.component.ts` | Authenticated shell container; starts user and event state services, subscribes to peer invalidation, resets both `UserStore` and `ChallengesStore`, and navigates to `/login` when the session is invalidated elsewhere. |
| `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. | | `frontend/src/app/features/shell/header/shell-header.component.ts` | Shell title, status LED (size/shape only — color comes from the pure map via `[style.background-color]`), countdown, and user menu. |
| `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. | | `frontend/src/app/features/shell/tabs/quick-tabs.component.ts` | Main shell navigation tabs. |
| `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. | | `frontend/src/app/features/shell/change-password/change-password-modal.component.ts` | Change-password form modal. |
@@ -73,7 +73,7 @@ timestamp: 2026-07-23T01:19:00Z
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. | | `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
| `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. | | `tests/frontend/admin-challenges-import-autoclose.spec.ts` | Pure helpers (`summarizeImportResult`, `importErrorMessage`) and modal auto-close branch for the confirmed-import handler. |
| `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). | | `frontend/src/app/features/challenges/challenges.page.ts` | `/challenges` smart page: gate logic (countdown/running/stopped/unconfigured), modal lifecycle, SSE wiring on `/api/v1/events`, and the page-owned countdown-zero reload (handler registered in the constructor with the disposer wired into `DestroyRef`). |
| `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle. | | `frontend/src/app/features/challenges/challenges.store.ts` | Signal store: board, event state, per-card solve listeners, SSE solve-frame mutation, submit response application, stop() lifecycle, public `reset()` for the session-boundary flush, and `setMyUserId(id)` which now returns the previous id and clears the cache whenever the cached state belongs to an unknown or different user. |
| `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. | | `frontend/src/app/features/challenges/challenges.service.ts` | HTTP service for `/api/v1/challenges/{board,status,:id,:id/solves}` returning typed `ApiErrorEnvelope`s. `getDetail` always requests `?include=solvers` so the modal can render the solvers list in one round-trip; `getBoard` only attaches `include=solvers` when the caller opts in. |
| `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). | | `frontend/src/app/features/challenges/challenges.pure.ts` | Pure types and helpers (`BoardCard`, `SolverRow`, `SolveEventPayload`, `parseSolveEvent`, `mergeSolveEventIntoSolvers`, `messageForSolveError`, `formatDdHhMm`). |
| `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. | | `frontend/src/app/features/challenges/category-column.component.ts` | Renders a single category column (header + cards) on the challenges board. |
@@ -83,7 +83,8 @@ timestamp: 2026-07-23T01:19:00Z
| `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). | | `frontend/src/app/core/interceptors/error-notification.interceptor.ts` | Translates `HttpErrorResponse` into friendly messages and pushes them to `NotificationService` (with suppression for `/api/v1/auth/{login,csrf,register}` and `/api/v1/challenges/status`). |
| `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). | | `tests/frontend/challenges.pure.spec.ts` | Pure helpers: sorting, parsers, friendly error mapping, `formatDdHhMm` (`DD:HH:mm:ss`). |
| `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. | | `tests/frontend/challenges.service.spec.ts` | HTTP-service contract: `getDetail` URL-encodes the id, attaches `?include=solvers`, and forwards `withCredentials: true`. |
| `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. | | `tests/frontend/challenges.store.spec.ts` | Signal store: board mutation, live solve merge, listeners, `stop()`. The "marks solvedByMe" and "does not double-count" tests now call `setMyUserId('me-1')` *before* `load()` to exercise the realistic page-mount sequence. |
| `tests/frontend/challenges.store.reset.spec.ts` | Per-user `solvedByMe` regression suite: `reset()` clears board/detail/myUserId/SSE; switching user id flushes the previous board; **orphan board** (cached with no recorded user id) is cleared when PlayerB's id is set; a non-mine solve never flips `solvedByMe` from `false` to `true`; `applySubmitResponse` takes the server value verbatim. |
| `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. | | `tests/frontend/notification-interceptor.spec.ts` | Interceptor suppression rules and friendly message mapping. |
| `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. | | `tests/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. |
| `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. | | `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. |
+9 -1
View File
@@ -28,7 +28,14 @@ The first time the page mounts it triggers `ChallengesStore.load()`
which calls `GET /api/v1/challenges/board`. It also calls which calls `GET /api/v1/challenges/board`. It also calls
`GET /api/v1/challenges/status` once to populate the event state `GET /api/v1/challenges/status` once to populate the event state
synchronously, then opens the authenticated SSE stream on synchronously, then opens the authenticated SSE stream on
`/api/v1/events` for live `solve` frames. `/api/v1/events` for live `solve` frames`.
The mount order is significant: `ChallengesPage.ngOnInit()` calls
`store.setMyUserId(user?.id ?? null)` **before** `store.load()`. If the
singleton's cache belongs to an unknown or different user, `setMyUserId`
calls `reset()` first so the new board is never blended with a previous
user's `solvedByMe`. See
[Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md).
# How to access (tester steps) # How to access (tester steps)
@@ -209,4 +216,5 @@ destruction of the page the SSE source is closed via
- [System Endpoints](/api/system.md) (legacy `/events/status`) - [System Endpoints](/api/system.md) (legacy `/events/status`)
- [Scoreboard Stream](/guides/scoreboard-stream.md) - [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport) - [Authenticated Shell](/guides/authenticated-shell.md) (SSE transport)
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) (logout/login, peer-tab, SSE-unauthorized reset)
- [Notifications](/guides/notifications.md) (error interceptor + service) - [Notifications](/guides/notifications.md) (error interceptor + service)
+200
View File
@@ -0,0 +1,200 @@
---
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-<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 `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)
+11 -5
View File
@@ -30,9 +30,11 @@ This change adds a same-browser **cross-tab session-invalidation channel**:
`sessionStorage` state **without** rebroadcasting, then notifies `sessionStorage` state **without** rebroadcasting, then notifies
subscribers. subscribers.
4. `HomeComponent` subscribes to the new peer-invalidation event, resets 4. `HomeComponent` subscribes to the new peer-invalidation event, resets
`UserStore`, closes any open change-password / user-menu overlays, and both `UserStore` and `ChallengesStore` (so the per-user board cache is
navigates to `/login`. A re-entrancy flag prevents duplicate navigations never leaked to the next signed-in user), closes any open
when the originating tab already navigated. change-password / user-menu overlays, and navigates to `/login`. A
re-entrancy flag prevents duplicate navigations when the originating
tab already navigated.
5. `AuthenticatedEventSourceService` was extended to dispatch a new 5. `AuthenticatedEventSourceService` was extended to dispatch a new
`'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic `'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic
network failures still dispatch `'error'` and do **not** invalidate. network failures still dispatch `'error'` and do **not** invalidate.
@@ -182,8 +184,10 @@ unhandled by the store; the SPA does not navigate on transport failure.
* `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from * `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from
`onPeerInvalidation`. `onPeerInvalidation`.
* `handleSessionInvalidated(reason)`: * `handleSessionInvalidated(reason)`:
* Calls `userStore.reset()` and closes `changePasswordOpen` / * Calls `userStore.reset()` and `challengesStore.reset()` and closes
`userMenuOpen` modal signals. `changePasswordOpen` / `userMenuOpen` modal signals. The challenges
reset guarantees the next signed-in user does not briefly see the
previous user's `solvedByMe` flags on `/challenges`.
* Guards re-entry with `navigatingToLogin` and short-circuits when the * Guards re-entry with `navigatingToLogin` and short-circuits when the
router is already on `/login`. router is already on `/login`.
* Otherwise awaits `router.navigateByUrl('/login')`. * Otherwise awaits `router.navigateByUrl('/login')`.
@@ -199,6 +203,8 @@ unhandled by the store; the SPA does not navigate on transport failure.
- [Authenticated Shell](/guides/authenticated-shell.md) - [Authenticated Shell](/guides/authenticated-shell.md)
- [Session Restoration on Reload](/guides/session-restoration.md) - [Session Restoration on Reload](/guides/session-restoration.md)
- [Challenges Board](/guides/challenges-board.md)
- [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md)
- [Frontend Structure](/architecture/frontend-structure.md) - [Frontend Structure](/architecture/frontend-structure.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Auth Endpoints](/api/auth.md) - [Auth Endpoints](/api/auth.md)
+5 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-23T03:09:36Z. they need. Last regenerated 2026-07-23T03:45:00Z.
# Architecture # Architecture
@@ -90,6 +90,10 @@ they need. Last regenerated 2026-07-23T03:09:36Z.
* [Challenges Board](/guides/challenges-board.md) - How a signed-in * [Challenges Board](/guides/challenges-board.md) - How a signed-in
player navigates `/challenges`, opens the modal, submits a flag, and player navigates `/challenges`, opens the modal, submits a flag, and
watches live solve updates. watches live solve updates.
* [Per-User `solvedByMe` Across the Session Boundary](/guides/challenges-per-user-state.md) -
How `ChallengesStore`, `HomeComponent`, and the challenges page
cooperate so PlayerA's `solvedByMe` flags never leak into PlayerB's
view across logout/login or peer-tab invalidation.
* [Change Password](/guides/change-password.md) - How a signed-in user * [Change Password](/guides/change-password.md) - How a signed-in user
changes their own password, including every error branch and the changes their own password, including every error branch and the
refresh-token revocation behavior. refresh-token revocation behavior.