344 lines
16 KiB
Markdown
344 lines
16 KiB
Markdown
---
|
|
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-<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 as one recoverable generation via
|
|
`FilesystemTransactionService.stageSwap(..., { stagingDir })` (with a
|
|
`restore-backup` rollback handle). Before moving either live path, the
|
|
service writes `<SYSTEM_OP_STAGING_DIR>/<transactionId>/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 `<DATA_DIR>/.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": "<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 + 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` |
|