225 lines
9.6 KiB
Markdown
225 lines
9.6 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, 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.
|
|
|
|
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, 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) |