docs: update documentation to OKF v0.1 format

This commit is contained in:
OpenVelo Agent
2026-07-23 12:10:36 +00:00
parent 6bac67fad7
commit 785d56c02d
8 changed files with 618 additions and 2 deletions
+291
View File
@@ -0,0 +1,291 @@
---
type: api
title: Admin System Endpoints
description: Admin-only destructive System operations: backup download, restore validate/commit, re-authenticated confirmation tokens, reset-scores, and wipe-challenges.
resource: backend/src/modules/admin/system/admin-system.controller.ts
tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens]
timestamp: 2026-07-23T12:04:50Z
---
# Overview
All handlers below are mounted under
`/api/v1/admin/system` and require a JWT-protected admin session
(`AdminGuard` + `@Roles('admin')`). Every destructive operation is
gated by a single-use confirmation token issued by the re-authentication
endpoint:
```
client ──reauth──▶ POST /confirmations ──token──▶ client
client ──commit──▶ POST /restore/commit | /scores/reset | /challenges/wipe
```
The token table is documented at
[/database/admin-operation-tokens.md](/database/admin-operation-tokens.md).
# Endpoints
| Method | Path | Source |
|--------|--------------------------------------------|-----------------------------------------------------------|
| `GET` | `/api/v1/admin/system/backup` | `admin-system.controller.ts``BackupService.build()` |
| `POST` | `/api/v1/admin/system/restore/validate` | `admin-system.controller.ts``RestoreService.stageArchive()` |
| `POST` | `/api/v1/admin/system/restore/commit` | `admin-system.controller.ts``RestoreService.commitRestore()` + `AuthService.revokeAllRefreshSessions()` |
| `POST` | `/api/v1/admin/system/confirmations` | `admin-system.controller.ts``AuthService.reauthenticateAdmin()` + `ConfirmationTokenService.issue()` |
| `POST` | `/api/v1/admin/system/scores/reset` | `admin-system.controller.ts``DangerZoneService.resetScores()` |
| `POST` | `/api/v1/admin/system/challenges/wipe` | `admin-system.controller.ts``DangerZoneService.wipeChallenges()` |
## GET /backup
Streams a complete platform snapshot as a JSON attachment. The body has
the shape:
```jsonc
{
"format": "hipctf-system-backup",
"version": 1,
"exportedAt": "2026-07-23T12:00:00.000Z",
"tables": { "user": [...], "challenge": [...], /* every application table */ },
"tableCounts": { "user": 12, "challenge": 4 },
"uploads": [
{ "path": "challenges/foo.bin",
"originalFilename": "foo.bin",
"sizeBytes": 1024,
"sha256": "...", "dataBase64": "..." }
]
}
```
Internal tables (`admin_operation_token`, `migrations`, all
`sqlite_*`) are excluded. The upload root is walked recursively and the
`.staging` directory is skipped. Files are base64-encoded inline so the
artifact is a single self-contained JSON file.
The response uses
`Content-Disposition: attachment; filename="hipctf-backup-<ISO>.json"`.
## POST /restore/validate
Body:
```jsonc
{ "text": "<raw backup JSON string>" } // or
{ "archive": { /* already-parsed object */ } }
```
The body is parsed with a strict zod schema (`RestoreArchiveSchema`).
Validation enforces:
* `format === 'hipctf-system-backup'`, `version === 1`
* every upload entry has a safe, non-`..` path, decodes from
base64 to exactly `sizeBytes`, and (when provided) matches `sha256`
* total restored bytes ≤ `BODY_SIZE_LIMIT`
Successful validation returns a `stagingId` plus a summary (sorted
table list, per-table counts, file count, total bytes, ISO expiresAt):
```jsonc
{
"stagingId": "f0c9...",
"expiresAt": "2026-07-23T12:15:00.000Z",
"summary": {
"tables": ["category", "challenge", "user", /* ... */],
"tableCounts": { "user": 12, "challenge": 4 },
"files": 3,
"totalBytes": 8192
}
}
```
The stage lives in-memory for `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default
15 min) and inside `<DATA_DIR>/.system-staging/restore-<id>/`.
Errors:
* `SYSTEM_RESTORE_VALIDATION_FAILED` (400) — JSON parse, schema, safe
path, duplicate path, base64, size mismatch, or checksum mismatch.
* `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` (400) — total bytes over
`BODY_SIZE_LIMIT`.
## POST /restore/commit
Body:
```jsonc
{
"operation": "restore-backup",
"token": "<confirmation token>",
"stagingId": "<stage returned from validate>"
}
```
Flow:
1. Verifies the admin role and that `operation === 'restore-backup'`.
2. `ConfirmationTokenService.consume()` verifies the token belongs to
this admin, is for `restore-backup`, is unexpired, and is unused.
3. `RestoreService.commitRestore(stagingId)` builds a new SQLite file
by cloning the live DB, then clearing every application table and
inserting the archived rows in FK-safe order. Uploads are copied
aside as `<UPLOAD_DIR>.restore-stage-<stamp>` and the live
database + uploads are swapped atomically via
`FilesystemTransactionService.stageSwap()` (with a `restore-backup`
rollback handle).
4. On any failure the swap is rolled back, the staged tree is removed,
and the error is mapped to `SYSTEM_RESTORE_ROLLED_BACK` (500).
5. On success, every refresh token belonging to the current admin is
revoked via `AuthService.revokeAllRefreshSessions()` so the SPA is
forced back to `/login`.
Returns `{ ok: true, operation: 'restore-backup' }`.
## POST /confirmations
Body:
```jsonc
{
"operation": "restore-backup" | "reset-scores" | "wipe-challenges",
"password": "<current admin password>",
"stagingId": "<optional, only when restoring>"
}
```
Flow:
1. `AuthService.reauthenticateAdmin(userId, password)` verifies the
user is still enabled + admin and the Argon2id password matches.
2. `ConfirmationTokenService.issue()` stores a SHA-256 hash of the
generated token and returns the raw token plus its `expiresAt`.
Returns:
```jsonc
{ "operation": "restore-backup", "token": "<raw>", "expiresAt": "2026-07-23T12:05:00.000Z" }
```
Errors:
* `SYSTEM_INVALID_CREDENTIALS` (401) — wrong password.
* `SYSTEM_REAUTH_REQUIRED` (401) — user is no longer admin/disabled.
## POST /scores/reset
Body:
```jsonc
{ "operation": "reset-scores", "token": "<confirmation token>" }
```
Consumes a `reset-scores` token, then `DangerZoneService.resetScores()`
runs a single `DELETE FROM solve` inside a transaction. Returns
`{ ok: true, solvesRemoved: N }`. Failure maps to `SYSTEM_DANGER_FAILED`
(500).
## POST /challenges/wipe
Body:
```jsonc
{ "operation": "wipe-challenges", "token": "<confirmation token>" }
```
Consumes a `wipe-challenges` token, then
`DangerZoneService.wipeChallenges()`:
1. Snapshots `<UPLOAD_DIR>/challenges` to `<challengesDir>.wipe-stage-<ts>`.
2. Runs a SQLite transaction that deletes every `challenge` row
(cascading via FK into `challenge_file` and `solve`).
3. After the DB commits, physically removes the live
`<UPLOAD_DIR>/challenges` directory. If the rm fails, the snapshot
is copied back into place.
4. Removes the snapshot only once the live tree is gone.
Returns
`{ ok: true, challengesRemoved: N, challengeFilesRemoved: M, solvesRemoved: K }`.
Failure maps to `SYSTEM_DANGER_ROLLED_BACK` (500).
# Error codes
| Code | HTTP | Source |
|-----------------------------------|------|-------------------------------------------------|
| `SYSTEM_BACKUP_FAILED` | 500 | `BackupService` table/file read failure |
| `SYSTEM_FILE_READ_FAILED` | 500 | `BackupService` upload walk failure |
| `SYSTEM_RESTORE_VALIDATION_FAILED`| 400 | `RestoreService` archive schema/size/sha mismatch |
| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`| 400 | Restored bytes exceed `BODY_SIZE_LIMIT` |
| `SYSTEM_RESTORE_STAGE_EXPIRED` | 400 | `stagingId` missing or expired |
| `SYSTEM_RESTORE_FAILED` | 500 | Live DB not readable after swap |
| `SYSTEM_RESTORE_ROLLED_BACK` | 500 | Restore aborted; original state restored |
| `SYSTEM_DANGER_FAILED` | 500 | Reset-scores internal error |
| `SYSTEM_DANGER_ROLLED_BACK` | 500 | Reset/wipe failed; data unchanged |
| `SYSTEM_PAYLOAD_INVALID` | 400 | `restore/validate` got neither `text` nor `archive` |
| `SYSTEM_REAUTH_REQUIRED` | 401 | `reauthenticateAdmin` user no longer admin/enabled |
| `SYSTEM_INVALID_CREDENTIALS` | 401 | Wrong password |
| `SYSTEM_TOKEN_INVALID` | 400 | Unknown token hash |
| `SYSTEM_TOKEN_EXPIRED` | 400 | Token `expiresAt` < now |
| `SYSTEM_TOKEN_REUSED` | 400 | Token already consumed |
| `SYSTEM_TOKEN_MISMATCH` | 400 | Token issued for a different operation/user |
# Examples
## Backup → restore on a fresh install
```bash
# 1. Download a backup
curl -sS -b cookies.txt -c cookies.txt \
-X GET 'http://localhost:3000/api/v1/admin/system/backup' \
-o hipctf-backup.json
# 2. Validate (and stage) it
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/restore/validate' \
-d "{\"text\": \"$(cat hipctf-backup.json | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))')\"}"
# => {"stagingId":"<id>", "expiresAt":"...", "summary":{...}}
# 3. Re-authenticate to get a confirmation token
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/confirmations' \
-d '{"operation":"restore-backup","password":"<currentPassword>","stagingId":"<id>"}'
# => {"operation":"restore-backup","token":"<raw>","expiresAt":"..."}
# 4. Commit
curl -sS -b cookies.txt -c cookies.txt \
-H 'Content-Type: application/json' \
-X POST 'http://localhost:3000/api/v1/admin/system/restore/commit' \
-d '{"operation":"restore-backup","token":"<raw>","stagingId":"<id>"}'
```
## Reset scores
```bash
# 1. Re-auth
curl -sS ... -d '{"operation":"reset-scores","password":"..."}' # → token
# 2. Commit
curl -sS ... -d '{"operation":"reset-scores","token":"<raw>"}'
# → {"ok":true,"solvesRemoved":42}
```
# Wiring
| Concern | File |
|------------------------------|---------------------------------------------------------------------------------|
| Controller | `backend/src/modules/admin/system/admin-system.controller.ts` |
| Module | `backend/src/modules/admin/system/admin-system.module.ts` |
| Backup | `backend/src/modules/admin/system/backup.service.ts` |
| Restore | `backend/src/modules/admin/system/restore.service.ts` |
| Confirmation tokens | `backend/src/modules/admin/system/confirmation-token.service.ts` |
| Danger zone | `backend/src/modules/admin/system/danger-zone.service.ts` |
| Filesystem swap | `backend/src/modules/admin/system/filesystem-transaction.service.ts` |
| Staging dir helper | `backend/src/common/utils/upload.ts` (`resolveSystemStagingDir`) |
| Password re-auth / revoke | `backend/src/modules/auth/auth.service.ts` (`reauthenticateAdmin`, `revokeAllRefreshSessions`) |
| Error codes | `backend/src/common/errors/error-codes.ts` |
| Front-end page | `frontend/src/app/features/admin/system/system.component.ts` |
| Front-end modal | `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` |
| Front-end service | `frontend/src/app/features/admin/system/system.service.ts` |
| Front-end pure helpers | `frontend/src/app/features/admin/system/system.pure.ts` |
| Front-end nav entry | `frontend/src/app/features/admin/admin-shell.component.ts` |
| Front-end route | `frontend/src/app/app.routes.ts` (admin child `system`) |
| Cross-store invalidation | `frontend/src/app/core/services/system-data-change.service.ts` |
| Forced logout helper | `frontend/src/app/core/services/auth.service.ts` (`forceServerInvalidation`) |
| Tests | `tests/backend/admin-system-authorization.spec.ts`, `admin-system-backup.spec.ts`, `admin-system-confirmation-token.spec.ts`, `admin-system-danger.spec.ts`, `admin-system-restore-validation.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` |
+4 -1
View File
@@ -27,7 +27,8 @@ 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` | 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`). | | `AdminModule` | `backend/src/modules/admin/admin.module.ts` | Five 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*`, `AdminSystemController` for `/api/v1/admin/system/*`) plus their services (`AdminService`, `AdminGeneralService`, `AdminCategoriesService`, `AdminChallengesService`, `ChallengeFilesService`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`). Gated by `AdminGuard` + `@Roles('admin')`. Imports `TypeOrmModule.forFeature([UserEntity, CategoryEntity, ChallengeEntity, ChallengeFileEntity, SolveEntity])`, `AuthModule`, `UsersModule`, `SettingsModule`, `CommonModule` (for `SseHubService`, `ThemeLoaderService`), and `AdminSystemModule` (which adds `AdminOperationTokenEntity` + `AuthModule`). |
| `AdminSystemModule` | `backend/src/modules/admin/system/admin-system.module.ts` | Hosts the destructive System operations: `AdminSystemController` + `BackupService` + `RestoreService` + `DangerZoneService` + `FilesystemTransactionService` + `ConfirmationTokenService`. Imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule` (for `reauthenticateAdmin`/`revokeAllRefreshSessions`). |
| `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`/`BlogService` expose published posts at `GET /api/v1/blog/posts`; `AdminBlogController`/`AdminBlogService` provide guarded CRUD at `/api/v1/admin/blog/posts` using `BlogPostEntity`. | | `BlogModule` | `backend/src/modules/blog/blog.module.ts` | `BlogController`/`BlogService` expose published posts at `GET /api/v1/blog/posts`; `AdminBlogController`/`AdminBlogService` provide guarded CRUD at `/api/v1/admin/blog/posts` using `BlogPostEntity`. |
| `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). | | `ChallengesModule` | `backend/src/modules/challenges/challenges.module.ts` | `ChallengesController` (`/api/v1/challenges/{board,status,:id,:id/solves}`) + `ChallengesEventsController` (authenticated SSE `/api/v1/events`) + `ChallengesService` (board, detail, submit, scoring util). |
@@ -44,6 +45,7 @@ also registers two global providers:
| `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` | | `AdminChallengesController` | `/api/v1/admin/challenges` | Admin only | `backend/src/modules/admin/admin-challenges.controller.ts` |
| `AdminSystemController` | `/api/v1/admin/system` | Admin only (plus confirmation tokens for destructive ops) | `backend/src/modules/admin/system/admin-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` | | `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` |
@@ -91,3 +93,4 @@ also registers two global providers:
- [Key Files Index](/architecture/key-files.md) - [Key Files Index](/architecture/key-files.md)
- [REST API Overview](/api/rest-overview.md) - [REST API Overview](/api/rest-overview.md)
- [Blog API](/api/blog.md) - [Blog API](/api/blog.md)
- [Admin System API](/api/admin-system.md)
+33
View File
@@ -123,6 +123,36 @@ timestamp: 2026-07-23T10:12:24Z
| `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. | | `tests/backend/scoreboard.service.spec.ts` | Service-level tests covering `computeAwardedPoints` parity with `submitFlag`, color-index stability, matrix ordering, and graph state handling. |
| `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. | | `tests/frontend/scoreboard.pure.spec.ts` | Pure helpers: `applyRankingSort` (competition-rank numbering), `parseSolveEventIntoRanking` (add-or-increment + sort), `dedupEventLogBySolveId`, `applySolveToGraph` (anchors + plateau + top-10 trim), `mutateMatrixFromSolve`, `stablePlayerColorIndex`, `formatSolveDateTime`. |
| `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. | | `tests/frontend/scoreboard.store.spec.ts` | Store lifecycle + live merge: `loadAll` aggregation + idempotency, SSE `solve` frame applying to all four tabs, `stop()` teardown, exponential reconnect. |
| `backend/src/modules/admin/system/admin-system.controller.ts` | Mounts `/api/v1/admin/system/*` (backup download, restore validate/commit, confirmations, scores/reset, challenges/wipe); all handlers require `AdminGuard` + `@Roles('admin')` and the destructive ones also consume a confirmation token. |
| `backend/src/modules/admin/system/admin-system.module.ts` | Wires `AdminSystemController`, `BackupService`, `RestoreService`, `DangerZoneService`, `FilesystemTransactionService`, `ConfirmationTokenService`; imports `TypeOrmModule.forFeature([AdminOperationTokenEntity])` and `AuthModule`. |
| `backend/src/modules/admin/system/backup.service.ts` | Builds the full backup JSON: every application table (`sqlite_master` discovery, excluding `admin_operation_token`/`migrations`/`sqlite_*`), with base64-encoded uploads walked recursively from `UPLOAD_DIR` (skipping `.staging`). Exposes `discoverTables()`, `getUploadDir()`, and `BackupService.stringify(doc)`. |
| `backend/src/modules/admin/system/restore.service.ts` | Two-phase restore: `stageArchive(rawText, userId)` validates the archive (zod), decodes uploads into `<DATA_DIR>/.system-staging/restore-<id>/`, and returns a summary; `commitRestore(stagingId)` clones the live DB, clears every application table, re-inserts archived rows in FK-safe order, then atomically swaps the live DB + uploads via `FilesystemTransactionService.stageSwap({ name: 'restore-backup', ... })`. On any failure the swap is rolled back and `SYSTEM_RESTORE_ROLLED_BACK` is returned. |
| `backend/src/modules/admin/system/danger-zone.service.ts` | `resetScores()` deletes every `solve` row transactionally; `wipeChallenges()` snapshots `<UPLOAD_DIR>/challenges` to a side directory, deletes every challenge inside a transaction, physically removes the live `-challenges` directory post-commit, and either deletes the snapshot or restores from it on failure. |
| `backend/src/modules/admin/system/filesystem-transaction.service.ts` | Generic file/directory swap primitive: `stageSwap({ pairs, hooks })` renames each live path to a rollback location, then renames the staged path into place (with copy+unlink cross-device fallback); `commit(handle)` removes rollback artifacts; `rollback(handle)` restores the original live paths. Provides static helpers `rmSafe`, `copyDirSync`, `ensureDir`. |
| `backend/src/modules/admin/system/confirmation-token.service.ts` | Issues SHA-256-hashed single-use tokens with `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` TTL; `consume()` runs in a transaction with conditional `WHERE consumedAt IS NULL` updates so only the first concurrent caller succeeds. `purgeExpired()` deletes expired and >24h-old consumed rows. |
| `backend/src/modules/admin/system/dto/admin-system.dto.ts` | Re-exports `ADMIN_OPERATION_KINDS` and zod contracts for the re-auth body, danger confirm body, and restore-commit body; declares the backup format identifier + version constants. |
| `backend/src/database/entities/admin-operation-token.entity.ts` | TypeORM entity and the canonical `ADMIN_OPERATION_KINDS` literal union (`restore-backup`, `reset-scores`, `wipe-challenges`). |
| `backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts` | Forward-only migration that creates the `admin_operation_token` table and its three indexes (`uq_admin_op_token_hash`, `idx_admin_op_token_user_op`, `idx_admin_op_token_expiry`). |
| `backend/src/common/utils/upload.ts` | Adds `resolveSystemStagingDir(config)` (defaults to `<DATA_DIR>/.system-staging`, never nested under `UPLOAD_DIR`) and `getRequestSizeLimit(config)` used by the system endpoints. |
| `backend/src/config/env.schema.ts` | Adds `SYSTEM_OP_STAGING_DIR`, `SYSTEM_OP_RESTORE_STAGE_TTL_MS` (default 15 min), `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS` (default 5 min). |
| `backend/src/common/errors/error-codes.ts` | Adds the `SYSTEM_*` error codes used by the system endpoints (`SYSTEM_BACKUP_FAILED`, `SYSTEM_RESTORE_VALIDATION_FAILED`, `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE`, `SYSTEM_RESTORE_STAGE_EXPIRED`, `SYSTEM_RESTORE_FAILED`, `SYSTEM_RESTORE_ROLLED_BACK`, `SYSTEM_OPERATION_IN_PROGRESS`, `SYSTEM_REAUTH_REQUIRED`, `SYSTEM_INVALID_CREDENTIALS`, `SYSTEM_TOKEN_INVALID`, `SYSTEM_TOKEN_EXPIRED`, `SYSTEM_TOKEN_REUSED`, `SYSTEM_TOKEN_MISMATCH`, `SYSTEM_DANGER_FAILED`, `SYSTEM_DANGER_ROLLED_BACK`, `SYSTEM_FILE_READ_FAILED`, `SYSTEM_PAYLOAD_INVALID`). |
| `backend/src/modules/auth/auth.service.ts` | Adds `reauthenticateAdmin(userId, password)` (Argon2id verify + admin/enabled status check) and `revokeAllRefreshSessions(userId, manager?)` (used after a successful restore to force the SPA back to `/login`). |
| `tests/backend/admin-system-authorization.spec.ts` | Non-admin JWTs and missing tokens are rejected on every `/api/v1/admin/system/*` endpoint. |
| `tests/backend/admin-system-backup.spec.ts` | Backup envelope shape, table discovery exclusion, and base64 file inclusion. |
| `tests/backend/admin-system-confirmation-token.spec.ts` | Issue/consume/expire/reuse/mismatch flow and TTL config. |
| `tests/backend/admin-system-danger.spec.ts` | Reset-scores and wipe-challenges happy path + DB/file rollback. |
| `tests/backend/admin-system-restore-validation.spec.ts` | Archive schema validation, base64 size mismatch, sha256 mismatch, safe-path rejection, and duplicate-path rejection. |
| `frontend/src/app/features/admin/system/system.component.ts` | `/admin/system` smart page: two panels (Database + Danger Zone), backup download, restore pick + validate, confirm modal trigger, and per-operation success/error surfacing + cross-store invalidation. |
| `frontend/src/app/features/admin/system/system-confirm-modal.component.ts` | Re-authentication + confirmation-phrase modal with operation-specific copy from `system.pure.ts`. |
| `frontend/src/app/features/admin/system/system.service.ts` | HTTP client for the six `/api/v1/admin/system/*` endpoints. |
| `frontend/src/app/features/admin/system/system.pure.ts` | Pure helpers: error → friendly message map, `destructiveCopy(op)` per-operation modal copy, `pickRestoreFile()` (jsdom-testable file picker), `deriveBackupFilename()`. |
| `frontend/src/app/core/services/system-data-change.service.ts` | Root-provided signal bus (`'scores-reset'`, `'challenges-wiped'`, `'restore-completed'`) consumed by `ChallengesStore` and `ScoreboardStore` to invalidate their caches after a destructive operation. |
| `frontend/src/app/core/services/auth.service.ts` | Adds `forceServerInvalidation()` so the restore flow can drop the local session and broadcast the cross-tab invalidation without invoking the backend logout. |
| `frontend/src/app/features/challenges/challenges.store.ts` | Now subscribes to `SystemDataChangeService` and calls `reset()` on every destructive operation kind. |
| `frontend/src/app/features/scoreboard/scoreboard.store.ts` | Now subscribes to `SystemDataChangeService` and clears ranking/matrix/event-log/graph on every destructive operation kind. |
| `frontend/src/app/features/admin/admin-shell.component.ts` | Side-nav entry for `system` is now `enabled: true`. |
| `frontend/src/app/app.routes.ts` | Adds the lazy `system` child route under the admin shell. |
| `tests/frontend/admin-system.pure.spec.ts` | Pure-helper contracts for the friendly error map, destructive copy, and file picker. |
# See also # See also
@@ -135,3 +165,6 @@ timestamp: 2026-07-23T10:12:24Z
- [Notifications](/guides/notifications.md) - [Notifications](/guides/notifications.md)
- [Blog Publishing and Reading](/guides/blog.md) - [Blog Publishing and Reading](/guides/blog.md)
- [Blog API](/api/blog.md) - [Blog API](/api/blog.md)
- [Admin System API](/api/admin-system.md)
- [Admin System Page Guide](/guides/admin-system-page.md)
- [Admin Operation Tokens Table](/database/admin-operation-tokens.md)
@@ -0,0 +1,38 @@
---
type: database
title: Admin Operation Tokens Migration
description: Forward-only migration that adds the `admin_operation_token` table and its indexes for destructive System-operation confirmation tokens.
resource: backend/src/database/migrations/1700000000900-AddAdminOperationTokens.ts
tags: [database, migration, admin, system, tokens]
timestamp: 2026-07-23T12:04:50Z
---
# Purpose
Adds the [admin_operation_token](/database/admin-operation-tokens.md)
table that backs the destructive operations under
`/api/v1/admin/system/*` (restore-backup, reset-scores,
wipe-challenges). Always fresh on a new database; on existing instances
the migration is a no-op if the table already exists.
# Up
1. `CREATE TABLE IF NOT EXISTS admin_operation_token` with the columns
listed in the [schema doc](/database/admin-operation-tokens.md#schema).
2. `CREATE UNIQUE INDEX IF NOT EXISTS uq_admin_op_token_hash` on
`token_hash` to make one-shot use enforceable.
3. `CREATE INDEX IF NOT EXISTS idx_admin_op_token_user_op` on
`(user_id, operation)` for fast per-admin lookup.
4. `CREATE INDEX IF NOT EXISTS idx_admin_op_token_expiry` on
`expires_at` so the periodic purge can use an index scan.
# Down
Drops the indexes in reverse order and drops the table.
# Wiring
The migration is registered in
`backend/src/database/database.module.ts` after the existing user
triggers migration. The entity is registered in the same `ENTITIES`
array so `forFeature` lets `AdminSystemModule` inject the repository.
+55
View File
@@ -0,0 +1,55 @@
---
type: database
title: Admin Operation Tokens
description: Single-use confirmation tokens backing the destructive admin System operations (restore-backup, reset-scores, wipe-challenges).
resource: backend/src/database/entities/admin-operation-token.entity.ts
tags: [database, admin, system, backup, restore, danger-zone, tokens]
timestamp: 2026-07-23T12:04:50Z
---
# Schema
| Column | Type | Description |
|---------------|-----------|----------------------------------------------------------------------------------------------------------|
| `id` | TEXT PK | UUIDv4 generated client-side; the primary key. |
| `user_id` | TEXT FK | `user.id` of the administrator who issued the token. FK `ON DELETE CASCADE` to `user`. |
| `operation` | TEXT | One of `restore-backup`, `reset-scores`, `wipe-challenges` (mirrors `ADMIN_OPERATION_KINDS`). |
| `token_hash` | TEXT | Hex-encoded SHA-256 of the raw token. The raw token is returned once and never logged or stored. Unique. |
| `issued_at` | TEXT | ISO-8601 timestamp at issuance. |
| `expires_at` | TEXT | ISO-8601 timestamp after which the token is invalid (default TTL: `SYSTEM_OP_CONFIRM_TOKEN_TTL_MS`, 5 min). |
| `consumed_at` | TEXT null | ISO-8601 timestamp when the token was consumed; null while still valid. |
# Indexes
| Index | Purpose |
|--------------------------------|--------------------------------------------------------|
| `uq_admin_op_token_hash` | `UNIQUE` on `token_hash` — guarantees one-shot use. |
| `idx_admin_op_token_user_op` | `(user_id, operation)` — fast per-admin/per-op lookup. |
| `idx_admin_op_token_expiry` | `expires_at` — enables the periodic expiry sweep. |
# Lifecycle
1. Admin re-authenticates with their current password via
`POST /api/v1/admin/system/confirmations`. The backend verifies the
password with Argon2id, then `ConfirmationTokenService.issue()`
generates a 32-byte base64url token, persists only its SHA-256 hash,
and returns the raw token plus its `expiresAt`.
2. The client passes the raw token (and the `stagingId` for restores)
to the matching destructive endpoint (`/restore/commit`,
`/scores/reset`, `/challenges/wipe`).
3. `ConfirmationTokenService.consume()` runs the consumption inside a
transaction: it looks the token up by hash, validates
`user_id` + `operation` (+ `stagingId` for restores), checks
`expires_at > now`, and finally performs a conditional update
`WHERE consumedAt IS NULL` so only the first concurrent caller
succeeds. A second attempt returns `SYSTEM_TOKEN_REUSED`.
4. `purgeExpired()` deletes expired rows and consumed rows older than
24h.
# Related
* Migration: [Admin Operation Tokens Migration](/database/admin-operation-tokens-migration.md)
* Service: `backend/src/modules/admin/system/confirmation-token.service.ts`
* Error codes: `SYSTEM_TOKEN_INVALID`, `SYSTEM_TOKEN_EXPIRED`,
`SYSTEM_TOKEN_REUSED`, `SYSTEM_TOKEN_MISMATCH`
* [Admin System API](/api/admin-system.md)
+3
View File
@@ -42,6 +42,7 @@ re-asserts the unique indexes on `system_key` and `abbreviation`.
| `solve` | User-solved challenges (scoring ledger). | | `solve` | User-solved challenges (scoring ledger). |
| `refresh_token` | Rotating refresh tokens (hash + revocation). | | `refresh_token` | Rotating refresh tokens (hash + revocation). |
| `blog_post` | Markdown blog posts in draft/published states. | | `blog_post` | Markdown blog posts in draft/published states. |
| `admin_operation_token` | Single-use confirmation tokens for destructive System operations. |
# Relationships # Relationships
@@ -68,6 +69,7 @@ user ───< refresh_token
| `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` | | `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` |
| `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` | | `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` |
| `blog_post` | `idx_blog_status_published` (`status`, `published_at`) | | `blog_post` | `idx_blog_status_published` (`status`, `published_at`) |
| `admin_operation_token` | `uq_admin_op_token_hash` (unique on `token_hash`), `idx_admin_op_token_user_op` (`user_id`, `operation`), `idx_admin_op_token_expiry` (`expires_at`) |
# PRAGMAs # PRAGMAs
@@ -91,3 +93,4 @@ On every process start, `DatabaseInitService.init()`:
- [Challenge Tables](/database/challenges.md) - [Challenge Tables](/database/challenges.md)
- [Auth and Settings Tables](/database/auth-settings.md) - [Auth and Settings Tables](/database/auth-settings.md)
- [Blog Post Table](/database/blog-posts.md) - [Blog Post Table](/database/blog-posts.md)
- [Admin Operation Tokens Table](/database/admin-operation-tokens.md)
+180
View File
@@ -0,0 +1,180 @@
---
type: guide
title: Admin — System (Backup, Restore, Danger Zone)
description: How an admin navigates to /admin/system, creates a backup, stages and commits a restore, resets all scores, and wipes challenges — including the re-authentication confirmation, progress, and side effects.
tags: [guide, admin, system, backup, restore, danger-zone, confirmation]
timestamp: 2026-07-23T12:04:50Z
---
# Navigation
1. Sign in as an admin and open the admin side-nav (see the
[Admin Shell guide](/guides/admin-shell.md)).
2. Click **System** in the side-nav. The route resolves to
`/admin/system` and lazily loads the
`AdminSystemComponent` standalone component.
3. The page renders two panels side by side: **Database** (left) and
**Danger Zone** (right). On narrow screens they stack vertically.
# Database panel
## Create a backup
1. Click **Create backup** (data-testid `admin-system-create-backup`).
A spinner appears inside the button while the request is in flight.
2. The browser receives a JSON file via `Content-Disposition:
attachment; filename="hipctf-backup-YYYY-MM-DD.json"` and the
browser download UI saves it to disk.
3. On success the panel shows a green **Backup ready.** status line
(data-testid `admin-system-backup-success`).
4. On failure the panel shows a red error message under the buttons
(data-testid `admin-system-backup-error`). The friendly message
comes from `system.pure.ts` and resolves `SYSTEM_BACKUP_FAILED`,
`SYSTEM_FILE_READ_FAILED`, `UNAUTHORIZED`, `FORBIDDEN`, etc.
## Restore a backup
1. Click **Restore backup** (data-testid `admin-system-restore-backup`).
A hidden file picker opens restricted to `application/json`/`.json`.
2. Pick a backup JSON file. The progress line shows
**Reading selected backup…**, then **Validating backup
server-side…** (data-testid `admin-system-restore-progress`).
3. The server validates the JSON, decodes uploads, and verifies
sizes/checksums. On success a summary card appears
(data-testid `admin-system-restore-summary`) showing:
* Tables detected
* Uploaded files
* Total bytes
* Stage expiry (ISO timestamp)
4. From the summary card, the admin has two options:
* **Discard staged backup** (data-testid
`admin-system-discard-restore`) — drops the staged state and
resumes from a clean panel.
* **Continue to destructive restore…** (data-testid
`admin-system-confirm-restore`) — opens the confirmation modal.
### Confirmation modal (restore-backup)
* The modal title is **Restore database?**.
* The detail paragraph explains that the entire database and uploads
will be replaced (the modal's `preserved` line says **Nothing is
preserved**).
* The stage banner explicitly warns: *You will be logged out after
restore completes.*
* The admin must enter their current password and type
`RESTORE BACKUP` exactly into the confirmation phrase field.
* Click **Confirm**. The modal swaps the input fields for a
*Working…* spinner.
* The frontend calls `POST /confirmations` with `{ operation:
'restore-backup', password, stagingId }` to receive a single-use
token, then immediately calls `POST /restore/commit` with that
token and the staging id.
* On success the SPA calls `AuthService.forceServerInvalidation()`,
resets the `UserStore`, and navigates to `/login`. The
`ChallengesStore` and `ScoreboardStore` invalidate themselves via
the `SystemDataChangeService` `'restore-completed'` event.
* On failure the modal stays open and shows the resolved error
(e.g. `SYSTEM_TOKEN_MISMATCH`, `SYSTEM_RESTORE_ROLLED_BACK`). The
database panel also reflects the error inline.
# Danger Zone panel
The danger zone panel is outlined by a dashed red border and contains
two red buttons:
| Button | Operation |
|--------------------------------------------|---------------------|
| `admin-system-reset-scores` | `reset-scores` |
| `admin-system-wipe-challenges` | `wipe-challenges` |
Each button opens the same confirmation modal with operation-specific
copy:
* **Reset all scores?** — detail lists what is removed
(every solve and award), preserved (users, sessions, roles,
challenges, categories, etc.). Confirmation phrase: `RESET SCORES`.
* **Wipe all challenges?** — detail explains that every challenge, its
attached files, every solve, and every award are removed, and that
every uploaded challenge file on disk is deleted. Preserved:
users, sessions, roles, categories, settings, blog posts, and
root-level uploads (e.g. the site logo). Confirmation phrase:
`WIPE CHALLENGES`.
After the admin enters their password and types the confirmation
phrase, the SPA re-authenticates to issue a token and then commits the
operation. The panel shows a green summary
(`N solve record(s) removed.` or `N challenge(s), M file(s) wiped.`)
and a toast notification. The corresponding stores
(`ChallengesStore`, `ScoreboardStore`) automatically reset themselves
via the `SystemDataChangeService` `scores-reset` and `challenges-wiped`
events so every open tab sees fresh data.
# Cross-tab invalidation
After any of the destructive operations, the
`SystemDataChangeService` notifies all subscribers in the same tab via
an Angular signal. The injected effects in `ChallengesStore` and
`ScoreboardStore` reset their cached board/matrix/event-log/graph
data. For a restore, the SPA additionally forces a cross-tab
invalidation by calling `AuthService.forceServerInvalidation()`,
which clears the access token and notifies peer tabs via
`BroadcastChannel` + the `storage`-event fallback so they redirect to
`/login` as well.
# Error mapping
The pure helper `friendlySystemErrorMessage(op, err)` in
`system.pure.ts` converts the backend error codes into user-facing
messages. Notable mappings:
| Backend code | UI message |
|---------------------------------------|----------------------------------------------------------------------------|
| `SYSTEM_REAUTH_REQUIRED` | "Your session is no longer valid. Please log in again." |
| `SYSTEM_INVALID_CREDENTIALS` | "The current password is incorrect." |
| `SYSTEM_TOKEN_INVALID` / `_EXPIRED` | "Confirmation token is invalid / expired. Re-authenticate and try again." |
| `SYSTEM_TOKEN_REUSED` | "Confirmation token was already used. Re-authenticate to get a fresh token." |
| `SYSTEM_TOKEN_MISMATCH` | "Confirmation token does not match this operation." |
| `SYSTEM_RESTORE_VALIDATION_FAILED` | "Backup validation failed. No data was changed." |
| `SYSTEM_RESTORE_PAYLOAD_TOO_LARGE` | "The backup is larger than the configured upload limit." |
| `SYSTEM_RESTORE_STAGE_EXPIRED` | "The backup staging window expired. Re-upload and validate again." |
| `SYSTEM_RESTORE_ROLLED_BACK` | "Restore failed and was rolled back. Your data is unchanged." |
| `SYSTEM_DANGER_ROLLED_BACK` | "Operation failed and was rolled back. Nothing was changed." |
| `SYSTEM_BACKUP_FAILED` | "Backup could not be generated. No data was changed." |
| `SYSTEM_DANGER_FAILED` | "Operation could not complete. No data was changed." |
| `SYSTEM_OPERATION_IN_PROGRESS` | "Another destructive operation is currently running." |
# Examples
## Manual restore happy path
1. Download the backup from a known-good environment.
2. Sign in as the admin on the target environment.
3. Navigate to `/admin/system`, click **Restore backup**, select the
downloaded file.
4. Verify the summary matches the expected table counts and file
counts.
5. Click **Continue to destructive restore…**, enter the admin
password, type `RESTORE BACKUP`, click **Confirm**.
6. The modal closes, the page shows a brief *Restore complete.
Logging out…* toast, and the SPA navigates to `/login`.
7. Sign in again with the same admin credentials. The data on the
target environment now matches the source backup.
## Manual reset happy path
1. Navigate to `/admin/system`.
2. Click **Reset all scores**, enter the password, type
`RESET SCORES`, click **Confirm**.
3. The danger zone panel shows the number of solve records removed
and a toast notifies the operation completed. Refresh the
Scoreboard page — every player is back at zero.
## Manual wipe happy path
1. Navigate to `/admin/system`.
2. Click **Wipe challenges**, enter the password, type
`WIPE CHALLENGES`, click **Confirm**.
3. The danger zone panel shows counts of challenges, files, and
solves removed. The Challenges page becomes empty and the
`/admin/challenges` list is empty. The `challenges/` folder on
disk is also gone.
+14 -1
View File
@@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and
signed-in blog views. signed-in blog views.
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-23T10:36:45Z. they need. Last regenerated 2026-07-23T12:04:50Z.
# Architecture # Architecture
@@ -43,6 +43,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
* [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token` * [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token`
and `setting` tables. and `setting` tables.
* [Blog Post Table](/database/blog-posts.md) - `blog_post` table. * [Blog Post Table](/database/blog-posts.md) - `blog_post` table.
* [Admin Operation Tokens Table](/database/admin-operation-tokens.md) -
Single-use confirmation tokens for destructive System operations.
* [Admin Operation Tokens Migration](/database/admin-operation-tokens-migration.md) -
Forward-only migration that adds the `admin_operation_token` table
and its indexes.
# API # API
@@ -63,6 +68,9 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
`/events/status` SSE stream. `/events/status` SSE stream.
* [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos. * [Uploads Endpoints](/api/uploads.md) - Admin-only multipart uploads for category icons, challenge files, and validated site logos.
* [Blog Endpoints](/api/blog.md) - Public published-post listing and admin-only post CRUD, validation, and publication behavior. * [Blog Endpoints](/api/blog.md) - Public published-post listing and admin-only post CRUD, validation, and publication behavior.
* [Admin System Endpoints](/api/admin-system.md) - Admin-only backup
download, restore validate/commit, re-authenticated confirmation
tokens, reset-scores, and wipe-challenges.
# Guides # Guides
@@ -87,6 +95,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
* [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) - * [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) -
Transactional full-replace semantics, side effects, and the auto-close Transactional full-replace semantics, side effects, and the auto-close
contract of the confirmed challenges import on `/admin/challenges`. contract of the confirmed challenges import on `/admin/challenges`.
* [Admin — System (Backup, Restore, Danger Zone)](/guides/admin-system-page.md) -
How an admin navigates to `/admin/system`, creates a backup, stages
and commits a restore, resets all scores, and wipes challenges,
including the re-authentication confirmation flow and cross-tab
invalidation.
* [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.