AI Implementation feature(871): Admin Area System: Database Backup/Restore and Danger Zone #61

Merged
m0rph3us1987 merged 2 commits from feature-871-1784805463846 into dev 2026-07-23 12:10:41 +00:00
8 changed files with 618 additions and 2 deletions
Showing only changes of commit 785d56c02d - Show all commits
+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). |
| `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`. |
| `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. |
| `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). |
@@ -44,6 +45,7 @@ also registers two global providers:
| `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` |
| `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` |
| `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` |
@@ -91,3 +93,4 @@ also registers two global providers:
- [Key Files Index](/architecture/key-files.md)
- [REST API Overview](/api/rest-overview.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/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. |
| `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
@@ -135,3 +165,6 @@ timestamp: 2026-07-23T10:12:24Z
- [Notifications](/guides/notifications.md)
- [Blog Publishing and Reading](/guides/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). |
| `refresh_token` | Rotating refresh tokens (hash + revocation). |
| `blog_post` | Markdown blog posts in draft/published states. |
| `admin_operation_token` | Single-use confirmation tokens for destructive System operations. |
# Relationships
@@ -68,6 +69,7 @@ user ───< refresh_token
| `solve` | `uq_solve_challenge_user` (unique), `idx_solve_user`, `idx_solve_challenge` |
| `refresh_token` | `idx_refresh_token_user`, unique on `token_hash` |
| `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
@@ -91,3 +93,4 @@ On every process start, `DatabaseInitService.init()`:
- [Challenge Tables](/database/challenges.md)
- [Auth and Settings Tables](/database/auth-settings.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.
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
@@ -43,6 +43,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
* [Auth and Settings Tables](/database/auth-settings.md) - `refresh_token`
and `setting` tables.
* [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
@@ -63,6 +68,9 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
`/events/status` SSE stream.
* [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.
* [Admin System Endpoints](/api/admin-system.md) - Admin-only backup
download, restore validate/commit, re-authenticated confirmation
tokens, reset-scores, and wipe-challenges.
# Guides
@@ -87,6 +95,11 @@ they need. Last regenerated 2026-07-23T10:36:45Z.
* [Admin — Challenges Full-Replace Import](/guides/admin-challenges-import.md) -
Transactional full-replace semantics, side effects, and the auto-close
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
+ countdown, quick-jump tabs, user menu, and SSE lifecycle that frame
every signed-in page.