Files
HIPCTF2/docs/api/challenges.md
T

398 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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)