docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-22 10:35:24 +00:00
parent 90e570c43c
commit 305352b259
5 changed files with 263 additions and 15 deletions
+30 -8
View File
@@ -3,7 +3,7 @@ type: architecture
title: Frontend Structure title: Frontend Structure
description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport. description: Angular routes, components, services, guards, interceptors, and authenticated SSE transport.
tags: [architecture, frontend, angular, shell, sse] tags: [architecture, frontend, angular, shell, sse]
timestamp: 2026-07-22T09:35:00Z timestamp: 2026-07-22T10:32:00Z
--- ---
# Routes # Routes
@@ -24,13 +24,14 @@ 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, and event-stream lifecycle. | | `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. |
| `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. | | `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. |
| `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events. | | `AuthenticatedEventSourceService` | `frontend/src/app/core/services/authenticated-event-source.service.ts` | Opens authenticated SSE over `fetch`, parsing streamed frames into `EventSourceLike` events; dispatches a separate `'unauthorized'` event for `401`/`403`. |
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Stores the access token used by the authenticated SSE transport. | | `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. |
# Authenticated SSE wiring # Authenticated SSE wiring
@@ -44,10 +45,17 @@ Routes live in `frontend/src/app/app.routes.ts`:
4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and 4. Splits complete SSE records on blank lines, joins repeated `data:` lines, and
dispatches `MessageEvent` objects. dispatches `MessageEvent` objects.
5. Buffers records received before the message listener is attached. 5. Buffers records received before the message listener is attached.
6. Aborts and clears listeners when `close()` is called. 6. Inspects the response status *before* the `ok`/`body` check; on `401` or
`403` dispatches a separate `'unauthorized'` event and returns without
reading the body. Generic transport failures still dispatch `'error'`.
7. Aborts and clears listeners when `close()` is called.
`EventStatusStore` parses each message as `EventStatePayload`, updates its signals, `EventStatusStore.start(createSource, onUnauthorized?)` parses each message as
and runs a one-second local timer. `HomeComponent` stops the store on destruction. `EventStatePayload`, updates its signals, runs a one-second local timer, and —
when `onUnauthorized` is provided — invokes it on `'unauthorized'` events.
`HomeComponent` passes an `onUnauthorized` callback that delegates to its
shared `handleSessionInvalidated('sse-unauthorized')` flow and stops the store
on destruction.
# Key integration files # Key integration files
@@ -84,6 +92,20 @@ color always comes from `LED_COLOR_BY_STATE` and is guaranteed to be a
non-transparent theme token. The four `led-<state>` classes remain on the non-transparent theme token. The four `led-<state>` classes remain on the
element as selectors/test hooks but no longer own the color. element as selectors/test hooks but no longer own the color.
# Cross-tab session invalidation
`AuthService` (constructed once at root) installs a single
`BroadcastChannel('hipctf.auth.v1')` and a `localStorage` `storage`-event
fallback. The shape of the broadcast payload is defined in
`auth-session-events.pure.ts` and contains only `{ type, origin, ts }` — no
tokens or user data. `clearAndBroadcast()` is invoked from `logout()` and
from the SSE unauthorized callback; peer listeners clear their local state
without rebroadcasting. `HomeComponent` subscribes via
`auth.onPeerInvalidation(...)`, resets `UserStore`, closes any open shell
overlays, and navigates to `/login`. See
[Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md)
for the full contract and tester matrix.
# See also # See also
- [Authenticated Shell](/guides/authenticated-shell.md) - [Authenticated Shell](/guides/authenticated-shell.md)
+9 -6
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index title: Key Files Index
description: One-line responsibility for important source files, including authenticated event streaming. description: One-line responsibility for important source files, including authenticated event streaming.
tags: [architecture, index, key-files] tags: [architecture, index, key-files]
timestamp: 2026-07-22T09:35:00Z timestamp: 2026-07-22T10:32:00Z
--- ---
# Backend # Backend
@@ -31,14 +31,17 @@ timestamp: 2026-07-22T09:35:00Z
| `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. | | `frontend/src/app/app.routes.ts` | Defines public, shell, child, and admin routes. |
| `frontend/src/app/app.component.ts` | Loads bootstrap data and restores the session. | | `frontend/src/app/app.component.ts` | Loads bootstrap data and restores the session. |
| `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. |
| `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token and current-user state. | | `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/authenticated-event-source.service.ts` | Fetch-based authenticated SSE transport with frame parsing and abort support. | | `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/event-status.store.ts` | Event state signal store and one-second countdown timer. | | `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/event-status.pure.ts` | Event payload types, pure countdown helpers, and the `LED_COLOR_BY_STATE` map used by the shell LED. | | `frontend/src/app/core/services/event-status.store.ts` | Event state signal store, one-second countdown timer, and the optional `onUnauthorized` callback wiring. |
| `frontend/src/app/core/services/event-status.pure.ts` | Event payload types, transport interface (including `'unauthorized'` listener), pure countdown helpers, 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/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. |
| `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization and frame transport behavior. | | `tests/frontend/authenticated-event-source.spec.ts` | Tests SSE authorization, frame transport behavior, and the `401`/`403` unauthorized path. |
| `tests/frontend/auth-session-events.spec.ts` | Pure tests for cross-tab invalidation message encoding, payload validation, and `storage`-event filtering. |
# See also # See also
+16
View File
@@ -106,6 +106,22 @@ error event. The shell retains the last event state until a later connection suc
1. Click each quick tab and confirm the matching route and active styling. 1. Click each quick tab and confirm the matching route and active styling.
2. Open the user menu and click **Logout**. 2. Open the user menu and click **Logout**.
3. Confirm navigation to `/login` and that the event stream is closed. 3. Confirm navigation to `/login` and that the event stream is closed.
4. With **two open tabs** of the same browser profile both on authenticated
routes, log out in one. Confirm the *other* tab also navigates to `/login`
without any user interaction. See
[Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md)
for the full tester matrix (peer logout, SSE unauthorized, negative cases).
## Unauthorized SSE response
1. Sign in once and open the authenticated shell.
2. Trigger an unauthorized response on `/api/v1/events/status` (e.g. by
revoking the refresh cookie via an admin tool or by expiring the session
server-side).
3. Confirm the shell clears, the event stream is closed, and the router
navigates to `/login`. The same flow in another open tab is propagated
via the cross-tab broadcast — see
[Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md).
# See also # See also
+204
View File
@@ -0,0 +1,204 @@
---
type: guide
title: Cross-Tab Authenticated Shell Invalidation
description: How logging out in one tab, or receiving an unauthorized SSE response, instantly logs out and redirects every other open tab of the SPA.
tags: [guide, auth, session, broadcast-channel, storage-event, sse, tester]
timestamp: 2026-07-22T10:32:00Z
---
# What this feature does
Before this change, `sessionStorage` was tab-scoped, so logging out in **tab A**
left any other open tabs (`/challenges`, `/scoreboard`, `/blog`, `/admin`)
**still rendering authenticated content** until they were individually closed or
reloaded. The same gap existed when the server returned `401` to the
authenticated `/api/v1/events/status` SSE stream: the offending tab knew it was
unauthenticated, but the others did not.
This change adds a same-browser **cross-tab session-invalidation channel**:
1. `AuthService` now owns a single `BroadcastChannel('hipctf.auth.v1')` plus a
`localStorage` `storage`-event fallback, registered in its constructor and
torn down on `DestroyRef`.
2. Every confirmed local session invalidation (manual `logout()`, SSE
`401`/`403`) clears state **and publishes** a fixed-shape
`session-invalidated` message containing only the broadcasting tab's
`origin` (random per-tab UUID) and a `ts` timestamp. No tokens, users, or
storage contents are broadcast.
3. Each tab listens for the message. If the `origin` differs from its own, it
treats the notification as a peer-logout: it clears its in-memory and
`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.
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.
# Why a new concept doc
The change spans the auth core, the SSE transport, the event-status store, and
the shell component. It introduces a new pure module
(`auth-session-events.pure.ts`) and a new transport event type
(`'unauthorized'` on `EventSourceLike`). A tester reading the
[Authenticated Shell](/guides/authenticated-shell.md) guide should not have to
re-derive the cross-tab propagation rules, and a developer wiring a new
service should not have to read the auth service diff to learn about the
broadcast contract.
# Tester flows
> Open the SPA in **two browser tabs** of the same browser profile, signed in
> as the same user. Both tabs must be on authenticated routes (`/`,
> `/challenges`, `/scoreboard`, `/blog`, or `/admin`).
## Peer logout (manual)
1. Sign in once and navigate to `/challenges` in **Tab A**.
2. Duplicate the tab — **Tab B** opens at `/scoreboard`.
3. In **Tab A**, open the user menu (`[data-testid="user-menu-trigger"]`)
and click **Logout**.
4. **Tab A** navigates to `/login` (unchanged behavior).
5. **Tab B** clears its shell state without any user interaction and navigates
to `/login`. The event LED, countdown, and quick-tab strip are no longer
visible — the landing/login view is shown.
6. Confirm in DevTools → Application → Session Storage of **Tab B** that the
`hipctf.auth.v1` key has been removed.
## Peer logout (SSE unauthorized)
1. Sign in once and open the SPA in two tabs.
2. In **Tab A** (server side, via an admin-only tool or by manually
revoking the refresh cookie), invalidate the refresh-cookie/session.
3. Wait for `EventStatusStore` to reconnect — the SSE fetch returns `401`.
4. **Tab A** clears state and navigates to `/login`.
5. **Tab B** (still on `/challenges`) clears state and navigates to `/login`
without any SSE retry happening locally — it received the broadcast from
Tab A.
> If you instead trigger the unauthorized response in **Tab B** directly
> (e.g. by expiring the refresh cookie for that tab only), Tab B navigates
> to `/login`; Tab A is unaffected.
## Negative cases
| Action | Expected |
|---|---|
| Open the SPA in a private/incognito window in addition to a normal window | Logging out in one does **not** affect the other. `BroadcastChannel` and the `storage` event are scoped to the browser profile. |
| Trigger a generic network failure on `/api/v1/events/status` | The store dispatches `'error'`, retries, and **does not** invalidate. The shell keeps the last known event state. |
| A non-HIPCTF tab on the same browser profile writes `hipctf.auth.invalidate.v1` to `localStorage` with garbage | The payload validator (`isCrossTabInvalidationEvent`) ignores it — no state change. |
| Same tab echoes its own broadcast (test environment that delivers self-messages) | The `origin === crossTabOrigin` guard prevents self-processing and rebroadcast loops. |
# Visual / interactive elements
There are **no new UI controls**. Existing selectors continue to work and
should be exercised after a peer invalidation:
| Element | Selector | After peer invalidation |
|---|---|---|
| Shell header | `[data-testid="shell-header"]` | Removed from DOM by `Router` (route changed to `/login`). |
| Quick tabs | `[data-testid="quick-tabs"]` | Removed from DOM by `Router`. |
| User menu | `[data-testid="user-menu-trigger"]` | Removed from DOM by `Router`. |
| Event LED / countdown | `[data-testid="shell-led"]`, `[data-testid="shell-countdown"]` | Removed from DOM by `Router`. |
| Login form | `[data-testid="login-submit"]` | Becomes visible (already-tested flow). |
# How it works (developer map)
## Pure contract module
`frontend/src/app/core/services/auth-session-events.pure.ts` is a
framework-light, testable module that defines:
| Export | Purpose |
|---|---|
| `CROSS_TAB_CHANNEL_NAME = 'hipctf.auth.v1'` | Stable `BroadcastChannel` name. |
| `CROSS_TAB_STORAGE_KEY = 'hipctf.auth.invalidate.v1'` | `localStorage` fallback key. |
| `CROSS_TAB_EVENT_TYPE = 'hipctf.session.invalidated'` | Marker used by the discriminated-union guard. |
| `CrossTabInvalidationMessage` | `{ type: 'session-invalidated'; origin: string; ts: number }`. |
| `encodeInvalidationMessage(origin)` | Builds a message stamped with `Date.now()`. |
| `isCrossTabInvalidationMessage(value)` | Type-guard used by both `BroadcastChannel` and `storage` listeners. |
| `isStorageInvalidationEvent(event)` | Guards `storage` events: only accepts the namespaced key with a parseable payload. |
The payload intentionally carries **no** access token, refresh token, user id,
or `sessionStorage` content.
## `AuthService` wiring
`frontend/src/app/core/services/auth.service.ts`
* Constructor calls `installCrossTabListener()`, which:
* Feature-detects `BroadcastChannel`; if present, opens it on
`CROSS_TAB_CHANNEL_NAME` and subscribes to `'message'`.
* Always subscribes a `'storage'` listener filtered through
`isStorageInvalidationEvent`.
* `clearAndBroadcast()` is the new public invalidation entry point: calls the
existing `clear()` (which wipes `sessionStorage`), and if
`suppressBroadcast` is `false`, publishes a fresh `encodeInvalidationMessage`
on both transports.
* `logout()` now ends with `clearAndBroadcast()` instead of `clear()`.
* `handleSseUnauthorized()` calls `clearAndBroadcast()` and then synchronously
notifies peer-invalidation subscribers with the reason
`'sse-unauthorized'` so the originating tab can navigate immediately.
* `onPeerInvalidation(cb)` registers a subscriber; returns an unsubscribe
closure. Used by `HomeComponent`.
* `handlePeerInvalidation()` is the single fan-in for both transport sources:
it parses the payload, drops self-origin messages, drops messages when the
tab is already unauthenticated, and runs `clear()` under
`suppressBroadcast = true` so the listener does not echo.
* `DestroyRef.onDestroy` calls `teardownCrossTabListener()` which removes the
`'message'` and `'storage'` listeners and closes the channel.
## Authenticated SSE: `unauthorized` event
`frontend/src/app/core/services/authenticated-event-source.service.ts`
The fetch path now inspects `res.status` *before* the `ok`/`body` check. If
the status is `401` or `403`, the service dispatches a new `'unauthorized'`
`Event` and returns; the response body is **not** read and no frame parsing
runs.
`frontend/src/app/core/services/event-status.pure.ts` widens
`EventSourceLike.addEventListener` to accept `'open' | 'message' | 'error' |
'unauthorized'`.
`frontend/src/app/core/services/event-status.store.ts`
`EventStatusStore.start(createSource, onUnauthorized?)` now accepts an
optional second argument. When provided, the store attaches an
`'unauthorized'` listener that invokes `onUnauthorized()` and is detached by
`stop()` (which is already idempotent). Generic `'error'` events remain
unhandled by the store; the SPA does not navigate on transport failure.
## Shell reaction
`frontend/src/app/features/home/home.component.ts`
* `ngOnInit()` passes a second callback to `eventStatus.start(...)` that
delegates to `handleSessionInvalidated('sse-unauthorized')`.
* It also subscribes to `auth.onPeerInvalidation(...)` and stores the
unsubscribe handle on `peerInvalidationUnsubscribe`.
* `ngOnDestroy()` calls `eventStatus.stop()` and unsubscribes from
`onPeerInvalidation`.
* `handleSessionInvalidated(reason)`:
* Calls `userStore.reset()` and closes `changePasswordOpen` /
`userMenuOpen` modal signals.
* Guards re-entry with `navigatingToLogin` and short-circuits when the
router is already on `/login`.
* Otherwise awaits `router.navigateByUrl('/login')`.
## Test coverage
| File | What it pins down |
|---|---|
| `tests/frontend/auth-session-events.spec.ts` | Pure payload validation, `storage`-event filtering, `encodeInvalidationMessage` shape. |
| `tests/frontend/authenticated-event-source.spec.ts` | Extended: `401`/`403` dispatches `'unauthorized'`; generic errors still dispatch `'error'`; no body is read on the unauthorized path. |
# See also
- [Authenticated Shell](/guides/authenticated-shell.md)
- [Session Restoration on Reload](/guides/session-restoration.md)
- [Frontend Structure](/architecture/frontend-structure.md)
- [REST API Overview](/api/rest-overview.md)
- [Auth Endpoints](/api/auth.md)
+4 -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-22T09:35:00Z. they need. Last regenerated 2026-07-22T10:32:00Z.
# Architecture # Architecture
@@ -65,6 +65,9 @@ they need. Last regenerated 2026-07-22T09:35:00Z.
* [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.
* [Cross-Tab Authenticated Shell Invalidation](/guides/cross-tab-invalidation.md) -
How a logout in one tab, or an unauthorized SSE response, instantly clears
and redirects every other open tab of the SPA.
* [Session Restoration on Reload](/guides/session-restoration.md) - How * [Session Restoration on Reload](/guides/session-restoration.md) - How
the SPA keeps a user signed in across hard refreshes and how the the SPA keeps a user signed in across hard refreshes and how the
guards wait for bootstrap + auth hydration before deciding where to guards wait for bootstrap + auth hydration before deciding where to