Files
HIPCTF2/docs/api/challenges.md
T
2026-07-23 05:13:20 +00:00

15 KiB
Raw Blame History

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.
api
challenges
board
score
scoreboard
sse
submit
2026-07-23T05: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
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:

{
  "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):

  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).

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[]):

[
  {
    "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