--- type: api title: Admin Endpoints description: Admin-only endpoints for user management, required general settings, categories, and supporting uploads. tags: [api, admin, users, general, categories, default-challenge-ip, ip-hostname, validation] timestamp: 2026-07-22T15:46:18Z --- # Endpoints All handlers below are mounted behind `JwtAuthGuard` (global) + `AdminGuard` + `@Roles('admin')`, so they require a valid admin JWT. CSRF is enforced on every mutating call (standard cookie + header pattern; nothing is added to the skip list). ## User management — `/api/v1/admin` | Method | Path | Source | |----------|-------------------------------|---------------------------------------------------| | `GET` | `/api/v1/admin/users` | `backend/src/modules/admin/admin.controller.ts` | | `POST` | `/api/v1/admin/users` | Same. | | `PATCH` | `/api/v1/admin/users/:id` | Same. | | `DELETE` | `/api/v1/admin/users/:id` | Same. | `AdminService` enforces the **last-admin invariant** (`LAST_ADMIN` / `409`) — the final admin row cannot be demoted or deleted. `POST` creates a new user; `PATCH .../:id` changes role / status; `DELETE .../:id` removes the row. ## General settings — `/api/v1/admin/general` | Method | Path | Source | |--------|---------------------------------------|-------------------------------------------------------------| | `GET` | `/api/v1/admin/general/settings` | `backend/src/modules/admin/admin-general.controller.ts` | | `PUT` | `/api/v1/admin/general/settings` | Same. | | `GET` | `/api/v1/admin/general/themes` | Same. | `GET /settings` returns the full `GeneralSettingsView` (`pageTitle`, `logo`, `welcomeMarkdown`, `themeKey`, `eventStartUtc`, `eventEndUtc`, `defaultChallengeIp`, `registrationsEnabled`). `PUT /settings` is validated by `GeneralSettingsSchema` (zod): * `pageTitle` is trimmed server-side and must be 1–120 chars after trimming (whitespace-only values are rejected). * `logo` up to 2048 chars (a public URL — uploaded separately) * `welcomeMarkdown` up to 64 000 chars * `themeKey` is one of `THEME_IDS` * `eventStartUtc` / `eventEndUtc` must each be non-empty ISO-8601 datetimes. Empty and malformed strings produce a field-specific `400 VALIDATION_FAILED` issue (`eventStartUtc must be a valid ISO-8601 datetime` or `eventEndUtc must be a valid ISO-8601 datetime`). `superRefine` additionally requires `eventEndUtc > eventStartUtc` and reports `eventEndUtc must be strictly after eventStartUtc` on the end field when both values parse but are out of order. * `defaultChallengeIp` is trimmed server-side; empty/whitespace-only values are rejected (`defaultChallengeIp is required and cannot contain only whitespace`), and the trimmed value must be a complete IPv4 address or a valid hostname, otherwise `400 VALIDATION_FAILED` with a per-field `defaultChallengeIp` issue (`defaultChallengeIp must be a valid IPv4 address or hostname`). Maximum length is 255 characters. * `registrationsEnabled` boolean On success the handler persists every field via `SettingsService`, emits an SSE `general` event (`{ topic: 'general', themeKey }`) via `SseHubService`, and returns the updated view. `GET /themes` returns the `ThemeView[]` for which a corresponding JSON file exists under `THEMES_DIR`. Each item is `{ id, key, name }`. See [Admin — General Settings](/guides/admin-general-settings.md). ## Categories — `/api/v1/admin/categories` | Method | Path | Source | |----------|-------------------------------------|-----------------------------------------------------------------| | `GET` | `/api/v1/admin/categories` | `backend/src/modules/admin/admin-categories.controller.ts` | | `POST` | `/api/v1/admin/categories` | Same. | | `PUT` | `/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-.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: * `GET` returns all categories sorted by `LOWER(abbreviation)` ascending. * `POST` uppercases the abbreviation, rejects duplicates with `409 CONFLICT`, and persists the row. Body validated by `CreateCategorySchema` (`name` 1–120 chars; `abbreviation` 2–6 chars; `description` up to 2000 chars; `iconPath` optional, up to 2048 chars). * `PUT` validates the body with `UpdateCategorySchema` (every field optional). System rows (`system_key IS NOT NULL`) cannot change their abbreviation — server returns `409 SYSTEM_PROTECTED`. Duplicate abbreviations on user rows return `409 CONFLICT`. Updates the `updated_at` timestamp on success. * `DELETE` is blocked for system rows (`403 SYSTEM_PROTECTED`) and for rows with at least one attached challenge (`409 CATEGORY_HAS_CHALLENGES` with `{ count }` in `details`). Returns `404 NOT_FOUND` for unknown ids. Response shape (`CategoryView`): ```json { "id": "uuid", "name": "Cryptography", "abbreviation": "CRY", "description": "Cryptographic challenges", "iconPath": "/uploads/icons/CRY.png", "isSystem": true, "systemKey": "CRY", "createdAt": "2026-07-21T18:00:00.000Z", "updatedAt": "2026-07-21T18:00:00.000Z" } ``` See [Admin — Categories](/guides/admin-categories.md). ## Supporting uploads Logo and category-icon uploads live on the uploads module and are documented in [Uploads Endpoints](/api/uploads.md): | Method | Path | Purpose | |--------|-----------------------------------|----------------------------------| | `POST` | `/api/v1/uploads/logo` | Logo used by General settings. | | `POST` | `/api/v1/uploads/category-icon` | Icon used by Categories. | Both endpoints are admin-only and use `FileInterceptor('file')` for a single multipart part named `file`. # Guard chain 1. `JwtAuthGuard` (global) validates the access token unless the handler is `@Public()`. Admin handlers are not. 2. `AdminGuard` (`backend/src/common/guards/admin.guard.ts`) requires `req.user.role === 'admin'`. 3. `RolesGuard` enforces `@Roles('admin')` metadata on the controller. # See also - [Backend Module Map](/architecture/backend-modules.md) - [Admin Shell](/guides/admin-shell.md) - [Admin — General Settings](/guides/admin-general-settings.md) - [Admin — Categories](/guides/admin-categories.md) - [Uploads Endpoints](/api/uploads.md) - [REST API Overview](/api/rest-overview.md)