From 4fd5c37d27d35fd3c2af74e359b077b43f2e399d Mon Sep 17 00:00:00 2001 From: OpenVelo Agent Date: Thu, 23 Jul 2026 03:47:06 +0000 Subject: [PATCH] docs: update documentation to OKF v0.1 format --- docs/architecture/frontend-structure.md | 6 +- docs/architecture/key-files.md | 15 +- docs/guides/challenges-board.md | 10 +- docs/guides/challenges-per-user-state.md | 200 +++++++++++++++++++++++ docs/guides/cross-tab-invalidation.md | 16 +- docs/index.md | 6 +- 6 files changed, 236 insertions(+), 17 deletions(-) create mode 100644 docs/guides/challenges-per-user-state.md diff --git a/docs/architecture/frontend-structure.md b/docs/architecture/frontend-structure.md index 46d79f8..a7b6f4e 100644 --- a/docs/architecture/frontend-structure.md +++ b/docs/architecture/frontend-structure.md @@ -25,7 +25,7 @@ Routes live in `frontend/src/app/app.routes.ts`: | 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. | | `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. | @@ -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`. | | `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. | -| `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`. | | `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. | @@ -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/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.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. | # Event-status pure helpers diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 428f882..f2ca5af 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -1,9 +1,9 @@ --- type: architecture 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. -tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications] -timestamp: 2026-07-23T01:19:00Z +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, per-user, session] +timestamp: 2026-07-23T03:45:00Z --- # 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.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/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-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`. | @@ -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/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/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/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. | @@ -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/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.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.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. | @@ -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`). | | `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.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/backend/challenges-board.spec.ts` | Board query shape, ordering, `?include=solvers`. | | `tests/backend/challenges-status-rest.spec.ts` | `/api/v1/challenges/status` snapshot shape. | diff --git a/docs/guides/challenges-board.md b/docs/guides/challenges-board.md index 09ed4a9..6caca51 100644 --- a/docs/guides/challenges-board.md +++ b/docs/guides/challenges-board.md @@ -28,7 +28,14 @@ The first time the page mounts it triggers `ChallengesStore.load()` which calls `GET /api/v1/challenges/board`. It also calls `GET /api/v1/challenges/status` once to populate the event state 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) @@ -209,4 +216,5 @@ destruction of the page the SSE source is closed via - [System Endpoints](/api/system.md) (legacy `/events/status`) - [Scoreboard Stream](/guides/scoreboard-stream.md) - [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) \ No newline at end of file diff --git a/docs/guides/challenges-per-user-state.md b/docs/guides/challenges-per-user-state.md new file mode 100644 index 0000000..4358005 --- /dev/null +++ b/docs/guides/challenges-per-user-state.md @@ -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-"]` | **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) \ No newline at end of file diff --git a/docs/guides/cross-tab-invalidation.md b/docs/guides/cross-tab-invalidation.md index 7166db2..39cc51a 100644 --- a/docs/guides/cross-tab-invalidation.md +++ b/docs/guides/cross-tab-invalidation.md @@ -30,9 +30,11 @@ This change adds a same-browser **cross-tab session-invalidation channel**: `sessionStorage` state **without** rebroadcasting, then notifies subscribers. 4. `HomeComponent` subscribes to the new peer-invalidation event, resets - `UserStore`, closes any open change-password / user-menu overlays, and - navigates to `/login`. A re-entrancy flag prevents duplicate navigations - when the originating tab already navigated. + both `UserStore` and `ChallengesStore` (so the per-user board cache is + never leaked to the next signed-in user), closes any open + 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 `'unauthorized'` event when the SSE fetch returns `401` or `403`. Generic 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 `onPeerInvalidation`. * `handleSessionInvalidated(reason)`: - * Calls `userStore.reset()` and closes `changePasswordOpen` / - `userMenuOpen` modal signals. + * Calls `userStore.reset()` and `challengesStore.reset()` and closes + `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 router is already on `/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) - [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) - [REST API Overview](/api/rest-overview.md) - [Auth Endpoints](/api/auth.md) diff --git a/docs/index.md b/docs/index.md index 8eae156..b22dc61 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin controls. 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 @@ -90,6 +90,10 @@ they need. Last regenerated 2026-07-23T03:09:36Z. * [Challenges Board](/guides/challenges-board.md) - How a signed-in player navigates `/challenges`, opens the modal, submits a flag, and 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 changes their own password, including every error branch and the refresh-token revocation behavior.