9.4 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-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/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.
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, 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.
See also
- Challenge Tables
- System Endpoints (legacy
/events/statusand/event/stream) - Scoreboard Stream
- Challenges Board Guide
- Backend Module Map