14 KiB
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-23T13:27:25Z
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.
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:
{
"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:
{ "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 exactlysizeBytes, and (when provided) matchessha256 - 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):
{
"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 overBODY_SIZE_LIMIT.
POST /restore/commit
Body:
{
"operation": "restore-backup",
"token": "<confirmation token>",
"stagingId": "<stage returned from validate>"
}
Flow:
-
Verifies the admin role and that
operation === 'restore-backup'. -
ConfirmationTokenService.consume()verifies the token belongs to this admin, is forrestore-backup, is unexpired, and is unused. -
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 viaFilesystemTransactionService.stageSwap()(with arestore-backuprollback handle).The offline clone rebuild begins by capturing the exact SQL of the deployment-wide
trg_user_last_admin_updateandtrg_user_last_admin_deletetriggers fromsqlite_master, dropping them in the candidate database, then clearing the application tables and re-inserting the archived rows. SQLite triggers fire regardless ofPRAGMA foreign_keys = OFF, so clearing the sole admin row before the archive's admin is inserted would otherwise abort the rebuild withLAST_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 withSYSTEM_RESTORE_ROLLED_BACKso the live system never lands in a no-admin state, even momentarily), - re-creates both captured triggers via
db.exec(sql)so theLAST_ADMINinvariant is restored on the candidate, and - re-enables
PRAGMA foreign_keysand runsPRAGMA foreign_key_check.
On any rebuild, integrity, swap, or verification error the capture loop is wrapped in a
try/catchthat best-effort re-creates any missing trigger before rethrowing, so a rolled-back candidate never leaks to disk missing theLAST_ADMINsafety net. The original failure is then rethrown unchanged. - asserts at least one
-
On any failure the swap is rolled back, the staged tree is removed, and the error is mapped to
SYSTEM_RESTORE_ROLLED_BACK(500). -
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:
{
"operation": "restore-backup" | "reset-scores" | "wipe-challenges",
"password": "<current admin password>",
"stagingId": "<optional, only when restoring>"
}
Flow:
AuthService.reauthenticateAdmin(userId, password)verifies the user is still enabled + admin and the Argon2id password matches.ConfirmationTokenService.issue()stores a SHA-256 hash of the generated token and returns the raw token plus itsexpiresAt.
Returns:
{ "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:
{ "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:
{ "operation": "wipe-challenges", "token": "<confirmation token>" }
Consumes a wipe-challenges token, then
DangerZoneService.wipeChallenges():
- Snapshots
<UPLOAD_DIR>/challengesto<challengesDir>.wipe-stage-<ts>. - Runs a SQLite transaction that deletes every
challengerow (cascading via FK intochallenge_fileandsolve). - After the DB commits, physically removes the live
<UPLOAD_DIR>/challengesdirectory. If the rm fails, the snapshot is copied back into place. - 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
# 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
# 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, admin-system-restore-commit.spec.ts; tests/frontend/admin-system.pure.spec.ts |