16 KiB
type, title, description, tags, timestamp
| type | title | description | tags | timestamp | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
| api | Challenges Endpoints | Authenticated challenge board, single-challenge detail, flag submission, event-state snapshot, and the combined authenticated SSE stream that pushes status + solve frames. |
|
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/eventsSSE is in addition to the existing/api/v1/events/statusstatus-only stream. The two share the same authenticated transport but serve different consumers — theChallengesPageopens/api/v1/eventsso it can react tosolveframes in addition to status, while the shell header keeps reading/api/v1/events/statusfor 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:
{
"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:
{
"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:
{
"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):
- Loads the challenge + category inside a
dataSource.transaction. - Reads
EventStatusService.getState(); rejects with409 EVENT_NOT_RUNNINGwhenstate !== 'running'. - Looks for an existing
(challengeId, userId)solverow.- If found, returns
status: 'already_solved'with the original awarded points and the current solver list (no new row is inserted; the unique indexuq_solve_challenge_userwould block it anyway).
- If found, returns
- Constant-time compares the submitted flag against
challenge.flagviasafeEqualString. On mismatch rejects with400 FLAG_INCORRECT. - Counts existing solves (
position = count + 1). - Computes
basePoints = computeLivePoints(...)forposition - 1,rankBonus = rankBonusForPosition(position)(15 / 10 / 5 / 0),awardedPoints = basePoints + rankBonus. - Inserts the new
solverow withis_first/is_second/is_thirdflags populated. - On
SQLITE_CONSTRAINT_UNIQUE(race) falls back to thealready_solvedshape. - On success, publishes a solve frame via
SseHubService.emitScoreboard(...)(see below) so every/api/v1/eventssubscriber 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
JwtAuthGuardis active; no@Public()is used on the scoreboard controller). - Read exclusively — no mutation — and never expose the
challenge.flagfield or any admin-only data. - Compute
awardedPointsper row usingcomputeAwardedPoints(initial, minimum, decaySolves, solveCountBefore)frombackend/src/modules/challenges/scoring.util.ts, which is the samebasePoints + rankBonusarithmetic thatsubmitFlagperforms when persisting thesolverow. - Color each player via
stablePlayerColorIndex(playerId)(FNV-1a hash → 10-entryPLAYER_COLOR_PALETTE) so the Ranking, Matrix, and Score Graph share a deterministic swatch perplayerId.
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[]):
[
{
"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):
{
"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[]):
[
{
"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):
{
"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.
See also
- Challenge Tables
- System Endpoints (legacy
/events/statusand/event/stream) - Scoreboard Stream
- Scoreboard Page Guide
- Challenges Board Guide
- Backend Module Map