docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-22 20:26:26 +00:00
parent 1a40e5708a
commit 6c18603a7d
8 changed files with 292 additions and 40 deletions
+101
View File
@@ -79,6 +79,107 @@ See [Admin — General Settings](/guides/admin-general-settings.md).
| `PUT` | `/api/v1/admin/categories/:id` | Same. | | `PUT` | `/api/v1/admin/categories/:id` | Same. |
| `DELETE` | `/api/v1/admin/categories/:id` | Same. | | `DELETE` | `/api/v1/admin/categories/:id` | Same. |
See [Admin — Categories](/guides/admin-categories.md).
## Challenges — `/api/v1/admin/challenges`
| Method | Path | Source |
|----------|--------------------------------------------|--------------------------------------------------------------------------------------|
| `GET` | `/api/v1/admin/challenges` | `backend/src/modules/admin/admin-challenges.controller.ts` |
| `GET` | `/api/v1/admin/challenges/:id` | Same. |
| `POST` | `/api/v1/admin/challenges` | Same. |
| `PUT` | `/api/v1/admin/challenges/:id` | Same. |
| `DELETE` | `/api/v1/admin/challenges/:id` | Same. |
| `GET` | `/api/v1/admin/challenges/export` | Same — JSON attachment `challenges-export-<ISO-date>.json`. |
| `POST` | `/api/v1/admin/challenges/import` | Same — preview by default, `?confirm=overwrite` to commit. |
| `POST` | `/api/v1/admin/challenges/files/stage` | Same — multipart upload that returns an opaque staging token. |
| `DELETE` | `/api/v1/admin/challenges/files/stage/:token` | Same — discards a previously staged file. |
List response (admin-only, **never includes the secret flag**):
```json
{
"id": "uuid",
"name": "Welcome",
"categoryAbbreviation": "CRY",
"categoryIconPath": "/uploads/icons/CRY.png",
"difficulty": "MEDIUM",
"protocol": "WEB",
"enabled": true,
"createdAt": "2026-07-22T20:00:00.000Z",
"updatedAt": "2026-07-22T20:00:00.000Z",
"fileCount": 1,
"solveCount": 0
}
```
Optional list query: `?q=substring&categoryId=uuid` (both optional).
Detail response (admin-only, **may include plaintext `flag`**):
```json
{
"id": "uuid",
"name": "Welcome",
"descriptionMd": "...",
"categoryId": "uuid",
"categoryAbbreviation": "CRY",
"categoryIconPath": "/uploads/icons/CRY.png",
"difficulty": "MEDIUM",
"initialPoints": 100,
"minimumPoints": 50,
"decaySolves": 10,
"flag": "flag{...}",
"protocol": "WEB",
"port": null,
"ipAddress": "",
"enabled": true,
"createdAt": "...",
"updatedAt": "...",
"files": [
{ "id": "uuid", "originalFilename": "readme.txt", "mimeType": "text/plain", "sizeBytes": 123, "createdAt": "..." }
]
}
```
Import document shape:
```json
{
"format": "hipctf-challenges",
"version": 1,
"exportedAt": "2026-07-22T20:00:00Z",
"challenges": [
{
"name": "...",
"descriptionMd": "...",
"categorySystemKey": "CRY",
"difficulty": "LOW|MEDIUM|HIGH",
"initialPoints": 100,
"minimumPoints": 50,
"decaySolves": 10,
"flag": "plaintext",
"protocol": "NC|WEB",
"port": 1337,
"ipAddress": "",
"files": [
{ "originalFilename": "...", "mimeType": "...", "sizeBytes": 123, "dataBase64": "..." }
]
}
]
}
```
Validation errors return `400 VALIDATION_FAILED` with stable codes
(`CHALLENGE_NAME_TAKEN`, `CHALLENGE_INVALID_CATEGORY`,
`IMPORT_VALIDATION_FAILED`, `IMPORT_PARTIAL_FAILURE`) and a
`details[]` array of `{ path, message }` entries. Delete cascades
`challenge_file` and `solve` rows inside one DB transaction;
filesystem cleanup of the deleted challenge's files runs best-effort
after the transaction commits.
See [Admin — Challenges](/guides/admin-challenges.md).
Behavior: Behavior:
* `GET` returns all categories sorted by `LOWER(abbreviation)` ascending. * `GET` returns all categories sorted by `LOWER(abbreviation)` ascending.
+1 -1
View File
@@ -12,7 +12,7 @@ timestamp: 2026-07-22T19:18:00Z
|--------|---------------------------------------|-------|---------------------------------------------------------| |--------|---------------------------------------|-------|---------------------------------------------------------|
| `POST` | `/api/v1/uploads/logo` | Admin | `backend/src/modules/uploads/uploads.controller.ts` | | `POST` | `/api/v1/uploads/logo` | Admin | `backend/src/modules/uploads/uploads.controller.ts` |
| `POST` | `/api/v1/uploads/category-icon` | Admin | Same. | | `POST` | `/api/v1/uploads/category-icon` | Admin | Same. |
| `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. | | `POST` | `/api/v1/uploads/challenge-file` | Admin | Same. *(Removed in Job 861 — challenge attachments now flow through `POST /api/v1/admin/challenges/files/stage` so they are only persisted on challenge save.)* |
# Guard chain # Guard chain
+2 -1
View File
@@ -27,7 +27,7 @@ also registers two global providers:
| `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). | | `UsersModule` | `backend/src/modules/users/users.module.ts` | `UsersController` (`/api/v1/auth/register-first-admin`) + `UsersService` (last-admin invariant) + `UsersRankService` (`rankOfUser(manager, userId) -> {rank, points}` over the `solve` table). |
| `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. | | `SetupModule` | `backend/src/modules/setup/setup.module.ts` | `SetupController` (`POST /api/v1/setup/create-admin`) + `SetupService`. Returns 409 `SYSTEM_INITIALIZED` once any admin exists; serializes concurrent first-admin attempts via an in-process promise chain. |
| `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. | | `SystemModule` | `backend/src/modules/system/system.module.ts` | `SystemController` (`/api/v1/bootstrap`, `/event/status`, SSE streams) + `SystemService`. |
| `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Three controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Four controllers (`AdminController` for `/api/v1/admin/users*`, `AdminGeneralController` for `/api/v1/admin/general/*`, `AdminCategoriesController` for `/api/v1/admin/categories*`, `AdminChallengesController` for `/api/v1/admin/challenges*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, and `CommonModule` (for `SseHubService`, `ThemeLoaderService`). |
| `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. | | `UploadsModule` | `backend/src/modules/uploads/uploads.module.ts` | `UploadsController` (`/api/v1/uploads/*`). Admin-only multipart. |
| `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). | | `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController` (`GET /api/v1/blog/posts`) + `BlogService` (lists `status='published'` rows). |
| `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. | | `FrontendModule` | `backend/src/frontend/frontend.module.ts` | Registers `SpaFallbackMiddleware` and the uploads static helper. |
@@ -42,6 +42,7 @@ also registers two global providers:
| `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` | | `AdminController` | `/api/v1/admin` | Admin only | `backend/src/modules/admin/admin.controller.ts` |
| `AdminGeneralController` | `/api/v1/admin/general` | Admin only | `backend/src/modules/admin/admin-general.controller.ts` | | `AdminGeneralController` | `/api/v1/admin/general` | Admin only | `backend/src/modules/admin/admin-general.controller.ts` |
| `AdminCategoriesController` | `/api/v1/admin/categories` | Admin only | `backend/src/modules/admin/admin-categories.controller.ts` | | `AdminCategoriesController` | `/api/v1/admin/categories` | Admin only | `backend/src/modules/admin/admin-categories.controller.ts` |
| `AdminChallengesController` | `/api/v1/admin/challenges` | Admin only | `backend/src/modules/admin/admin-challenges.controller.ts` |
| `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` | | `SystemController` | `/api/v1` | Mixed (bootstrap/event/scoreboard/settings are public; `events/status` SSE is JWT-protected) | `backend/src/modules/system/system.controller.ts` |
| `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` | | `UploadsController` | `/api/v1/uploads` | Admin only | `backend/src/modules/uploads/uploads.controller.ts` |
| `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` | | `BlogController` | `/api/v1/blog` | Public | `backend/src/modules/blog/blog.controller.ts` |
+1
View File
@@ -19,6 +19,7 @@ Routes live in `frontend/src/app/app.routes.ts`:
| `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. | | `/scoreboard` | `ScoreboardPage` | inherited | Placeholder scoreboard page. |
| `/blog` | `BlogPage` | inherited | Placeholder blog page. | | `/blog` | `BlogPage` | inherited | Placeholder blog page. |
| `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. | | `/admin` | `AdminUsersComponent` | `adminGuard` | Admin-only child route. |
| `/admin/challenges` | `AdminChallengesComponent` | inherited | Sortable/searchable admin Challenges list, Add/Edit modal, import/export. |
# Components and services # Components and services
+46 -36
View File
@@ -3,7 +3,7 @@ type: database
title: Challenge Tables title: Challenge Tables
description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored. description: category, challenge, challenge_file, and solve tables — how CTF challenges and scoring are stored.
tags: [database, challenge, category, solve] tags: [database, challenge, category, solve]
timestamp: 2026-07-22T16:44:54Z timestamp: 2026-07-22T20:30:00Z
--- ---
# Tables # Tables
@@ -18,68 +18,80 @@ timestamp: 2026-07-22T16:44:54Z
| `abbreviation` | TEXT | Short label (e.g. `CRY`), 26 chars, server-uppercased. Globally unique — enforced by `uq_category_abbreviation`. | | `abbreviation` | TEXT | Short label (e.g. `CRY`), 26 chars, server-uppercased. Globally unique — enforced by `uq_category_abbreviation`. |
| `description` | TEXT | Optional description. | | `description` | TEXT | Optional description. |
| `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/CRY.png`). | | `icon_path` | TEXT | Default icon URL (e.g. `/uploads/icons/CRY.png`). |
| `created_at` | TEXT | ISO 8601 timestamp the row was created. Declared in the initial `InitSchema1700000000000` migration (defaulting to `strftime('%Y-%m-%dT%H:%M:%fZ','now')`); the `AddCategoryTimestampsAndUniqueAbbrev1700000000200` migration adds it as a no-op for older pre-existing databases. | | `created_at` | TEXT | ISO 8601 timestamp the row was created. |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (bumped on `PUT /api/v1/admin/categories/:id`). Same provenance as `created_at`. | | `updated_at` | TEXT | ISO 8601 timestamp the row was last updated. |
System rows are seeded by the System rows are seeded by the
`UpdateSystemCategoryKeys1700000000300` migration so the canonical `UpdateSystemCategoryKeys1700000000300` migration so the canonical
keys are `CRY` (Cryptography), `MSC` (Misc), `PWN` (Binary keys are `CRY` (Cryptography), `MSC` (Misc), `PWN` (Binary
exploitation), `REV` (Reverse Engineering), `WEB` (Web), and `HW` exploitation), `REV` (Reverse Engineering), `WEB` (Web), and `HW`
(Hardware). A forward-only repair migration, (Hardware). The forward-only
`RepairCategorySchemaAndSystemCategories1700000000400`, runs after `RepairCategorySchemaAndSystemCategories1700000000400` migration
that one on existing databases that pre-date the canonical six keys repairs legacy schemas on existing databases.
(it adds `created_at`/`updated_at` if missing, deletes obsolete
`FOR`/`OSI` system rows, rewrites name/abbreviation/description/icon
metadata on canonical rows, inserts any missing canonical row, and
guarantees unique indexes on `system_key` and `abbreviation`).
User-created categories leave `system_key` as `NULL`.
The uniqueness on `abbreviation` is enforced both by application
validation (uppercased on save, duplicates rejected) and by the
`uq_category_abbreviation` index created in migration
`1700000000200` (re-asserted by `1700000000400`).
## `challenge` ## `challenge`
| Column | Type | Description | | Column | Type | Description |
|-------------------|----------|-----------------------------------------------------------------------------------| |-------------------|----------|-----------------------------------------------------------------------------------|
| `id` | TEXT PK | UUID v4. | | `id` | TEXT PK | UUID v4. |
| `name` | TEXT | Display name. | | `name` | TEXT | Display name. **Case-insensitive uniqueness** enforced by `uq_challenge_name_lower`. |
| `description_md` | TEXT | Markdown description. | | `description_md` | TEXT | Markdown description. |
| `category_id` | TEXT | FK → `category.id`. | | `category_id` | TEXT | FK → `category.id` (`ON DELETE RESTRICT`). |
| `difficulty` | TEXT | `'low'` / `'med'` / `'high'`. | | `difficulty` | TEXT | `'LOW'` / `'MEDIUM'` / `'HIGH'`. |
| `initial_points` | INTEGER | Starting point value. | | `initial_points` | INTEGER | Starting point value (1100000). |
| `minimum_points` | INTEGER | Floor after decay. | | `minimum_points` | INTEGER | Floor after decay (1100000, must be ≤ `initial_points`). |
| `decay_solves` | INTEGER | Number of solves at which decay reaches the minimum. | | `decay_solves` | INTEGER | Solves at which decay reaches the minimum (110000). |
| `flag` | TEXT | Submission string expected from players. | | `flag` | TEXT | Submission string expected from players. **Never returned in non-admin DTOs.** |
| `protocol` | TEXT | `'nc'` (netcat-style connection) or `'web'` (browser-based). | | `protocol` | TEXT | `'NC'` (netcat-style connection) or `'WEB'` (browser-based). |
| `port` | INTEGER | TCP port when relevant (nullable). | | `port` | INTEGER | TCP port (nullable for WEB, required 165535 for NC). |
| `ip_address` | TEXT | IP the challenge listens on (defaulted from `setting.defaultChallengeIp`). | | `ip_address` | TEXT | IP the challenge listens on (defaults to `setting.defaultChallengeIp`). |
| `created_at` | TEXT | ISO 8601 timestamp. | | `enabled` | INTEGER | Boolean — 1 (default) or 0. |
| `created_at` | TEXT | ISO 8601 timestamp the row was created. |
| `updated_at` | TEXT | ISO 8601 timestamp the row was last updated (bumped on every PUT). |
The `UpgradeChallengeAdminSchema1700000000500` migration rebuilt
the `challenge` table atomically (preserving legacy data), introduced
canonical enums, the `enabled` flag, `updated_at`, the
`LOWER(name)` unique index, and tighter scoring defaults. The
category FK stays at `ON DELETE RESTRICT` so the
`CATEGORY_HAS_CHALLENGES` business rule is preserved.
## `challenge_file` ## `challenge_file`
| Column | Type | Description | | Column | Type | Description |
|---------------------|---------|--------------------------------------| |---------------------|---------|--------------------------------------|
| `id` | TEXT PK | UUID v4. | | `id` | TEXT PK | UUID v4. |
| `challenge_id` | TEXT | FK → `challenge.id`. | | `challenge_id` | TEXT | FK → `challenge.id` (`ON DELETE CASCADE`). |
| `original_filename` | TEXT | Filename from upload. | | `original_filename` | TEXT | Filename from upload. |
| `stored_path` | TEXT | Path on disk under `UPLOAD_DIR/challenges/`. | | `stored_filename` | TEXT | UUID-based filename on disk under `UPLOAD_DIR/challenges/`; unique. |
| `mime_type` | TEXT | Source MIME type (defaults to `application/octet-stream`). |
| `size_bytes` | INTEGER | Decoded file size in bytes. |
| `created_at` | TEXT | ISO 8601 timestamp. |
The upgrade migration added `stored_filename`, `mime_type`,
`size_bytes`, and `created_at`. The challenge → file FK was changed
from `RESTRICT` to `CASCADE` so deleting a challenge in one DB
transaction removes all its `ChallengeFile` rows in lockstep.
## `solve` ## `solve`
| Column | Type | Description | | Column | Type | Description |
|------------------|---------|----------------------------------------------------------------------| |------------------|---------|----------------------------------------------------------------------|
| `id` | TEXT PK | UUID v4. | | `id` | TEXT PK | UUID v4. |
| `challenge_id` | TEXT | FK → `challenge.id`. | | `challenge_id` | TEXT | FK → `challenge.id` (`ON DELETE CASCADE`). |
| `user_id` | TEXT | FK → `user.id`. | | `user_id` | TEXT | FK → `user.id` (`ON DELETE RESTRICT`). |
| `solved_at` | TEXT | ISO 8601 timestamp the solve was recorded. | | `solved_at` | TEXT | ISO 8601 timestamp the solve was recorded. |
| `points_awarded` | INTEGER | Points actually awarded (after decay). | | `points_awarded` | INTEGER | Points actually awarded (after decay). |
| `base_points` | INTEGER | Snapshot of `challenge.initial_points` at solve time. | | `base_points` | INTEGER | Snapshot of `challenge.initial_points` at solve time. |
| `rank_bonus` | INTEGER | Extra points from rank (e.g. first-blood). | | `rank_bonus` | INTEGER | Extra points from rank (e.g. first-blood). |
| `is_first` | INTEGER | Boolean — first-blood flag. |
| `is_second` | INTEGER | Boolean — second-blood flag. |
| `is_third` | INTEGER | Boolean — third-blood flag. |
Uniqueness is enforced by `uq_solve_challenge_user` on `uq_solve_challenge_user` on `(challenge_id, user_id)` is retained;
`(challenge_id, user_id)` — a user can solve a given challenge only once. deleting a challenge now cascades to its solve rows, while deleting
a user still requires explicit handling (`user_id` remains
`RESTRICT`).
# Scoring formula # Scoring formula
@@ -87,11 +99,9 @@ Uniqueness is enforced by `uq_solve_challenge_user` on
where `decay = max(0, base_points - minimum_points) * solvesBefore / decay_solves`, where `decay = max(0, base_points - minimum_points) * solvesBefore / decay_solves`,
and additional `rank_bonus` may be added for early solves. and additional `rank_bonus` may be added for early solves.
(The exact scoring algorithm lives in the future solve-recording service
and is consumed by `SseHubService` whenever a new solve is published.)
# See also # See also
- [Database Schema Overview](/database/schema.md) - [Database Schema Overview](/database/schema.md)
- [System Endpoints](/api/system.md) (SSE scoreboard stream) - [System Endpoints](/api/system.md) (SSE scoreboard stream)
- [Scoreboard Stream](/guides/scoreboard-stream.md) - [Scoreboard Stream](/guides/scoreboard-stream.md)
- [Admin — Challenges](/guides/admin-challenges.md)
+136
View File
@@ -0,0 +1,136 @@
---
type: guide
title: Admin — Challenges
description: How an admin lists, searches, creates, edits, deletes, imports, and exports challenges from the /admin/challenges page.
tags: [guide, admin, challenges, import, export, files, tester]
timestamp: 2026-07-22T20:30:00Z
---
# When this view is available
The Challenges admin page renders at `/admin/challenges` for users with `role === 'admin'`. It is reached from the [Admin Shell](/guides/admin-shell.md) side-nav.
| Layer | File | Check |
|------------------|--------------------------------------------------------------------------------------------|------------------------------------|
| Client route | `frontend/src/app/app.routes.ts` | `/admin/challenges` lazy-loaded under `adminGuard`. |
| Client component | `frontend/src/app/features/admin/challenges/challenges.component.ts` | Smart container; owns search, sort, modal state. |
| Client modals | `frontend/src/app/features/admin/challenges/challenge-form-modal.component.ts` | Create / edit modal with General / Connection / Files tabs. |
| | `frontend/src/app/features/admin/challenges/challenge-delete-modal.component.ts` | Non-backdrop-dismissible delete confirmation. |
| | `frontend/src/app/features/admin/challenges/challenge-import-modal.component.ts` | Non-backdrop-dismissible import preview/result. |
| Server route | `backend/src/modules/admin/admin-challenges.controller.ts` | `/api/v1/admin/challenges*`, mounted under `AdminGuard` + `@Roles('admin')`. |
# How to access (tester steps)
1. Sign in as an admin user.
2. Open **Admin area****Challenges** in the side-nav, or visit `/admin/challenges` directly.
3. The page shows `Loading challenges…` (`data-testid="challenges-loading"`) while the request is in flight, then renders the list, an empty state, or a load error.
# Visual elements
| Element | Selector | Purpose |
|------------------------|--------------------------------------------|---------|
| Page section | `[data-testid="admin-challenges"]` | Root container. |
| Search input | `[data-testid="challenges-search"]` | Debounced (~250 ms) case-insensitive substring filter on name. |
| Add button | `[data-testid="challenges-add"]` | Opens the create modal. |
| Import button | `[data-testid="challenges-import"]` | Opens a file picker for `.json`; preview/confirm flow runs in the import modal. |
| Export button | `[data-testid="challenges-export"]` | Downloads `challenges-export-<ISO-date>.json`. |
| Status banner | `[data-testid="challenges-status"]` | `role="status"`, success message after CRUD/import/export. |
| Error banner | `[data-testid="challenges-error"]` | `role="alert"`, red load error. |
| Loading | `[data-testid="challenges-loading"]` | "Loading challenges…" placeholder. |
| Empty (unfiltered) | `[data-testid="challenges-empty"]` | Centered "No challenges yet" + Add challenge button. |
| Empty (filtered) | `[data-testid="challenges-empty-search"]` | "No challenges match '<q>'". |
| Table | `[data-testid="challenges-table"]` | One row per challenge. |
| Row | `[data-testid="challenge-row-<id>"]` | Row in the list. |
| Sortable Name | `[data-testid="challenges-sort-name"]` | Toggle asc/desc. |
| Edit row action | `[data-testid="challenges-row-edit"]` | Opens the edit modal (loads detail). |
| Delete row action | `[data-testid="challenges-row-delete"]` | Opens the delete confirmation modal. |
| Form modal | `[data-testid="challenge-form-modal"]` | Backdrop-dismissible (this is the only non-destructive modal). |
| Form tabs | `[data-testid="cf-tab-general" / -connection / -files"]` | Switch between sections. |
| Delete modal | `[data-testid="challenge-delete-modal"]` | Non-backdrop-dismissible; "Cancel" + "Delete". |
| Import modal | `[data-testid="challenge-import-modal"]` | Non-backdrop-dismissible; preview, confirm, result. |
# Form fields
The form modal has three sections (tabs): General, Connection, Files. Fields marked *required* are not nullable; the server enforces all rules and surfaces structured `400 VALIDATION_FAILED` errors that the modal renders inline (`data-testid="cf-error-<field>"`).
| Label | `data-testid` | Required | Notes |
|-------------------------------|------------------|----------|-------|
| Name (1128 chars) | `cf-name` | yes | Server enforces case-insensitive uniqueness; `CHALLENGE_NAME_TAKEN` is shown inline. |
| Description (Markdown) | `cf-desc` | yes | "Preview" toggle renders sanitized HTML using `MarkdownService`. |
| Category dropdown | `cf-category` | yes | Populated from `GET /api/v1/admin/categories`. |
| Difficulty radios | `cf-difficulty-*` | yes | LOW / MEDIUM / HIGH. |
| Initial points | `cf-initial` | yes | Integer 1100000 (default 100). |
| Minimum points | `cf-minimum` | yes | Integer 1100000; default = `floor(initialPoints / 2)`. Auto-updates while untouched; once edited, the value sticks. |
| Decay solves | `cf-decay` | yes | Integer 110000. |
| Secret flag (password) | `cf-flag` | yes | Stored plaintext server-side; toggled between `password` and `text` via `cf-flag-toggle`. The flag is omitted from any non-admin DTO. |
| Protocol radios | `cf-protocol-*` | yes | NC / WEB. |
| Port (NC: required) | `cf-port` | NC only | Integer 165535; cleared automatically when switching to WEB. |
| IP address | `cf-ip` | no | Free text — falls back to `setting.defaultChallengeIp` at solve time. |
| File picker (multiple) | `cf-upload` | no | Each pick is staged via `POST /api/v1/admin/challenges/files/stage`; the form's `Files` tab lists them with size and MIME. |
| File list | `cf-files` | n/a | Existing files (with `🗑` to mark for removal) plus staged uploads. |
# Expected behavior
## List & search
* The list is fetched via `GET /api/v1/admin/challenges` and contains the admin-only summary DTO (`categoryAbbreviation`, `categoryIconPath`, `difficulty`, `protocol`, `enabled`, `fileCount`, `solveCount`, `createdAt`, `updatedAt`). The secret flag is never in the response.
* Typing in the search input triggers a 250 ms debounced reload with `?q=`.
* Toggling the sort header flips `asc`/`desc` over `LOWER(name)`. The client re-sorts defensively in `applySort`.
* While the list is in flight, the toolbar buttons stay disabled and show an inline spinner.
* The icon column renders `<img src="iconPath">`; on `error` it hides the image and the abbreviation fallback appears as text.
## Create / Edit
1. Click **Add challenge** (or the row's ✎ button) → form modal opens in create (or edit) mode.
2. Edit mode fetches `GET /api/v1/admin/challenges/:id` to populate every field including `flag`, then renders sanitized description preview.
3. Save calls `POST` (or `PUT`) with the manifest of staged file tokens and existing-file removal ids.
4. Server-side errors map to inline field messages (`cf-error-<field>`) and stay on the modal; success closes the modal, refreshes the list, and surfaces a success banner.
5. Cancel/Esc/backdrop discards every pending staged token via `DELETE /files/stage/:token`.
## Delete
1. Click the row's 🗑 button → delete modal opens. Backdrop dismissal and Esc are intentionally disabled.
2. Confirm → `DELETE /api/v1/admin/challenges/:id`. The server runs one transaction that cascades `challenge_file` and `solve` rows, then best-effort unlinks the disk files. Failures to unlink are logged but do not abort the transaction.
3. Success closes the modal, refreshes the list, and announces a status banner.
## Export
* Click **Export challenges**`GET /api/v1/admin/challenges/export` returns a JSON attachment named `challenges-export-<ISO-date>.json`. The browser downloads it; the URL is revoked after a short delay.
## Import
1. Click **Import challenges** → a file picker accepts `.json`. The client reads it and posts it to `POST /api/v1/admin/challenges/import`.
2. On validation success the import modal opens with the preview (`total`, `conflicts`, `unknownCategories`, `warnings`) and the standard copy: *"Import N challenges. Existing challenges with matching names will be overwritten. New categories referenced but missing locally will be skipped with a warning. Continue?"*
3. Click **Overwrite & Import** → the client posts the same document with `?confirm=overwrite`. The server stages every file up front (rolling back any FS failure), then upserts challenges inside one transaction by case-insensitive name.
4. The response renders imported/skipped/warning counts; the list refreshes automatically.
# Validation & error codes (server)
| Code | HTTP | Triggered by |
|---------------------------------|------|-----------------------------------------------------------------------------|
| `VALIDATION_FAILED` | 400 | Zod schema failure on create/update/list query/import document. |
| `CHALLENGE_NAME_TAKEN` | 409 | Case-insensitive duplicate `name`. |
| `CHALLENGE_INVALID_CATEGORY` | 400 | `categoryId` does not match any existing category. |
| `IMPORT_VALIDATION_FAILED` | 400 | Import document fails top-level or per-challenge validation. |
| `IMPORT_PARTIAL_FAILURE` | 200 | Import committed but some entries were skipped (returns `{imported, skipped, warnings}`). |
| `NOT_FOUND` | 404 | `GET/PUT/DELETE` on a non-existent challenge id. |
# Architecture map
| Step | Where | What happens |
|------|--------------------------------------------------------------------------------------|--------------|
| 1 | `frontend/src/app/app.routes.ts` | `/admin/challenges` lazy-loads `AdminChallengesComponent`. |
| 2 | `frontend/src/app/features/admin/challenges/challenges.component.ts` | Owns search debounce, modal state, in-flight locks, refresh. |
| 3 | `frontend/src/app/core/services/admin.service.ts` | Typed `listChallenges`, `getChallengeDetail`, `createChallenge`, `updateChallenge`, `deleteChallenge`, `exportChallenges`, `previewChallengeImport`, `confirmChallengeImport`, `stageChallengeFile`, `discardChallengeFile`. |
| 4 | `frontend/src/app/features/admin/challenges/challenge-form.pure.ts` | Defaults/prefill, points/port validation, payload mapping. |
| 5 | `backend/src/modules/admin/admin-challenges.controller.ts` | `AdminGuard` + `@Roles('admin')` on every handler. |
| 6 | `backend/src/modules/admin/challenges.service.ts` | CRUD, list aggregates, transactional deletion, export, import orchestration. |
| 7 | `backend/src/modules/admin/challenge-files.service.ts` | Staging, commit, discard, read for export, limits. |
| 8 | `backend/src/modules/admin/dto/challenges.dto.ts` | Zod schemas for IDs, list query, create/update, staged-file manifest, import document with field paths. |
# Notes
* The flag is never present in any non-admin DTO. The player-facing challenges route and scoreboard remain unchanged; only admin endpoints can fetch the plaintext.
* All mutating endpoints require the standard CSRF cookie + header pair; the existing `csrfInterceptor` handles the headers automatically.
* The list query enforces a deterministic `LOWER(name), name` order so the UI sort is reproducible.
* Importing the same document twice is idempotent: every incoming challenge either replaces an existing row by lowercased name or inserts a new one.
+1 -1
View File
@@ -46,7 +46,7 @@ The side-nav is defined as a static `ENTRIES` array in
| Label | Path | Enabled | Renders | | Label | Path | Enabled | Renders |
|-------------|-----------------------|---------|----------------------------------------------------------------------------------------------| |-------------|-----------------------|---------|----------------------------------------------------------------------------------------------|
| General | `/admin/general` | yes | [Admin — General Settings](/guides/admin-general-settings.md) (default — `/admin` redirects here). | | General | `/admin/general` | yes | [Admin — General Settings](/guides/admin-general-settings.md) (default — `/admin` redirects here). |
| Challenges | `/admin/challenges` | no | Greyed-out placeholder (`.disabled` class). No route registered. | | Challenges | `/admin/challenges` | yes | [Admin — Challenges](/guides/admin-challenges.md) — sortable list, search, Add/Edit modal, file staging, import/export. |
| Players | `/admin/players` | no | Greyed-out placeholder. No route registered. | | Players | `/admin/players` | no | Greyed-out placeholder. No route registered. |
| Blog | `/admin/blog` | no | Greyed-out placeholder. No route registered. | | Blog | `/admin/blog` | no | Greyed-out placeholder. No route registered. |
| System | `/admin/system` | no | Greyed-out placeholder. No route registered. | | System | `/admin/system` | no | Greyed-out placeholder. No route registered. |
+4 -1
View File
@@ -11,7 +11,7 @@ scoreboard, an event window with a public countdown, theming, and admin
controls. controls.
The docs below are organized by purpose so agents can pull just the slice The docs below are organized by purpose so agents can pull just the slice
they need. Last regenerated 2026-07-22T19:54:00Z. they need. Last regenerated 2026-07-22T20:25:58Z.
# Architecture # Architecture
@@ -71,6 +71,9 @@ they need. Last regenerated 2026-07-22T19:54:00Z.
* [Admin — Categories](/guides/admin-categories.md) - How an admin lists, * [Admin — Categories](/guides/admin-categories.md) - How an admin lists,
creates, edits, and deletes challenge categories at `/admin/categories`, creates, edits, and deletes challenge categories at `/admin/categories`,
including system-row and challenge-attached protection. including system-row and challenge-attached protection.
* [Admin — Challenges](/guides/admin-challenges.md) - How an admin lists,
searches, creates, edits, deletes, imports, and exports challenges at
`/admin/challenges`, including file staging and overwrite confirm.
* [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED * [Authenticated Shell](/guides/authenticated-shell.md) - The header, LED
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame + countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page. every signed-in page.