docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-21 15:32:48 +00:00
parent 960516a7bc
commit 5499e30f95
5 changed files with 173 additions and 19 deletions
+21 -8
View File
@@ -25,7 +25,7 @@ All components are standalone (no NgModules). Each component imports
| Component | Path | Purpose | | Component | Path | Purpose |
|------------------------|-----------------------------------------------------------------|-------------------------------------------------| |------------------------|-----------------------------------------------------------------|-------------------------------------------------|
| `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>` and calls `BootstrapService.load()`. | | `AppComponent` | `frontend/src/app/app.component.ts` | Root; renders `<router-outlet>`, calls `BootstrapService.load()` then `AuthService.restoreSession()`. |
| `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. | | `HomeComponent` | `frontend/src/app/features/home/home.component.ts` | Authenticated shell with header, conditional admin nav, and `<router-outlet>` for children. |
| `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. | | `AdminUsersComponent` | `frontend/src/app/features/admin/admin-users.component.ts` | Admin-only user list rendered inside the home shell. |
| `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. | | `LoginComponent` | `frontend/src/app/features/auth/login.component.ts` | Username/password form. |
@@ -35,16 +35,18 @@ All components are standalone (no NgModules). Each component imports
| Service | Path | Purpose | | Service | Path | Purpose |
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| |---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
| `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user. | | `AuthService` | `frontend/src/app/core/services/auth.service.ts` | Signal-backed access token + current user; persists session to `sessionStorage` and exposes `restoreSession()` + `waitUntilHydrated()`. |
| `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS. | | `BootstrapService` | `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; exposes `ready()` that deduplicates the in-flight load. |
| `AuthSessionStorage` (helpers) | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `readStoredSession` / `writeStoredSession` / `clearStoredSession` against `sessionStorage` (key `hipctf.auth.v1`). |
| `AdminService` | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (typed `AdminUser[]`). | | `AdminService` | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` (typed `AdminUser[]`). |
# Guards and interceptors # Guards and interceptors
| Symbol | Path | Purpose | | Symbol | Path | Purpose |
|---------------------|---------------------------------------------------------------|-----------------------------------------------------------| |---------------------|---------------------------------------------------------------|-----------------------------------------------------------|
| `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Redirects to `/bootstrap` when uninitialized, `/login` when not authenticated. | | `authGuard` | `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits `BootstrapService.ready()` and `AuthService.waitUntilHydrated()`, then delegates to `decideAuthRedirect`. |
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. | | `decideAuthRedirect`| `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function mirroring `decideAdminGuard`; redirects to `/bootstrap` or `/login`, returns `true` otherwise. |
| `adminGuard` | `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, then delegates to `decideAdminGuard` (pure). Redirects to `/bootstrap`, `/login`, or `/` based on state; allows only admins. |
| `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. | | `decideAdminGuard` | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function: returns `{kind:'allow'}` or `{kind:'redirect', path}`. |
| `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. | | `authInterceptor` | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches `Authorization: Bearer <accessToken>` header. |
| `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. | | `csrfInterceptor` | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. |
@@ -55,7 +57,10 @@ Both interceptors are wired in `frontend/src/main.ts` via
# Bootstrap flow # Bootstrap flow
1. `AppComponent.ngOnInit()``BootstrapService.load()`. 1. `AppComponent.ngOnInit()``BootstrapService.load()` then
`AuthService.restoreSession()`. Both are deduplicated so concurrent
callers share a single in-flight promise (`loadPromise` /
`hydrateWaiters`).
2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with 2. `BootstrapService` calls `GET /api/v1/bootstrap` (public, with
`credentials: 'include'`). `credentials: 'include'`).
3. The payload sets `initialized` (true iff any admin user exists), 3. The payload sets `initialized` (true iff any admin user exists),
@@ -64,8 +69,16 @@ Both interceptors are wired in `frontend/src/main.ts` via
4. Theme tokens are written to CSS custom properties on 4. Theme tokens are written to CSS custom properties on
`document.documentElement` (`--color-primary`, `--font-family`, `document.documentElement` (`--color-primary`, `--font-family`,
`--radius-*`, etc.) consumed by `frontend/src/styles.css`. `--radius-*`, etc.) consumed by `frontend/src/styles.css`.
5. The `authGuard` reads `BootstrapService.initialized()` and either 5. `AuthService.restoreSession()` rehydrates the in-memory signals from
allows navigation or redirects to `/bootstrap`. `sessionStorage` (key `hipctf.auth.v1`) and POSTs
`/api/v1/auth/refresh` with `withCredentials: true` to mint a fresh
access token. On success it persists the new token; on failure it
clears storage. Either way it flips `hydrated()` so the guards can
unblock.
6. The `authGuard` (and `adminGuard`) `await` `BootstrapService.ready()`
and `AuthService.waitUntilHydrated()` before reading `initialized()`
/ `isAuthenticated()`, which prevents a flash of `/bootstrap` or
`/login` on a hard refresh of a deep link.
# Home shell flow # Home shell flow
+9 -5
View File
@@ -106,13 +106,15 @@ timestamp: 2026-07-21T15:05:00Z
| `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. | | `frontend/src/main.ts` | Bootstraps the standalone Angular app + registers interceptors. |
| `frontend/src/index.html` | Root HTML shell. | | `frontend/src/index.html` | Root HTML shell. |
| `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. | | `frontend/src/styles.css` | Global styles consuming theme CSS custom properties. |
| `frontend/src/app/app.component.ts` | Root component; triggers bootstrap on init. | | `frontend/src/app/app.component.ts` | Root component; triggers bootstrap then `AuthService.restoreSession()` on init. |
| `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). | | `frontend/src/app/app.routes.ts` | Angular route table (declares `/` shell with `adminGuard`-gated `/admin` child). |
| `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user. | | `frontend/src/app/core/services/auth.service.ts` | Signal store for access token + current user; persists to `sessionStorage`, exposes `restoreSession()` and `waitUntilHydrated()`. |
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap` and applies theme tokens to CSS. | | `frontend/src/app/core/services/auth.session-storage.ts` | Pure `sessionStorage` helpers (`readStoredSession`, `writeStoredSession`, `clearStoredSession`) under key `hipctf.auth.v1`. |
| `frontend/src/app/core/services/bootstrap.service.ts` | Fetches `/api/v1/bootstrap`, applies theme tokens to CSS; `load()` / `ready()` deduplicate the in-flight fetch. |
| `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. | | `frontend/src/app/core/services/admin.service.ts` | `GET /api/v1/admin/users` returning `AdminUser[]`. |
| `frontend/src/app/core/guards/auth.guard.ts` | `CanActivateFn` checking init + auth. | | `frontend/src/app/core/guards/auth.guard.ts` | Async `CanActivateFn`; awaits bootstrap + auth hydration, delegates to `decideAuthRedirect`. |
| `frontend/src/app/core/guards/admin.guard.ts` | `CanActivateFn` for admin-only routes; delegates to `decideAdminGuard`. | | `frontend/src/app/core/guards/auth.guard.decision.ts` | Pure decision function for the auth guard (returns `true` or a redirect `UrlTree`). |
| `frontend/src/app/core/guards/admin.guard.ts` | Async `CanActivateFn` for admin-only routes; awaits bootstrap + auth hydration, delegates to `decideAdminGuard`. |
| `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). | | `frontend/src/app/core/guards/admin.guard.decision.ts` | Pure decision function for the admin guard (returns `allow` / `redirect`). |
| `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. | | `frontend/src/app/core/interceptors/auth.interceptor.ts` | Attaches Bearer header. |
| `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. | | `frontend/src/app/core/interceptors/csrf.interceptor.ts` | Attaches `X-CSRF-Token` on unsafe methods. |
@@ -132,6 +134,8 @@ timestamp: 2026-07-21T15:05:00Z
| `tests/frontend/theme.spec.ts` | Jest test for theme token application. | | `tests/frontend/theme.spec.ts` | Jest test for theme token application. |
| `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. | | `tests/frontend/admin-shell.spec.ts` | Unit tests for `shouldShowAdminNav` predicate. |
| `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. | | `tests/frontend/admin-navigation.spec.ts` | Tests for admin guard decision + nav-link rendering. |
| `tests/frontend/auth-restore.spec.ts` | Round-trips `auth.session-storage` helpers + refresh success/failure paths. |
| `tests/frontend/guard-bootstrap-race.spec.ts` | Pins `decideAuthRedirect` so a refresh on a deep link never flashes `/bootstrap` or `/login` while auth is hydrating. |
| `tests/backend/csrf-client.ts` | Test helper for CSRF flow. | | `tests/backend/csrf-client.ts` | Test helper for CSRF flow. |
| `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. | | `tests/backend/db-helper.ts` | Test helper for spinning up an isolated DB. |
+12 -5
View File
@@ -64,16 +64,23 @@ Browser ──HTTPS──▶ NestJS process (PORT, default 3000)
attaches the CSRF header on unsafe methods and the Bearer access attaches the CSRF header on unsafe methods and the Bearer access
token on every request. token on every request.
2. `provideRouter(APP_ROUTES, withComponentInputBinding())`. 2. `provideRouter(APP_ROUTES, withComponentInputBinding())`.
3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` which 3. `AppComponent.ngOnInit()` calls `BootstrapService.load()` then
fetches `GET /api/v1/bootstrap` and applies the returned theme tokens `AuthService.restoreSession()`. Bootstrap fetches `GET /api/v1/bootstrap`
to CSS custom properties on `document.documentElement`. and applies the returned theme tokens to CSS custom properties on
`document.documentElement`. Session restore rehydrates the in-memory
auth signals from `sessionStorage` and posts `POST /api/v1/auth/refresh`
with `withCredentials: true` so a hard refresh does not log the user out.
4. Guards (`authGuard`, `adminGuard`) are async and `await`
`BootstrapService.ready()` + `AuthService.waitUntilHydrated()` before
reading state, so refreshing on a deep link never flashes `/bootstrap`
or `/login`.
# Cross-cutting concepts # Cross-cutting concepts
| Concern | Backend | Frontend | | Concern | Backend | Frontend |
|----------------|------------------------------------------------------|---------------------------------------------| |----------------|------------------------------------------------------|---------------------------------------------|
| Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor` | | Authentication | `JwtAuthGuard` (global) + `AuthGuard('jwt')` strategy | `AuthService` signal + `authInterceptor`; session is mirrored to `sessionStorage` and rehydrated on boot via `/api/v1/auth/refresh` |
| Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` for `/`, `adminGuard` for `/admin` (delegates to pure `decideAdminGuard`) | | Authorization | `AdminGuard` + `@Roles('admin')` decorator | `authGuard` for `/`, `adminGuard` for `/admin` (both async, both delegate to pure decision functions: `decideAuthRedirect` / `decideAdminGuard`) |
| CSRF | `CsrfMiddleware` (skips `/api/v1/auth/login` and `/api/v1/auth/register-first-admin`) | `csrfInterceptor` reads `csrf` cookie | | CSRF | `CsrfMiddleware` (skips `/api/v1/auth/login` and `/api/v1/auth/register-first-admin`) | `csrfInterceptor` reads `csrf` cookie |
| Errors | `ApiError``GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` | | Errors | `ApiError``GlobalExceptionFilter` returns `{code, message, details, path, timestamp}` | Components display `error?.error?.message` |
| Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) | | Config | `envSchema` (zod) + `validateEnv` | None (consumed via bootstrap) |
+126
View File
@@ -0,0 +1,126 @@
---
type: guide
title: Session Restoration on Reload
description: How the SPA keeps a user signed in across page reloads and how the guards wait for bootstrap + auth hydration before deciding where to route.
tags: [guide, auth, session, restoration, refresh, tester]
timestamp: 2026-07-21T15:31:00Z
---
# What this feature does
Before this change, refreshing the tab while signed in logged the user out
(because the access JWT was only held in memory). Now the SPA:
1. Writes the access token + current user to **`sessionStorage`** on every
`setSession()` / `clear()`.
2. On every app boot, attempts to **silently refresh** the session by
`POST /api/v1/auth/refresh` (the `rt` HttpOnly cookie is sent
automatically).
3. Guards (`authGuard`, `adminGuard`) **wait** until both the bootstrap
payload and the auth hydration step finish before deciding where to
redirect — so refreshing on a deep link no longer sends the user
to `/bootstrap` or `/login` while data is still in flight.
# Where the user sees it (tester perspective)
| Scenario | Expected outcome |
|-------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Reload the tab while signed in as an admin | The home shell (`/`) re-renders, the admin nav link is visible, no redirect through `/login` or `/bootstrap`. |
| Reload while signed in as a player | The home shell (`/`) re-renders, no admin nav link. |
| Reload while on `/admin` as an admin | The admin user list renders without redirecting away. |
| Reload while signed in but the refresh cookie expired | The stored session is cleared, the user is redirected to `/login`. |
| Reload while on a deep link (e.g. `/admin`) | The app waits for bootstrap + auth hydration, then navigates directly. No flash of `/login` or `/bootstrap`. |
| Open `/bootstrap` in the URL once an admin exists | The setup component detects `initialized === true` and redirects away (to `/` when authenticated, otherwise `/login`). |
# Visual / interactive elements
There are **no new UI controls**. The existing flows (login, home shell,
admin nav) simply stop flickering through `/bootstrap` or `/login` when
the session is still valid.
Key selectors that should still work (regression targets):
| Element | Selector | Notes |
|--------------------|-------------------------------------|----------------------------------------------------|
| Admin nav link | `[data-testid="nav-admin"]` | Re-renders after hydration; not visible on `/login` or `/bootstrap`. |
| Login form | `[data-testid="login-*"]` | Reachable only when `initialized && !isAuthenticated`. |
| Setup modal | `[data-testid="setup-submit"]` etc. | Reachable only when `!initialized`. |
# How it works (developer map)
## On every successful auth (`setSession`)
`AuthService.setSession()` (`frontend/src/app/core/services/auth.service.ts:42`)
now also writes `{ token, user }` to `sessionStorage` under the key
`hipctf.auth.v1` via `writeStoredSession()` from
`frontend/src/app/core/services/auth.session-storage.ts`. `clear()`
removes the key via `clearStoredSession()`.
## On app boot (`AppComponent.ngOnInit`)
`AppComponent.ngOnInit()` (`frontend/src/app/app.component.ts:13`)
performs:
```ts
await this.bootstrap.load();
await this.auth.restoreSession();
```
`AuthService.restoreSession()`:
1. Rehydrates the in-memory signals from `sessionStorage`
(`restoreFromStorage()`) — the UI can render immediately.
2. `POST /api/v1/auth/refresh` with `{ withCredentials: true }` so the
`rt` cookie is sent.
3. On success: replaces the stored session with the fresh token + user,
flips `hydrated.set(true)`, resolves any `waitUntilHydrated()` callers.
4. On failure (network or 401): calls `clear()` (which also wipes
`sessionStorage`) and still flips `hydrated.set(true)` so guards can
unblock and route the user to `/login`.
## Guard synchronization (race fix)
Both `authGuard` (`frontend/src/app/core/guards/auth.guard.ts`) and
`adminGuard` (`frontend/src/app/core/guards/admin.guard.ts`) are now
`async` and **await**:
```ts
await bootstrap.ready(); // deduplicated in-flight promise
await auth.waitUntilHydrated(); // resolves once restoreSession() finishes
```
* `BootstrapService.load()` and `BootstrapService.ready()`
(`frontend/src/app/core/services/bootstrap.service.ts`) share a single
`loadPromise` so concurrent callers don't double-fetch `/api/v1/bootstrap`.
* `AuthService.waitUntilHydrated()` returns a promise that resolves when
`hydrated()` flips to `true` (or immediately if already hydrated).
## Pure decision functions
* `decideAuthRedirect` (`frontend/src/app/core/guards/auth.guard.decision.ts`)
is the pure unit-testable predicate matching the existing
`decideAdminGuard` pattern. Inputs: `{ initialized, isAuthenticated }`.
Outputs: `true` or a router `UrlTree`.
* The `CanActivateFn` `authGuard` now just calls the pure function.
## Setup component redirect
`SetupCreateAdminComponent` (`frontend/src/app/features/setup/setup-create-admin.component.ts:36`)
runs an Angular `effect()` watching `bootstrap.initialized()`. If a user
navigates directly to `/bootstrap` on an already-initialized instance,
the effect pushes them to `/` (when authenticated) or `/login`
(otherwise).
# Tests
| File | What it pins down |
|-------------------------------------------------|-----------------------------------------------------------------------------------------|
| `tests/frontend/auth-restore.spec.ts` | `auth.session-storage` round-trips; refresh-success writes new token; refresh-failure clears storage. |
| `tests/frontend/guard-bootstrap-race.spec.ts` | `decideAuthRedirect` redirects to `/bootstrap` while uninitialized, to `/login` when init'd but unauth'd, and **allows** when both init + auth are true (no `/bootstrap` redirect). |
# See also
- [Frontend Structure](/architecture/frontend-structure.md)
- [REST API Overview](/api/rest-overview.md)
- [First-Run Bootstrap](/guides/bootstrap.md)
- [Admin Shell & User Management](/guides/admin-shell.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. they need. Last regenerated 2026-07-21.
# Architecture # Architecture
@@ -54,6 +54,10 @@ they need.
initialized by the very first admin via the non-dismissible modal. initialized by the very first admin via the non-dismissible modal.
* [Admin Shell & User Management](/guides/admin-shell.md) - How admins * [Admin Shell & User Management](/guides/admin-shell.md) - How admins
navigate the post-login shell and reach the user-management area. navigate the post-login shell and reach the user-management area.
* [Session Restoration on Reload](/guides/session-restoration.md) - How
the SPA keeps a user signed in across hard refreshes and how the
guards wait for bootstrap + auth hydration before deciding where to
route.
* [Theming](/guides/theming.md) - How themes are loaded and applied. * [Theming](/guides/theming.md) - How themes are loaded and applied.
* [Event Window](/guides/event-window.md) - How the event window and live * [Event Window](/guides/event-window.md) - How the event window and live
countdown work. countdown work.