--- 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": "", "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. # `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)