398 lines
16 KiB
Markdown
398 lines
16 KiB
Markdown
---
|
||
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, scoreboard, sse, submit]
|
||
timestamp: 2026-07-23T05:41: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` |
|
||
| `GET` | `/api/v1/scoreboard/ranking` | Authenticated | `backend/src/modules/challenges/scoreboard.controller.ts` |
|
||
| `GET` | `/api/v1/scoreboard/matrix` | Authenticated | Same. |
|
||
| `GET` | `/api/v1/scoreboard/event-log` | Authenticated | Same. |
|
||
| `GET` | `/api/v1/scoreboard/graph` | Authenticated | Same. |
|
||
|
||
> 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.
|
||
|
||
On a fresh database the `SeedSampleChallenges1700000000600`
|
||
migration populates the board with a representative sample set
|
||
spanning every canonical system category so the page renders cards
|
||
without any manual admin action.
|
||
|
||
# `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, challenge_name, category_abbreviation, 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`, `challenge_name` or `challengeName`,
|
||
`category_abbreviation` or `categoryAbbreviation`, `player_id` or
|
||
`userId`, `awarded_points` or `pointsAwarded`). `challenge_name`
|
||
and `category_abbreviation` carry the same display metadata that
|
||
the `/api/v1/scoreboard/event-log` projection joins in, so the
|
||
Scoreboard Event Log tab can render a freshly pushed row without
|
||
performing a second lookup.
|
||
|
||
# Scoreboard endpoints
|
||
|
||
The `ScoreboardController` mounts the four authenticated read-only
|
||
projections that drive the `/scoreboard` page (Ranking, Matrix,
|
||
Event Log, Score Graph tabs). All four live under
|
||
`/api/v1/scoreboard/*` and are served by
|
||
`backend/src/modules/challenges/scoreboard.controller.ts` via
|
||
`ScoreboardService`
|
||
(`backend/src/modules/challenges/scoreboard.service.ts`). The DTO
|
||
contracts live in
|
||
`backend/src/modules/challenges/dto/scoreboard.dto.ts` (zod
|
||
`EventLogQuerySchema` plus TypeScript interfaces for the response
|
||
shapes).
|
||
|
||
All four endpoints:
|
||
|
||
* Require a valid JWT (the global `JwtAuthGuard` is active; no
|
||
`@Public()` is used on the scoreboard controller).
|
||
* Read exclusively — no mutation — and never expose the `challenge.flag`
|
||
field or any admin-only data.
|
||
* Compute `awardedPoints` per row using
|
||
`computeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)`
|
||
from `backend/src/modules/challenges/scoring.util.ts`, which is the
|
||
same `basePoints + rankBonus` arithmetic that `submitFlag` performs
|
||
when persisting the `solve` row.
|
||
* Color each player via `stablePlayerColorIndex(playerId)` (FNV-1a
|
||
hash → 10-entry `PLAYER_COLOR_PALETTE`) so the Ranking, Matrix, and
|
||
Score Graph share a deterministic swatch per `playerId`.
|
||
|
||
## `GET /api/v1/scoreboard/ranking`
|
||
|
||
Returns every user (including zero-solve players) ranked by total
|
||
points, with competition-rank numbering (`1, 2, 2, 4, …` — players with
|
||
equal `points` share a rank).
|
||
|
||
Response (`RankingRowDto[]`):
|
||
|
||
```json
|
||
[
|
||
{
|
||
"rank": 1,
|
||
"playerId": "<user-uuid>",
|
||
"playerName": "alice",
|
||
"solvedCount": 7,
|
||
"points": 3120,
|
||
"colorIndex": 4
|
||
}
|
||
]
|
||
```
|
||
|
||
`colorIndex` is the same value the frontend uses to tint the
|
||
per-player swatch in the Ranking, Matrix, and Score Graph tabs so a
|
||
player keeps their color across all three views.
|
||
|
||
## `GET /api/v1/scoreboard/matrix`
|
||
|
||
Returns a players × challenges grid for the live scoreboard view.
|
||
|
||
Response (`MatrixViewDto`):
|
||
|
||
```json
|
||
{
|
||
"players": [ /* RankingRowDto[] in ranking order */ ],
|
||
"challenges": [
|
||
{
|
||
"id": "<challenge-uuid>",
|
||
"name": "RSA Rollers",
|
||
"solveCount": 4,
|
||
"categoryAbbreviation": "CRY"
|
||
}
|
||
],
|
||
"cells": {
|
||
"<player-uuid>": {
|
||
"<challenge-uuid>": 1,
|
||
"<challenge-uuid>": 2,
|
||
"<challenge-uuid>": 3,
|
||
"<challenge-uuid>": "solved",
|
||
"<challenge-uuid>": null
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`cells[playerId][challengeId]` is one of `1` (gold ★, 1st solver),
|
||
`2` (silver ★, 2nd solver), `3` (bronze ★, 3rd solver), `'solved'`
|
||
(✓, 4th+ solver), or `null` (not solved). Challenges are sorted by
|
||
`solveCount` DESC, then by `name` ASC. The service walks the `solve`
|
||
rows in `solved_at ASC` order per challenge to assign each cell its
|
||
first solver rank.
|
||
|
||
## `GET /api/v1/scoreboard/event-log`
|
||
|
||
Recent solves, newest first, joined with the user, challenge, and
|
||
category so the frontend never needs a second round-trip to render
|
||
labels.
|
||
|
||
Query string (`EventLogQuerySchema`, validated by `ZodValidationPipe`):
|
||
|
||
| Param | Type | Default | Description |
|
||
|---------|------|---------|----------------------------------------------|
|
||
| `limit` | int | `50` | `1 <= limit <= 200`, clamped server-side too. |
|
||
|
||
Response (`EventLogRowDto[]`):
|
||
|
||
```json
|
||
[
|
||
{
|
||
"solveId": "<solve-uuid>",
|
||
"playerId": "<user-uuid>",
|
||
"playerName": "bob",
|
||
"challengeId": "<challenge-uuid>",
|
||
"challengeName": "Forensic Trail",
|
||
"categoryAbbreviation": "FOR",
|
||
"position": 1,
|
||
"awardedPoints": 515,
|
||
"awardedAtUtc": "2026-07-23T05:01:30.000Z"
|
||
}
|
||
]
|
||
```
|
||
|
||
## `GET /api/v1/scoreboard/graph`
|
||
|
||
Time-series of cumulative points for the top 10 players over the
|
||
configured event window, used to drive the Score Graph tab.
|
||
|
||
Response (`GraphViewDto`):
|
||
|
||
```json
|
||
{
|
||
"startUtc": "2026-07-23T05:00:00.000Z",
|
||
"endUtc": "2026-07-23T09:00:00.000Z",
|
||
"serverNowUtc": "2026-07-23T05:10:00.000Z",
|
||
"state": "running",
|
||
"series": [
|
||
{
|
||
"playerId": "<user-uuid>",
|
||
"playerName": "alice",
|
||
"colorIndex": 4,
|
||
"points": [
|
||
{ "tUtc": "2026-07-23T05:00:00.000Z", "value": 0 },
|
||
{ "tUtc": "2026-07-23T05:01:30.000Z", "value": 515 },
|
||
{ "tUtc": "2026-07-23T05:09:50.000Z", "value": 980 },
|
||
{ "tUtc": "2026-07-23T09:00:00.000Z", "value": 980 }
|
||
]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
`state` mirrors the 4-state event window (`running` / `countdown` /
|
||
`stopped` / `unconfigured`) and `startUtc` / `endUtc` come from
|
||
`SettingsService` via the admin general-settings pipeline. When the
|
||
event has not yet started (`countdown`) or has no window configured
|
||
(`unconfigured`), the endpoint still returns `200` with empty
|
||
`series` and `null` boundaries so the frontend can render the
|
||
"Awaiting event configuration" / "Not started" empty states.
|
||
|
||
Series are sorted by their final cumulative value DESC and trimmed to
|
||
the top 10. Each series starts with a `{tUtc: startUtc, value: 0}`
|
||
anchor and (when `endUtc` is known) ends with a
|
||
`{tUtc: endUtc, value: finalValue}` step so the line plot shows a
|
||
flat plateau between the last solve and the event end.
|
||
|
||
# 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)
|
||
- [Scoreboard Page Guide](/guides/scoreboard-page.md)
|
||
- [Challenges Board Guide](/guides/challenges-board.md)
|
||
- [Backend Module Map](/architecture/backend-modules.md) |