Files
HIPCTF2/docs/api/challenges.md
T
2026-07-23 02:24:40 +00:00

9.6 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.
api
challenges
board
score
sse
submit
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:

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

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