--- 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": "", "abbreviation": "CRY", "name": "Cryptography", "iconPath": "/uploads/icons/CRY.png", "cards": [ { "id": "", "name": "RSA Rollers", "descriptionMd": "...", "categoryId": "", "categoryAbbreviation": "CRY", "categoryIconPath": "/uploads/icons/CRY.png", "difficulty": "MEDIUM", "livePoints": 480, "solveCount": 4, "solvedByMe": false, "solvers": [ /* SolverRow[] when ?include=solvers */ ] } ] } ], "solvedChallengeIds": ["", "..."], "perChallengeLivePoints": { "": 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": "", "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": "", "name": "RSA Rollers", "solveCount": 4, "categoryAbbreviation": "CRY" } ], "cells": { "": { "": 1, "": 2, "": 3, "": "solved", "": 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": "", "playerId": "", "playerName": "bob", "challengeId": "", "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": "", "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)