AI Implementation feature(918): Admin Area System: Database Backup/Restore and Danger Zone 1.01 (#63)

This commit was merged in pull request #63.
This commit is contained in:
2026-07-23 13:28:29 +00:00
parent e611d201d9
commit e2d6bb3d69
8 changed files with 382 additions and 163 deletions
+27 -2
View File
@@ -4,7 +4,7 @@ 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
timestamp: 2026-07-23T13:27:25Z
---
# Overview
@@ -130,6 +130,31 @@ Flow:
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
@@ -288,4 +313,4 @@ curl -sS ... -d '{"operation":"reset-scores","token":"<raw>"}'
| 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` |
| 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` |
+3 -2
View File
@@ -3,7 +3,7 @@ type: architecture
title: Key Files Index
description: One-line responsibility for important source and contract-test files, including event-window, challenge, scoreboard, session, notification, and blog flows.
tags: [architecture, key-files, event-window, validation, default-challenge-ip, sse, bootstrap, migrations, challenges, import, board, notifications, per-user, session, blog]
timestamp: 2026-07-23T10:12:24Z
timestamp: 2026-07-23T13:27:25Z
---
# Backend
@@ -126,7 +126,7 @@ timestamp: 2026-07-23T10:12:24Z
| `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 `<DATA_DIR>/.system-staging/restore-<id>/`, 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/restore.service.ts` | Two-phase restore: `stageArchive(rawText, userId)` validates the archive (zod), decodes uploads into `<DATA_DIR>/.system-staging/restore-<id>/`, and returns a summary; `commitRestore(stagingId)` clones the live DB, captures and temporarily drops the `trg_user_last_admin_update` / `trg_user_last_admin_delete` triggers in the offline candidate, clears every application table, re-inserts archived rows in FK-safe order, asserts the archive still contains at least one admin, re-creates the captured triggers, 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; the rebuild also best-effort re-creates any missing trigger before rethrowing so a rolled-back candidate never leaks missing the `LAST_ADMIN` safety net. |
| `backend/src/modules/admin/system/danger-zone.service.ts` | `resetScores()` deletes every `solve` row transactionally; `wipeChallenges()` snapshots `<UPLOAD_DIR>/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. |
@@ -142,6 +142,7 @@ timestamp: 2026-07-23T10:12:24Z
| `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. |
| `tests/backend/admin-system-restore-commit.spec.ts` | Controller forwards `stagingId` to `RestoreService.commitRestore`, plus a real-file service-level regression that restores a backup containing the same admin as the live system, asserts swapped table settings + uploads + trigger preservation, and verifies an admin-less archive is rejected with `SYSTEM_RESTORE_ROLLED_BACK`. |
| `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. |
+11 -1
View File
@@ -3,7 +3,7 @@ type: database
title: User Table
description: The `user` table — accounts, roles, and statuses.
tags: [database, user, auth]
timestamp: 2026-07-21T17:37:18Z
timestamp: 2026-07-23T13:27:25Z
---
# Schema
@@ -31,6 +31,16 @@ timestamp: 2026-07-21T17:37:18Z
demote or delete the last admin are rejected by
`UsersService.enforceLastAdminInvariant` (used by
`AdminService.updateUserRole` and `AdminService.deleteUser`).
- A deployment-wide database safety net backs the invariant up at the
SQLite level via two triggers created by the
`AddUserLastAdminTriggers1700000000800` migration:
`trg_user_last_admin_update` (fires on `UPDATE OF role` when the old
row was admin, the new role is not, and no other admin row exists)
and `trg_user_last_admin_delete` (fires on `DELETE` of an admin row
when no other admin row exists). Both abort the offending statement
with `RAISE(ABORT, 'LAST_ADMIN')`. They are recreated by the restore
rebuild after the candidate database is rebuilt — see
[Admin System Endpoints — POST /restore/commit](/api/admin-system.md).
# First-admin bootstrap
+1 -1
View File
@@ -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-23T12:04:50Z.
they need. Last regenerated 2026-07-23T13:27:25Z.
# Architecture