diff --git a/docs/api/admin-system.md b/docs/api/admin-system.md index ef1a252..9276b31 100644 --- a/docs/api/admin-system.md +++ b/docs/api/admin-system.md @@ -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":""}' | 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` | diff --git a/docs/architecture/key-files.md b/docs/architecture/key-files.md index 1268feb..62167e8 100644 --- a/docs/architecture/key-files.md +++ b/docs/architecture/key-files.md @@ -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 `/.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/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, 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 `/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. | diff --git a/docs/database/users.md b/docs/database/users.md index 30b0e66..13982de 100644 --- a/docs/database/users.md +++ b/docs/database/users.md @@ -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 diff --git a/docs/index.md b/docs/index.md index 03b9307..444ae6a 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-23T12:04:50Z. +they need. Last regenerated 2026-07-23T13:27:25Z. # Architecture