Files
HIPCTF2/docs/api/admin-system.md
T

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.tsBackupService.build()
POST /api/v1/admin/system/restore/validate admin-system.controller.tsRestoreService.stageArchive()
POST /api/v1/admin/system/restore/commit admin-system.controller.tsRestoreService.commitRestore() + AuthService.revokeAllRefreshSessions()
POST /api/v1/admin/system/confirmations admin-system.controller.tsAuthService.reauthenticateAdmin() + ConfirmationTokenService.issue()
POST /api/v1/admin/system/scores/reset admin-system.controller.tsDangerZoneService.resetScores()
POST /api/v1/admin/system/challenges/wipe admin-system.controller.tsDangerZoneService.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 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):

{
  "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:

{
  "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).

    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.

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:

  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:

{ "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():

  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

# 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