AI Implementation feature(863): Challenges Page and Challenge Solve Modal (#45)

This commit was merged in pull request #45.
This commit is contained in:
2026-07-23 00:13:59 +00:00
parent dcda3dfd4d
commit 0bd1d9472a
40 changed files with 4710 additions and 130 deletions
+220
View File
@@ -0,0 +1,220 @@
---
type: api
title: Challenges Endpoints
description: Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames.
tags: [api, challenges, board, score, sse, submit]
timestamp: 2026-07-23T00:10:00Z
---
# Endpoints
All routes are mounted by `ChallengesModule`
(`backend/src/modules/challenges/challenges.module.ts`) and require
authentication (`JwtAuthGuard` is registered globally and these handlers
are not `@Public()`). The DTOs and zod schemas live in
`backend/src/modules/challenges/dto/challenges.dto.ts`.
| Method | Path | Auth | Source |
|--------|-----------------------------------|-------------|-----------------------------------------------------------|
| `GET` | `/api/v1/challenges/board` | Authenticated | `backend/src/modules/challenges/challenges.controller.ts` |
| `GET` | `/api/v1/challenges/status` | Authenticated | Same. |
| `GET` | `/api/v1/challenges/:id` | Authenticated | Same. |
| `POST` | `/api/v1/challenges/:id/solves` | Authenticated | Same. |
| `SSE` | `/api/v1/events` | Authenticated (SSE) | `backend/src/modules/challenges/events.controller.ts` |
> The combined `/api/v1/events` SSE is **in addition to** the existing
> `/api/v1/events/status` status-only stream. The two share the same
> authenticated transport but serve different consumers — the
> `ChallengesPage` opens `/api/v1/events` so it can react to `solve`
> frames in addition to status, while the shell header keeps reading
> `/api/v1/events/status` for the LED/countdown only.
# `GET /api/v1/challenges/board`
Authenticated board of enabled challenges grouped by category. Query
string:
| Param | Type | Description |
|-----------|--------|----------------------------------------------------------------------------------------------|
| `include` | string | Comma-separated feature list. Passing `solvers` attaches a per-card `solvers` array (ordered by `solved_at ASC`). |
The handler (`ChallengesService.getBoard`) returns:
```json
{
"columns": [
{
"id": "<category-uuid>",
"abbreviation": "CRY",
"name": "Cryptography",
"iconPath": "/uploads/icons/CRY.png",
"cards": [
{
"id": "<challenge-uuid>",
"name": "RSA Rollers",
"descriptionMd": "...",
"categoryId": "<category-uuid>",
"categoryAbbreviation": "CRY",
"categoryIconPath": "/uploads/icons/CRY.png",
"difficulty": "MEDIUM",
"livePoints": 480,
"solveCount": 4,
"solvedByMe": false,
"solvers": [ /* SolverRow[] when ?include=solvers */ ]
}
]
}
],
"solvedChallengeIds": ["<uuid>", "..."],
"perChallengeLivePoints": { "<uuid>": 480 }
}
```
`livePoints` is computed by
`computeLivePoints(initialPoints, minimumPoints, decaySolves, solveCount)`
(`backend/src/modules/challenges/scoring.util.ts`):
```
if (currentSolveCount >= decaySolves) return minimum;
raw = initial - (initial - minimum) * (currentSolveCount / decaySolves);
return max(minimum, round(raw));
```
Columns are sorted alphabetically by `abbreviation`; cards within a
column are sorted by `difficulty` (`LOW < MEDIUM < HIGH`) then by name.
Only `enabled = 1` rows are returned. The query joins
`challenge` and `category` once and groups in memory.
# `GET /api/v1/challenges/status`
Authenticated event-window snapshot (the same `EventStatePayload`
returned by the legacy `/api/v1/event/status` public endpoint, but
gated by JWT and shaped to the canonical 4-state payload used by the
SPA). Built by
`backend/src/common/services/event-status.service.ts:getState()`.
`ChallengesPage` calls this once on init to populate the event state
before the SSE frame arrives, so the gate UI is never blank on slow
networks.
# `GET /api/v1/challenges/:id`
Single challenge detail with the full `SolverRow[]` for that
challenge. `:id` must be a UUID (validated by `ChallengeIdParamSchema`).
The response shape is:
```json
{
"challenge": { /* BoardCard */ },
"solvers": [ /* SolverRow[] sorted by solvedAtUtc ASC */ ]
}
```
`404 NOT_FOUND` is returned for unknown ids and for disabled
challenges.
# `POST /api/v1/challenges/:id/solves`
Submit a flag. Body is validated by `SolveSubmitBodySchema`
(`{ flag: string, min 1, max 4096 }`). Response:
```json
{
"status": "solved" | "already_solved",
"challenge": { /* BoardCard with solvedByMe=true and the new solveCount */ },
"awarded": {
"position": 1,
"basePoints": 500,
"rankBonus": 15,
"awardedPoints": 515,
"awardedAtUtc": "2026-07-23T00:01:30.000Z"
},
"solvers": [ /* SolverRow[] */ ]
}
```
The full transaction (`ChallengesService.submitFlag`):
1. Loads the challenge + category inside a `dataSource.transaction`.
2. Reads `EventStatusService.getState()`; rejects with
`409 EVENT_NOT_RUNNING` when `state !== 'running'`.
3. Looks for an existing `(challengeId, userId)` `solve` row.
* If found, returns `status: 'already_solved'` with the original
awarded points and the current solver list (no new row is
inserted; the unique index `uq_solve_challenge_user` would block
it anyway).
4. Constant-time compares the submitted flag against `challenge.flag`
via `safeEqualString`. On mismatch rejects with
`400 FLAG_INCORRECT`.
5. Counts existing solves (`position = count + 1`).
6. Computes `basePoints = computeLivePoints(...)` for `position - 1`,
`rankBonus = rankBonusForPosition(position)` (`15 / 10 / 5 / 0`),
`awardedPoints = basePoints + rankBonus`.
7. Inserts the new `solve` row with `is_first` / `is_second` /
`is_third` flags populated.
8. On `SQLITE_CONSTRAINT_UNIQUE` (race) falls back to the
`already_solved` shape.
9. On success, publishes a solve frame via
`SseHubService.emitScoreboard(...)` (see below) so every
`/api/v1/events` subscriber sees the live event.
The flag is **never** returned in any DTO exposed by this controller
(it is read only inside the `dataSource.transaction` and is not
included in `BoardCard`, `SolverRow`, `AwardedSolve`, or
`SolveResponse`).
# `SSE GET /api/v1/events`
Authenticated global SSE stream mounted by
`backend/src/modules/challenges/events.controller.ts`. It merges three
sources and emits `MessageEvent` frames whose `type` is one of:
| `event:` | Payload |
|----------|-------------------------------------------------------------------------------------------------|
| `status` | `{ state, server_time_utc, event_start_utc, event_end_utc, seconds_to_start, seconds_to_end }` |
| `solve` | `{ challenge_id, player_id, player_name, awarded_points, rank_bonus, awarded_at_utc, position, live_points, solve_count, initial_points, minimum_points, decay_solves }` |
Sources:
| Source | Trigger |
|-----------------------------------------|------------------------------------------------------------------------------------|
| `interval(60_000).pipe(startWith(0))` | Refresh the full `status` payload every 60 seconds so a disconnected client self-heals. |
| `SseHubService.event$()` (filter `'general'` topic) | Re-publish the current `status` whenever an admin updates general settings. |
| `SseHubService.event$()` (legacy event payloads) | Legacy fallback for pre-existing event-window broadcasts. |
| `SseHubService.scoreboard$()` | Every fresh `solve` published by `ChallengesService.submitFlag`. |
Consecutive identical `status` frames are suppressed by
`distinctUntilChanged(JSON.stringify)`. The `solve` frames are
appended as-is.
## Solve frame wire format
`SseHubService.emitScoreboard({...})` is called with a flat
`SolveEventPayload` (see
`backend/src/modules/challenges/dto/challenges.dto.ts`). The
controller maps both camelCase and snake_case aliases so a
downstream consumer can read either shape (for example
`challenge_id` or `challengeId`, `player_id` or `userId`,
`awarded_points` or `pointsAwarded`).
# Errors
The challenges endpoints use three additional error codes from
`backend/src/common/errors/error-codes.ts`:
| Code | HTTP | When |
|---------------------|------|------------------------------------------------------------|
| `EVENT_NOT_RUNNING` | 409 | Flag submitted while the event is in `countdown` / `stopped` / `unconfigured`. |
| `FLAG_INCORRECT` | 400 | Submitted flag did not constant-time-equal `challenge.flag`. |
| `CHALLENGE_DISABLED`| — | Reserved for future use (admin-side disabled challenges already return `404 NOT_FOUND` from this controller). |
The full error envelope and middleware are documented in
[REST API Overview](/api/rest-overview.md).
# See also
- [Challenge Tables](/database/challenges.md)
- [System Endpoints](/api/system.md) (legacy `/events/status` and `/event/stream`)
- [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Challenges Board Guide](/guides/challenges-board.md)
- [Backend Module Map](/architecture/backend-modules.md)
+15 -3
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-21T22:19:08Z
timestamp: 2026-07-23T00:10:00Z
---
# Base URL & versioning
@@ -78,7 +78,18 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
`CSRF_INVALID`, `WEAK_PASSWORD`, `USERNAME_TAKEN`,
`INVALID_CREDENTIALS`, `TOKEN_REVOKED`, `THEME_INVALID`,
`REGISTRATIONS_DISABLED`, `INVALID_OLD_PASSWORD`, `PASSWORD_POLICY`,
`PASSWORDS_DO_NOT_MATCH`, `INTERNAL`.
`PASSWORDS_DO_NOT_MATCH`, `CATEGORY_HAS_CHALLENGES`, `SYSTEM_PROTECTED`,
`CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`,
`CHALLENGE_INVALID_POINTS`, `CHALLENGE_INVALID_PORT`,
`CHALLENGE_INVALID_IP`, `CHALLENGE_FILE_LIMIT`,
`IMPORT_VALIDATION_FAILED`, `IMPORT_PARTIAL_FAILURE`,
`EVENT_NOT_RUNNING`, `FLAG_INCORRECT`, `CHALLENGE_DISABLED`, `INTERNAL`.
The player-facing codes (`EVENT_NOT_RUNNING`, `FLAG_INCORRECT`) are
emitted by the challenges submit endpoint; see
[Challenges Endpoints](/api/challenges.md) for the full set of HTTP
statuses and the canonical messages produced by
`messageForSolveError`.
# See also
@@ -86,4 +97,5 @@ endpoint). Full list lives in `backend/src/common/errors/error-codes.ts`:
- [Admin Endpoints](/api/admin.md)
- [Setup Endpoint](/api/setup.md)
- [System Endpoints](/api/system.md)
- [Uploads Endpoints](/api/uploads.md)
- [Uploads Endpoints](/api/uploads.md)
- [Challenges Endpoints](/api/challenges.md)