diff --git a/docs/api/admin-system.md b/docs/api/admin-system.md new file mode 100644 index 0000000..ef1a252 --- /dev/null +++ b/docs/api/admin-system.md @@ -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-.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 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": "", + "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 | `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` | diff --git a/docs/architecture/backend-modules.md b/docs/architecture/backend-modules.md index db816cb..0066c3e 100644 --- a/docs/architecture/backend-modules.md +++ b/docs/architecture/backend-modules.md @@ -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) diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 08f7bcc..1268feb 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -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 `/.system-staging/restore-/`, 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 `/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 `/.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) diff --git a/docs/database/admin-operation-tokens-migration.md b/docs/database/admin-operation-tokens-migration.md new file mode 100644 index 0000000..f87d38b --- /dev/null +++ b/docs/database/admin-operation-tokens-migration.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. diff --git a/docs/database/admin-operation-tokens.md b/docs/database/admin-operation-tokens.md new file mode 100644 index 0000000..a8501f8 --- /dev/null +++ b/docs/database/admin-operation-tokens.md @@ -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) diff --git a/docs/database/schema.md b/docs/database/schema.md index f4667c7..a409791 100644 --- a/docs/database/schema.md +++ b/docs/database/schema.md @@ -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) diff --git a/docs/guides/admin-system-page.md b/docs/guides/admin-system-page.md new file mode 100644 index 0000000..e2758c6 --- /dev/null +++ b/docs/guides/admin-system-page.md @@ -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. diff --git a/docs/index.md b/docs/index.md index 06d600b..03b9307 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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.