--- 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-23T15:46:55Z --- # 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-.json"`. ## POST /restore/validate Body: ```jsonc { "text": "" } // 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 `/.system-staging/restore-/`. 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": "", "stagingId": "" } ``` 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 `.restore-stage-` and the live database + uploads are swapped as one recoverable generation via `FilesystemTransactionService.stageSwap(..., { stagingDir })` (with a `restore-backup` rollback handle). Before moving either live path, the service writes `//manifest.json`. The manifest advances through `prepared`, `live-snapshotted`, `replacements-installed`, and `verified`; commit removes rollback and manifest artifacts. SQLite `-wal` and `-shm` sidecars travel with the database rollback generation and stale sidecars are removed from the replacement. The offline clone rebuild begins by capturing the exact SQL of the deployment-wide `trg_user_last_admin_update` and `trg_user_last_admin_delete` triggers from `sqlite_master`, dropping them in the candidate database, then clearing the application tables and re-inserting the archived rows. SQLite triggers fire regardless of `PRAGMA foreign_keys = OFF`, so clearing the sole admin row before the archive's admin is inserted would otherwise abort the rebuild with `LAST_ADMIN`. The suspension is confined to the offline candidate file. Before the candidate is eligible to swap, the rebuild: - asserts at least one `user.role = 'admin'` row exists in the rebuilt data (zero-admin archives fail with `SYSTEM_RESTORE_ROLLED_BACK` so the live system never lands in a no-admin state, even momentarily), - re-creates both captured triggers via `db.exec(sql)` so the `LAST_ADMIN` invariant is restored on the candidate, and - re-enables `PRAGMA foreign_keys` and runs `PRAGMA foreign_key_check`. On any rebuild, integrity, swap, or verification error the capture loop is wrapped in a `try/catch` that best-effort re-creates any missing trigger before rethrowing, so a rolled-back candidate never leaks to disk missing the `LAST_ADMIN` safety net. The original failure is then rethrown unchanged. 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`. ### Startup recovery contract Before TypeORM opens SQLite, `DatabaseInitService.init()` calls `FilesystemTransactionService.recoverTransactions()` using `SYSTEM_OP_STAGING_DIR` (default `/.system-staging`): | Manifest phase | Required complete generation | Startup action | |---|---|---| | `prepared` | Existing live paths | Keep the old generation and remove the transaction manifest. | | `live-snapshotted` | Every rollback path exists and corresponding live path is absent | Restore all rollback paths, including tracked SQLite sidecars. | | `replacements-installed` or `verified` | Every replacement live path and rollback path exists | Keep the complete new generation and remove rollback/staged artifacts. | | Any ambiguous or mixed state | Neither generation is complete | Throw `SYSTEM_RESTORE_FAILED`, preserve the manifest and artifacts, and abort startup. | | `committed` or `rolled-back` | Terminal | Remove the transaction directory. | After manifest recovery, startup best-effort removes stale unowned `.rollback-*`, `.restore-stage-*`, and `.wipe-stage-*` artifacts near the configured database/uploads paths. Fresh artifacts and paths referenced by a manifest are retained. Returns `{ ok: true, operation: 'restore-backup' }`. ## POST /confirmations Body: ```jsonc { "operation": "restore-backup" | "reset-scores" | "wipe-challenges", "password": "", "stagingId": "" } ``` 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": "", "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": "" } ``` 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": "" } ``` Consumes a `wipe-challenges` token, then `DangerZoneService.wipeChallenges()`: 1. Snapshots `/challenges` to `.wipe-stage-`. 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 `/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":"", "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":"","stagingId":""}' # => {"operation":"restore-backup","token":"","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":"","stagingId":""}' ``` ## Reset scores ```bash # 1. Re-auth curl -sS ... -d '{"operation":"reset-scores","password":"..."}' # → token # 2. Commit curl -sS ... -d '{"operation":"reset-scores","token":""}' # → {"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 + recovery | `backend/src/modules/admin/system/filesystem-transaction.service.ts` | | Shared filesystem module | `backend/src/modules/admin/system/filesystem-transaction.module.ts` | | Startup recovery wiring | `backend/src/database/database-init.service.ts`, `backend/src/database/database.module.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`, `admin-system-restore-commit.spec.ts`, `database-init-recovery.spec.ts`, `filesystem-transaction.spec.ts`; `tests/frontend/admin-system.pure.spec.ts` |