docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-21 22:26:44 +00:00
parent 8dc8cee769
commit e5720c66dc
11 changed files with 727 additions and 122 deletions
+104 -16
View File
@@ -1,9 +1,9 @@
---
type: api
title: Auth Endpoints
description: Login, refresh, logout, public self-registration, CSRF, first-admin registration, and registration throttling.
tags: [api, auth, login, register, refresh, csrf]
timestamp: 2026-07-21T18:28:00Z
description: Login, refresh, logout, public self-registration, CSRF, /me, change-password, first-admin registration, and registration throttling.
tags: [api, auth, login, register, refresh, csrf, me, change-password]
timestamp: 2026-07-21T22:19:08Z
---
# Endpoints
@@ -14,6 +14,8 @@ timestamp: 2026-07-21T18:28:00Z
| `POST` | `/api/v1/auth/login` | `@Public()` | Enforced — same pattern as `register`. CSRF skip was removed in this change. | Yes — `LoginBackoffService` per IP + username | `backend/src/modules/auth/auth.controller.ts` |
| `POST` | `/api/v1/auth/refresh` | Public route, requires a valid refresh cookie. | Skipped (the request is a `POST` carrying an HttpOnly cookie, not a state-changing user action). | No | `backend/src/modules/auth/auth.controller.ts` |
| `POST` | `/api/v1/auth/logout` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` |
| `GET` | `/api/v1/auth/me` | Authenticated (JWT). | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` |
| `POST` | `/api/v1/auth/change-password` | Authenticated (JWT). | Enforced. | No | `backend/src/modules/auth/auth.controller.ts` |
| `GET` | `/api/v1/auth/csrf` | `@Public()` | n/a (`GET`). | No | `backend/src/modules/auth/auth.controller.ts` |
| `POST` | `/api/v1/auth/register-first-admin` | `@Public()` | Skipped (path is in `CsrfMiddleware.SKIP_PATH_PREFIXES`). | Yes | `backend/src/modules/users/users.controller.ts` |
@@ -64,27 +66,103 @@ The refresh token is set as an `HttpOnly` cookie via `setRefreshCookie(res, sess
* `AuthService.registerFirstAdmin(username, password, ip)` uses the same limiter before checking whether an administrator already exists.
* The limiter is an in-memory Nest provider in `backend/src/common/services/registration-rate-limit.service.ts`; counters are process-local and reset when the API process restarts.
# `GET /api/v1/auth/me`
Authenticated-only projection of the current user, including derived rank
and total points from the `solve` table.
| HTTP | `code` | When |
|------|-----------------|-----------------------------------------------------|
| 200 | — | Returns `{ id, username, role, rank, points }`. |
| 401 | `UNAUTHORIZED` | No JWT on the request (handled by `JwtAuthGuard`). |
## Successful response (200)
```json
{
"id": "<uuid>",
"username": "player1",
"role": "player",
"rank": 3,
"points": 275
}
```
* `rank` is `null` while the user has 0 points (no rank assigned).
* `points` is `SUM(solve.pointsAwarded)` for that user.
* `rank` is `1 + (count of users with strictly higher points total)`.
## Wiring
`AuthService.getMe(userId)` (`backend/src/modules/auth/auth.service.ts`)
loads the `UserEntity` then delegates the rank/points math to
`UsersRankService.rankOfUser(manager, userId)`
(`backend/src/modules/users/users-rank.service.ts`).
# `POST /api/v1/auth/change-password`
Authenticated-only password change. Requires the current password
(`mode='self'`); the admin-reset variant lives in a later Job.
| Field | Type | Constraints |
|-----------------------|--------|-----------------------------------------------------------------------------------|
| `oldPassword` | string | 1256 chars. Required when `mode='self'`. |
| `newPassword` | string | 1256 chars; passes `validatePassword` (Argon2 policy) AND must differ from old. |
| `confirmNewPassword` | string | Must equal `newPassword` (zod refine → `VALIDATION_FAILED`). |
Validated by `ChangePasswordDtoSchema` in `backend/src/modules/auth/dto/auth.dto.ts`.
## Successful response (204)
No body. The new password hash is persisted inside a `DataSource.transaction`
that also revokes every active `refresh_token` row for the user
(`revokedAt = now`), forcing the user to re-authenticate on other devices.
## Error envelope
| HTTP | `code` | When |
|------|---------------------------|----------------------------------------------------------------------------------------|
| 400 | `VALIDATION_FAILED` | zod body validation failed. |
| 400 | `PASSWORDS_DO_NOT_MATCH` | `newPassword !== confirmNewPassword`. |
| 400 | `PASSWORD_POLICY` | New password equals the old, or fails the Argon2 policy (`PASSWORD_MIN_LENGTH`, mixed-case requirement). |
| 401 | `UNAUTHORIZED` | No JWT on the request. |
| 401 | `INVALID_OLD_PASSWORD` | `argon2.verify(oldPasswordHash, oldPassword)` failed. |
## Wiring
`AuthService.changePassword(userId, dto)` runs inside a single
`DataSource.transaction`:
1. Validate `newPassword === confirmNewPassword``PASSWORDS_DO_NOT_MATCH`.
2. Reject `oldPassword === newPassword``PASSWORD_POLICY`.
3. `argon2.verify(user.passwordHash, dto.oldPassword)``INVALID_OLD_PASSWORD` on miss.
4. `validatePassword(dto.newPassword, config)` → rethrown as `PASSWORD_POLICY` on failure.
5. Re-hash with Argon2id and `manager.save(user)`.
6. UPDATE all `refresh_token` rows for the user where `revokedAt IS NULL`.
# Key Files
| File | Responsibility |
|------|----------------|
| `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, and mints sessions. |
| `backend/src/modules/auth/auth.service.ts` | Validates registration policy, applies the limiter, creates users, mints sessions, exposes `getMe()` and `changePassword()`. |
| `backend/src/common/services/registration-rate-limit.service.ts` | Tracks timestamps and enforces the 10-per-minute per-IP limit. |
| `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes. |
| `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration request fields. |
| `backend/src/modules/auth/auth.controller.ts` | Exposes public registration and authentication routes plus authenticated `/me` and `/change-password`. |
| `backend/src/modules/auth/dto/auth.dto.ts` | Validates registration, login, refresh, `/me`, and `change-password` request fields. |
| `backend/src/modules/users/users-rank.service.ts` | Pure provider computing `{ rank, points }` over the `solve` table for a user. |
| `backend/src/common/errors/error-codes.ts` | Canonical `ERROR_CODES` (now includes `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`, `PASSWORDS_DO_NOT_MATCH`). |
# Frontend consumer
`frontend/src/app/core/services/auth.service.ts` exposes `login()` and
`register()`. Both:
1. Call `ensureCsrf()``GET /api/v1/auth/csrf` to materialise the `csrf`
cookie if it isn't set yet.
2. `POST` with `{ withCredentials: true, observe: 'response' }`.
3. Return success or the standard [error envelope](/api/rest-overview.md).
The `LandingComponent` consumes both methods and maps failures to user-facing
copy (see [Landing Page](/guides/landing-page.md)).
`frontend/src/app/core/services/auth.service.ts` exposes `login()`,
`register()`, `me()`, `changePassword()`, `logout()`, and
`restoreSession()`. Each writes through the existing CSRF + auth
interceptors and returns a discriminated result type (`LoginResult`,
`RegisterResult`, `ChangePasswordResult`) for component-level error
handling. The authenticated shell's
[change-password modal](/guides/change-password.md) consumes
`changePassword()` and maps failures via
`formatChangePasswordError()` in
`frontend/src/app/features/shell/change-password/password-feedback.ts`.
# Examples
@@ -96,8 +174,18 @@ copy (see [Landing Page](/guides/landing-page.md)).
4. The first 10 attempts are processed normally; the 11th within 60 seconds returns HTTP `429` with code `RATE_LIMITED`.
5. After the rolling window expires, a new attempt is accepted if other requirements pass.
## Tester flow: change-password happy path
1. Sign in via `/login`.
2. Click the username dropdown in the shell header → "Change password".
3. Enter the current password, a new password that meets the policy, and the same new password in "Confirm new password".
4. Click **OK**. The modal closes and the success indicator (modal closes) is visible.
5. The user's existing refresh tokens have been revoked, so a `refresh` from any other tab will return 401 and that tab will be sent to `/login` on the next guard tick.
# See also
- [REST API Overview](/api/rest-overview.md)
- [Setup Endpoint](/api/setup.md)
- [Landing Page Guide](/guides/landing-page.md)
- [Authenticated Shell Guide](/guides/authenticated-shell.md)
- [Change Password Guide](/guides/change-password.md)
+3 -2
View File
@@ -3,7 +3,7 @@ type: api
title: REST API Overview
description: Base URL, versioning, auth, CSRF, and the standard error envelope.
tags: [api, rest, overview, csrf]
timestamp: 2026-07-21T18:28:00Z
timestamp: 2026-07-21T22:19:08Z
---
# Base URL & versioning
@@ -77,7 +77,8 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
`CONFLICT`, `LAST_ADMIN`, `SYSTEM_INITIALIZED`, `RATE_LIMITED`,
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
`REGISTRATIONS_DISABLED`, `INTERNAL`.
`REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`,
`PASSWORDS_DO_NOT_MATCH`, `INTERNAL`.
# See also
+93 -25
View File
@@ -1,19 +1,25 @@
---
type: api
title: System Endpoints
description: Bootstrap payload, event status, and SSE streams.
tags: [api, system, bootstrap, event, sse]
timestamp: 2026-07-21T18:28:00Z
description: Bootstrap payload, public event status, public/authenticated SSE streams, and public event-window timestamps.
tags: [api, system, bootstrap, event, sse, settings]
timestamp: 2026-07-21T22:19:08Z
---
# Endpoints
| Method | Path | Auth | Source |
|--------|---------------------------------|--------|-----------------------------------------------------|
| `GET` | `/api/v1/bootstrap` | Public | `backend/src/modules/system/system.controller.ts` |
| `GET` | `/api/v1/event/status` | Public | Same. |
| `GET` | `/api/v1/event/stream` | Public (SSE) | Same. |
| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. |
| Method | Path | Auth | Source |
|--------|-----------------------------------|--------|-----------------------------------------------------|
| `GET` | `/api/v1/bootstrap` | Public | `backend/src/modules/system/system.controller.ts` |
| `GET` | `/api/v1/event/status` | Public | Same. |
| `GET` | `/api/v1/event/stream` | Public (SSE) | Same. |
| `GET` | `/api/v1/settings/event` | Public | Same. |
| `GET` | `/api/v1/events/status` | Authenticated (SSE) | Same. |
| `GET` | `/api/v1/scoreboard/stream` | Public (SSE) | Same. |
> Path note: the **authenticated** status stream is mounted at the plural
> `/api/v1/events/status` so it can be distinguished from the legacy
> public `/api/v1/event/status` snapshot endpoint.
# `GET /api/v1/bootstrap`
@@ -28,7 +34,12 @@ SPA's `BootstrapService`:
"welcomeMarkdown": "# Welcome\n\n...",
"theme": { "id": "classic", "tokens": { "...": "..." } },
"defaultChallengeIp": "127.0.0.1",
"registrationsEnabled": false
"registrationsEnabled": false,
"passwordPolicy": {
"minLength": 12,
"requireMixed": true,
"description": "At least 12 characters with upper, lower, digit and symbol"
}
}
```
@@ -38,32 +49,89 @@ SPA's `BootstrapService`:
* `registrationsEnabled` is read from the `setting` table
(`SETTINGS_KEYS.REGISTRATIONS_ENABLED`) and gates the new public
register endpoint (see [Auth Endpoints](/api/auth.md)).
* `passwordPolicy` is derived from `PASSWORD_MIN_LENGTH` /
`PASSWORD_REQUIRE_MIXED` env vars (see
[Change Password Guide](/guides/change-password.md)).
# `GET /api/v1/event/status`
# `GET /api/v1/event/status` (legacy public snapshot)
Returns the current event window computed by `EventStatusService`:
Returns the current event window computed by `EventStatusService.getStatus()`:
```json
{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "..." }
{ "state": "Running", "countdownMs": 12345, "startUtc": "...", "endUtc": "...", "serverNowUtc": "..." }
```
`state` is `Stopped` when `now < eventStartUtc` (countdown), `Running`
between start and end, and `Stopped` once `now >= eventEndUtc`.
`status` is `Stopped` while `now < eventStartUtc` (countdown), `Running`
between start and end, and `Stopped` once `now >= eventEndUtc`. This
endpoint is preserved for backward compatibility — new consumers should
use `getState()` (see below).
# SSE streams
# `GET /api/v1/settings/event` (public timestamps)
* `/api/v1/event/stream` — emits the same payload as
`GET /api/v1/event/status` whenever `SseHubService.publish` is invoked
(typically via an admin cron tick).
* `/api/v1/scoreboard/stream` — emits the public scoreboard projection
after each new solve.
Returns the raw UTC event-window timestamps without computing state.
Useful for the SPA when only the window endpoints are needed:
Both use NestJS `@Sse()` and are proxied through `SseHubService`
(`backend/src/common/services/sse-hub.service.ts`).
```json
{ "eventStartUtc": "2026-07-21T12:00:00.000Z", "eventEndUtc": "2026-07-28T12:00:00.000Z" }
```
A `null` value for either field means that the underlying `setting` row
is missing or empty; in that case `EventStatusService.getState()` returns
the `unconfigured` state.
# `GET /api/v1/events/status` (authenticated SSE)
Authenticated Server-Sent Events stream that pushes the full
`EventStatePayload` whenever it changes. Protected by the global
`JwtAuthGuard` — unauthenticated clients receive `401` before any frame
is sent.
Each frame:
```json
{
"state": "countdown" | "running" | "stopped" | "unconfigured",
"serverNowUtc": "2026-07-21T12:00:00.000Z",
"eventStartUtc": "2026-07-21T12:00:00.000Z" | null,
"eventEndUtc": "2026-07-28T12:00:00.000Z" | null,
"secondsToStart": 3600,
"secondsToEnd": 604800
}
```
* `state` transitions:
* `unconfigured``eventStartUtc`/`eventEndUtc` settings are missing or unparseable.
* `countdown``now < eventStartUtc`.
* `running``start <= now <= end`.
* `stopped``now > eventEndUtc`.
* The stream emits:
1. An **initial** frame on connect (`defer(getState())`).
2. A **60-second tick** frame so the SPA can update even if no admin
cron publishes through the hub.
3. Any frame pushed via `SseHubService.publish()`.
* Consecutive identical payloads are suppressed via `distinctUntilChanged(JSON.stringify)`.
See `backend/src/common/services/event-status.service.ts` for the
authoritative state machine; see
[Event Window Guide](/guides/event-window.md) for the full diagram and
the SPA wiring.
# `GET /api/v1/event/stream` (public SSE, legacy)
Public, flattened event stream that emits the legacy payload shape
(`status`, `countdownMs`, `startUtc`, `endUtc`, `serverNowUtc`) on a
1-second tick and on hub pushes. Preserved for backward compatibility
with the original landing page status badge.
# `GET /api/v1/scoreboard/stream`
Public SSE stream that pushes a flattened solve payload whenever a new
solve is published (`backend/src/common/services/sse-hub.service.ts`).
# See also
- [Auth and Settings Tables](/database/auth-settings.md)
- [Theming](/guides/theming.md)
- [Event Window](/guides/event-window.md)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Event Window Guide](/guides/event-window.md)
- [Scoreboard Stream Guide](/guides/scoreboard-stream.md)
- [Authenticated Shell Guide](/guides/authenticated-shell.md)