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
-155
View File
@@ -1,155 +0,0 @@
# Implementation Plan: Job 916 — Admin System Restore Commit (stagingId stripped from request body)
## Root cause (read-only investigation)
The destructive restore never executes because the Zod schema used to validate
`POST /api/v1/admin/system/restore/commit` **drops the `stagingId` field** from
the request body before the controller runs.
`backend/src/modules/admin/system/dto/admin-system.dto.ts:23`:
```ts
export const AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchema;
```
`AdminDangerConfirmBodySchema` (line 18) only allows `{ operation, token }`.
Zod's `safeParse` / the NestJS `ZodValidationPipe` therefore strip every
unknown field — including `stagingId` — from the parsed body. The controller
then receives `body.stagingId === undefined`, falls back to the empty string
(`body.stagingId ?? ''`), and `RestoreService.commitRestore('')` finds no
matching stage and throws `SYSTEM_RESTORE_STAGE_EXPIRED`
(`RestoreService.commitRestore` at `restore.service.ts:223-231`).
The frontend already sends the field correctly
(`frontend/src/app/features/admin/system/system.service.ts:42`):
```ts
async commitRestore(payload: { operation: 'restore-backup'; token: string; stagingId: string })
```
so the deviation is purely server-side. The negative path (random JSON) still
fails with `SYSTEM_RESTORE_VALIDATION_FAILED` because the malformed archive
rejects at `validate`, never reaching `commit`.
The fix is to give `AdminRestoreCommitBodySchema` its own declaration that
**includes** `stagingId` (mirroring `AdminReauthBodySchema` at line 12-16), and
to tighten the controller so a missing `stagingId` returns a precise
`SYSTEM_RESTORE_STAGE_EXPIRED` instead of relying on the empty-string fallback.
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** TypeScript monorepo (`backend` + `frontend`).
Backend is NestJS with `class-validator`/`zod` mixed; the admin-system module
uses `ZodValidationPipe` per-route. Strict async/await, error handling via
`ApiError` + `ERROR_CODES` (`backend/src/common/errors/`).
- **Data Layer:** SQLite via `better-sqlite3` + TypeORM. Restore streams are
in-memory (`RestoreService.stages: Map<stagingId, StagedRestore>`) plus a
sibling directory tree under `<DATA_DIR>/.system-staging/restore-<id>/`.
- **Test Framework & Structure:** Jest with two projects in
`tests/jest.config.js`. Tests live in `tests/backend/*.spec.ts` and
`tests/frontend/*.spec.ts`. Single command from the root:
`npm test` (already wired in `package.json`).
- **Required Tools & Dependencies:** No new tools. Existing: Node 20, NestJS,
better-sqlite3, zod, Jest, ts-jest. `setup.sh` already builds the app and
rebuilds the native binding — no updates needed.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/system/dto/admin-system.dto.ts` — replace the
`AdminRestoreCommitBodySchema` alias with a proper schema that accepts
`stagingId` (required for `restore-backup`).
- `backend/src/modules/admin/system/admin-system.controller.ts` — validate
`stagingId` is present before consuming the token, and stop feeding an
empty string into `commitRestore`.
- **To Create:**
- `tests/backend/admin-system-restore-commit.spec.ts` — focused e2e test
that the `restore/commit` endpoint forwards `stagingId` to
`RestoreService.commitRestore` and atomically swaps the live DB.
## 3. Proposed Changes
1. **DTO fix**`backend/src/modules/admin/system/dto/admin-system.dto.ts`
- Remove the `AdminRestoreCommitBodySchema = AdminDangerConfirmBodySchema`
alias (line 23).
- Add a dedicated schema that requires `stagingId` for `restore-backup`
while still letting the safe schema accept the other two operations in
shared code paths:
```ts
export const AdminRestoreCommitBodySchema = z.object({
operation: z.enum(ADMIN_OPERATION_KINDS),
token: z.string().min(1).max(512),
stagingId: z.string().min(1).max(128).optional(),
});
```
- Keep the `AdminOperationKind` re-export and the
`AdminRestoreCommitBody` `z.infer` type aligned so the controller
signature remains `body: { operation; token; stagingId? }`.
2. **Controller hardening** — `backend/src/modules/admin/system/admin-system.controller.ts:95-121`
- After the `body.operation === 'restore-backup'` check, throw
`SYSTEM_RESTORE_STAGE_EXPIRED` with HTTP 400 if `body.stagingId` is
falsy. This produces a precise error instead of the empty-string
fallback that currently slips through to `RestoreService`.
- Pass `body.stagingId` directly to `commitRestore` (no `?? ''`).
- Existing behavior for `reset-scores` / `wipe-challenges` (which use
`AdminDangerConfirmBodySchema`) is unchanged.
3. **No frontend changes required.** `system.service.ts` already POSTs
`{ operation, token, stagingId }`; the modal already calls it after
`confirmations`. Once the server schema preserves `stagingId` the happy
path works end-to-end.
4. **No DB migration needed.** `admin_operation_token` already exists;
`stagingId` is bound through the in-memory `RestoreService.stages` map.
## 4. Test Strategy
- **Target Unit Test File:** `tests/backend/admin-system-restore-commit.spec.ts` (new).
Companion lightweight spec additions to
`tests/backend/admin-system-restore-validation.spec.ts` are optional; the
new file is sufficient and keeps the diff small.
- **Mocking Strategy:**
- Build a minimal `Test.createTestingModule` with just
`AdminSystemController`, `RestoreService`, `ConfirmationTokenService`,
`AuthService`, `BackupService`, `DangerZoneService`, the
`AdminOperationTokenEntity` repository, the `RestoreArchiveSchema` DTO,
and a `Role`-aware stub for `AdminGuard` (re-use the approach from
`tests/backend/admin-system-authorization.spec.ts`).
- Stub `ConfirmationTokenService.consume` to record the
`{ userId, operation, stagingId }` argument and resolve `{ id: 'tok' }`.
- Stub `RestoreService.commitRestore` to record its argument and resolve
`{ restoresPerformed: true, revokeUserId: userId }`.
- Stub `AuthService.reauthenticateAdmin` to resolve an admin user, and
`AuthService.revokeAllRefreshSessions` to a no-op.
- Stub `RestoreService.stageArchive` to return a fixed
`{ stagingId: 'stage-1', expiresAt, summary }` so the validate→confirm
→commit pipeline can be exercised when needed.
- **Cases covered (minimal, focused):**
1. `commitRestore` forwards `stagingId` to `RestoreService.commitRestore`
and returns `{ ok: true, operation: 'restore-backup' }` when the body
contains `{ operation, token, stagingId }`.
2. `commitRestore` returns
`SYSTEM_RESTORE_STAGE_EXPIRED` (HTTP 400) when `stagingId` is omitted
**before** consuming the token (i.e. the fix returns the error early
rather than reaching `RestoreService`).
3. `commitRestore` returns `SYSTEM_TOKEN_MISMATCH` when `operation` is
`reset-scores` (regression guard for the existing controller branch).
- **Run command:** `npm test` from the repo root (already wired in
`package.json:test`).
## 5. Verification
- Manual smoke test against the dev stack (`npm run dev`):
1. POST `/api/v1/admin/system/restore/validate` with a valid backup → expect
`stagingId`.
2. POST `/api/v1/admin/system/confirmations` with `{ operation: 'restore-backup', password, stagingId }` → expect `token`.
3. POST `/api/v1/admin/system/restore/commit` with `{ operation: 'restore-backup', token, stagingId }`
→ expect `{ ok: true, operation: 'restore-backup' }`, the live DB swapped,
and the admin session revoked (redirect to `/login`).
- The error path must still surface
`SYSTEM_RESTORE_VALIDATION_FAILED` for malformed JSON bodies.
+32
View File
@@ -0,0 +1,32 @@
# Implementation Plan: Job 918 — Admin Area System Database Backup/Restore and Danger Zone 1.01
## 1. Architectural Reconnaissance
- **Codebase style & conventions:** Node.js monorepo using strict TypeScript, NestJS controllers/services on the backend, Angular standalone components and signals on the frontend, async/await, dependency injection, Zod request/archive validation, and stable `ApiError` codes. The requested UI and API flow already exists; the defect is isolated to the backend restore rebuild. `RestoreService.cloneAndOverwriteDatabase()` clones the live SQLite file and executes `DELETE` statements while the live schema's `trg_user_last_admin_delete` trigger remains active. `PRAGMA foreign_keys = OFF` does not disable triggers, so deleting the sole live admin raises `LAST_ADMIN` before archived users can be inserted.
- **Data Layer:** SQLite through TypeORM's `better-sqlite3` driver. Runtime migrations create a database-level invariant with `trg_user_last_admin_update` and `trg_user_last_admin_delete`. Backups dynamically include application tables but exclude `admin_operation_token`, `migrations`, and SQLite internals. Restore uses a cloned database, direct parameterized `better-sqlite3` statements, and a database/uploads filesystem swap with rollback handles.
- **Test Framework & Structure:** Jest 29 with `ts-jest`; backend tests live under `/repo/tests/backend` and are selected by `/repo/tests/jest.config.js`. Root `npm test` runs backend and frontend projects, while `npm run test:backend` runs the focused backend suite. Tests must remain CLI-only and use isolated temporary SQLite/upload/staging paths rather than shared mutable resources.
- **Required Tools & Dependencies:** No new package, system tool, global CLI, or persistent `/data` asset is required. Existing Node.js/npm, TypeScript, Jest, Nest testing utilities, TypeORM, and `better-sqlite3` are sufficient. `setup.sh` already installs dependencies and rebuilds the native SQLite binding, so it should not be changed.
## 2. Impacted Files
- **To Modify:**
- `backend/src/modules/admin/system/restore.service.ts` — make the offline clone rebuild temporarily suspend the last-admin triggers, restore all archived rows, validate invariants, and reinstate the triggers before the file is eligible for swapping.
- `tests/backend/admin-system-restore-commit.spec.ts` — add a focused real-file restore regression covering a backup that contains the same sole admin as the live database, restored table content, uploads, and trigger preservation; retain the existing controller/DTO contract coverage.
- **To Create:** None.
## 3. Proposed Changes
1. **Database / Schema Migration:**
- Do not add or alter a migration: the `LAST_ADMIN` triggers are correct for ordinary runtime user mutations.
- In the private staged database clone only, read the exact SQL definitions of `trg_user_last_admin_update` and `trg_user_last_admin_delete` from `sqlite_master`, fail closed if the expected trigger definitions cannot be captured, and drop those triggers before clearing application tables.
- Keep foreign keys disabled during the bulk replacement, clear all known application tables, and insert archived rows in the existing dependency-safe order with parameterized values.
- Before closing the staged clone, verify the archive produced at least one admin user so a restore cannot bypass the deployment-wide invariant. Recreate both captured triggers exactly, re-enable foreign keys, run `PRAGMA foreign_key_check`, and treat any violations or trigger-restoration failure as a restore failure. This confines the temporary invariant suspension to an offline candidate file and ensures the candidate has full protections before swap.
2. **Backend Logic & APIs:**
- Preserve the existing `POST /api/v1/admin/system/restore/validate`, confirmation-token, and `POST /restore/commit` contracts; `stagingId` already survives Zod validation and reaches `RestoreService.commitRestore()`.
- Refactor the staged rebuild cleanup so database closure and pragma/trigger restoration are deterministic and original failures are not masked by cleanup. Any rebuild, integrity, swap, or verification error must continue through the existing rollback path and return `SYSTEM_RESTORE_ROLLED_BACK` without changing live data/uploads.
- Keep the existing filesystem transaction: build and validate the candidate first, copy staged uploads beside the live upload root, swap database and uploads together, verify the swapped database, then commit rollback artifacts.
- Keep post-success session revocation in `AdminSystemController.commitRestore()`. After the restored database is live, revoke refresh sessions for the authenticated admin ID; the frontend's already-wired `forceServerInvalidation()` then clears the access session and redirects all tabs to login.
3. **Frontend UI Integration:**
- No frontend changes are planned. The file picker, staging summary, reauthentication modal, confirmation phrase, restore commit request, rollback alert, data-cache invalidation, and forced-login behavior are already wired in `frontend/src/app/features/admin/system/system.component.ts`, `system.service.ts`, and the auth/system data-change services.
## 4. Test Strategy
- **Target Unit Test File:** `tests/backend/admin-system-restore-commit.spec.ts`.
- **Mocking Strategy:** Keep existing controller dependencies mocked for request forwarding/token/session-revocation assertions. Add one minimal service-level integration regression using a unique temporary directory, a real file-backed `better-sqlite3` database, real `RestoreService`, and real `FilesystemTransactionService`; mock only `ConfigService` and the live `DataSource.query` verification boundary as needed. Arrange a live database with the migrated last-admin triggers, one sole admin, pre-restore challenge/blog/setting rows, and live uploads; stage a valid archive containing that same admin plus different table values and replacement challenge/icon files; commit; then open the swapped file independently and assert archived counts/settings, exact upload replacement, and both last-admin triggers still exist and reject deleting the sole restored admin. Add one focused invalid-archive invariant case with no admin and assert `SYSTEM_RESTORE_ROLLED_BACK` plus unchanged live database/uploads. Clean all temporary files in `afterEach`/`afterAll`; no UI, network, visual checks, shared `/data`, or large mock environment.
- **Execution:** Follow red-green-refactor: first run the focused backend spec to confirm the `LAST_ADMIN` failure, implement the smallest restore-service correction, rerun the focused spec, then run `npm run test:backend`, root `npm test`, `npm --workspace backend run build`, and the repository lint/typecheck commands if present (the current package scripts expose build but no dedicated lint/typecheck command).
@@ -343,7 +343,32 @@ export class RestoreService {
} }
const db = new Database(targetPath); const db = new Database(targetPath);
db.pragma('foreign_keys = OFF'); db.pragma('foreign_keys = OFF');
const capturedTriggerSqls: string[] = [];
try { try {
// Capture and temporarily drop the deployment-wide last-admin triggers.
// SQLite triggers fire regardless of foreign_keys, so clearing the
// sole admin row before inserting the archive's admin would abort the
// rebuild. We only mutate an offline candidate file and reinstate
// them before swap eligibility.
const triggerRows = db
.prepare(
`SELECT name, sql FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete') AND sql IS NOT NULL`,
)
.all() as Array<{ name: string; sql: string }>;
const capturedTriggerNames = new Set(triggerRows.map((r) => r.name));
if (
!capturedTriggerNames.has('trg_user_last_admin_update') ||
!capturedTriggerNames.has('trg_user_last_admin_delete')
) {
throw new Error(
'Required LAST_ADMIN triggers missing from cloned schema; refusing to rebuild',
);
}
for (const row of triggerRows) {
capturedTriggerSqls.push(row.sql);
db.prepare(`DROP TRIGGER IF EXISTS "${row.name}"`).run();
}
// Discover the tables present in the live schema; only these may be // Discover the tables present in the live schema; only these may be
// restored. This protects against accidentally injecting unknown // restored. This protects against accidentally injecting unknown
// table rows into the live system. // table rows into the live system.
@@ -384,10 +409,55 @@ export class RestoreService {
}); });
tx(rows); tx(rows);
} }
} finally {
// Re-establish the deployment-wide invariant in the candidate
// database before swap eligibility. A restore that ships zero
// admins must fail here so the live system never lands in that
// state, even momentarily.
const adminCount = (
db.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE "role" = 'admin'`).get() as { c: number }
).c;
if (!adminCount || adminCount < 1) {
throw new Error('Restored archive contains no admin user; refusing to swap');
}
for (const sql of capturedTriggerSqls) {
db.exec(sql);
}
db.pragma('foreign_keys = ON'); db.pragma('foreign_keys = ON');
db.pragma('foreign_key_check'); db.pragma('foreign_key_check');
db.close(); } catch (innerErr) {
// Make sure we never leak a candidate missing the triggers: re-create
// them best-effort before rethrowing so the rolled-back candidate is
// not left in an unsafe state on disk.
try {
const existing = new Set(
(
db
.prepare(
`SELECT name FROM sqlite_master WHERE type='trigger' AND name IN ('trg_user_last_admin_update','trg_user_last_admin_delete')`,
)
.all() as Array<{ name: string }>
).map((r) => r.name),
);
for (const sql of capturedTriggerSqls) {
const m = /"([^"]+)"/.exec(sql);
const name = m?.[1];
if (name && !existing.has(name)) {
db.exec(sql);
}
}
} catch {
// ignore best-effort restore errors
}
throw innerErr;
} finally {
try {
db.close();
} catch {
// ignore
}
} }
} }
+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. 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 resource: backend/src/modules/admin/system/admin-system.controller.ts
tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens] tags: [api, admin, system, backup, restore, danger-zone, confirmation-tokens]
timestamp: 2026-07-23T12:04:50Z timestamp: 2026-07-23T13:27:25Z
--- ---
# Overview # Overview
@@ -130,6 +130,31 @@ Flow:
database + uploads are swapped atomically via database + uploads are swapped atomically via
`FilesystemTransactionService.stageSwap()` (with a `restore-backup` `FilesystemTransactionService.stageSwap()` (with a `restore-backup`
rollback handle). 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, 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). and the error is mapped to `SYSTEM_RESTORE_ROLLED_BACK` (500).
5. On success, every refresh token belonging to the current admin is 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`) | | 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` | | 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`) | | 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 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. 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] 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 # 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.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/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/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/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/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/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-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-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-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.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-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.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 title: User Table
description: The `user` table — accounts, roles, and statuses. description: The `user` table — accounts, roles, and statuses.
tags: [database, user, auth] tags: [database, user, auth]
timestamp: 2026-07-21T17:37:18Z timestamp: 2026-07-23T13:27:25Z
--- ---
# Schema # Schema
@@ -31,6 +31,16 @@ timestamp: 2026-07-21T17:37:18Z
demote or delete the last admin are rejected by demote or delete the last admin are rejected by
`UsersService.enforceLastAdminInvariant` (used by `UsersService.enforceLastAdminInvariant` (used by
`AdminService.updateUserRole` and `AdminService.deleteUser`). `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 # First-admin bootstrap
+1 -1
View File
@@ -12,7 +12,7 @@ controls, administrator-managed Markdown blog publishing, and public and
signed-in blog views. signed-in blog views.
The docs below are organized by purpose so agents can pull just the slice 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 # Architecture
@@ -120,3 +120,239 @@ describe('Job 916: Admin restore commit body passes stagingId through', () => {
void ApiError; void ApiError;
}); });
}); });
describe('Job 918: Restore commit succeeds end-to-end with a real backup file', () => {
// Minimal valid archive shape used to drive RestoreService.stageArchive + commitRestore
// against a real file-backed SQLite database and uploads directory.
beforeEach(() => {
jest.resetModules();
delete process.env.SYSTEM_OP_RESTORE_STAGE_TTL_MS;
delete process.env.SYSTEM_OP_CONFIRM_TOKEN_TTL_MS;
delete process.env.SYSTEM_OP_STAGING_DIR;
delete process.env.UPLOAD_DIR;
delete process.env.DATABASE_PATH;
});
async function buildArchive(adminId: string, challengeCount: number, blogCount: number) {
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const tmp = fs.mkdtempSync(require('path').join(require('os').tmpdir(), 'hipctf-archive-'));
const dbPath = path.join(tmp, 'source.sqlite');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);
CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT);
CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT);
CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
`);
db.prepare(`INSERT INTO "user" (id, username, role, passwordHash, status) VALUES (?, ?, 'admin', 'hash', 'enabled')`).run(adminId, 'admin');
db.prepare(`INSERT INTO "setting" (key, value) VALUES (?, ?)`).run('site_title', 'ARCHIVE');
for (let i = 0; i < challengeCount; i++) {
db.prepare(`INSERT INTO "challenge" (id, title) VALUES (?, ?)`).run(`c${i}`, `Archived ${i}`);
}
for (let i = 0; i < blogCount; i++) {
db.prepare(`INSERT INTO "blog_post" (id, title) VALUES (?, ?)`).run(`b${i}`, `Archived blog ${i}`);
}
const tables = {
user: db.prepare('SELECT * FROM "user"').all(),
setting: db.prepare('SELECT * FROM "setting"').all(),
challenge: db.prepare('SELECT * FROM "challenge"').all(),
blog_post: db.prepare('SELECT * FROM "blog_post"').all(),
};
const uploads = [
{
path: 'icons/replaced.png',
originalFilename: 'replaced.png',
mimeType: 'image/png',
sizeBytes: 4,
sha256: '',
dataBase64: Buffer.from('ARCH').toString('base64'),
},
];
uploads[0].sha256 = require('crypto').createHash('sha256').update(Buffer.from('ARCH')).digest('hex');
db.close();
const archive = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: new Date().toISOString(),
tables,
tableCounts: {
user: tables.user.length,
setting: tables.setting.length,
challenge: tables.challenge.length,
blog_post: tables.blog_post.length,
},
uploads,
};
return { archiveText: JSON.stringify(archive), cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }) };
}
function makeLiveDbWithTrigger(dbPath: string): void {
const fs = require('fs');
fs.rmSync(dbPath, { recursive: true, force: true });
const Database = require('better-sqlite3');
const db = new Database(dbPath);
db.exec(`
CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);
CREATE TABLE "setting" (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE "challenge" (id TEXT PRIMARY KEY, title TEXT);
CREATE TABLE "blog_post" (id TEXT PRIMARY KEY, title TEXT);
INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('admin-live', 'admin', 'admin', 'hash', 'enabled');
INSERT INTO "user" (id, username, role, passwordHash, status) VALUES ('player-live', 'p1', 'player', 'hash', 'enabled');
INSERT INTO "setting" (key, value) VALUES ('site_title', 'MODIFIED TITLE');
INSERT INTO "challenge" (id, title) VALUES ('live-1', 'Live 1');
INSERT INTO "blog_post" (id, title) VALUES ('live-b1', 'Live blog');
CREATE TRIGGER trg_user_last_admin_update BEFORE UPDATE OF role ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NEW.role<>'admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
CREATE TRIGGER trg_user_last_admin_delete BEFORE DELETE ON "user"
FOR EACH ROW WHEN OLD.role='admin' AND NOT EXISTS (SELECT 1 FROM "user" other WHERE other.role='admin' AND other.id<>OLD.id)
BEGIN SELECT RAISE(ABORT, 'LAST_ADMIN'); END;
`);
db.close();
}
it('successfully swaps live DB and uploads for an archive that contains the live admin', async () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job918-'));
const dataDir = path.join(work, 'data');
const uploadDir = path.join(dataDir, 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
fs.mkdirSync(path.join(uploadDir, 'icons'), { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'icons', 'old.png'), Buffer.from('OLD'));
const dbPath = path.join(dataDir, 'db.sqlite');
makeLiveDbWithTrigger(dbPath);
process.env.DATABASE_PATH = dbPath;
process.env.UPLOAD_DIR = uploadDir;
process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging');
const { ConfigService } = require('@nestjs/config');
const config = new ConfigService({
DATABASE_PATH: dbPath,
UPLOAD_DIR: uploadDir,
SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'),
SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000,
SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000,
BODY_SIZE_LIMIT: '50mb',
});
const ttl = config.get('SYSTEM_OP_RESTORE_STAGE_TTL_MS');
expect(typeof ttl).toBe('number');
const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service');
const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service');
const fakeDataSource = {
query: jest.fn().mockResolvedValue([{ c: 1 }]),
};
const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService());
const { archiveText, cleanup } = await buildArchive('admin-live', 3, 2);
const staged = await restore.stageArchive(archiveText, 'admin-live');
expect(staged.stagingId).toBeTruthy();
const result = await restore.commitRestore(staged.stagingId);
expect(result.restoresPerformed).toBe(true);
const liveDb = new Database(dbPath, { readonly: true });
const counts = {
user: liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c,
challenge: liveDb.prepare(`SELECT COUNT(*) AS c FROM "challenge"`).get().c,
blog_post: liveDb.prepare(`SELECT COUNT(*) AS c FROM "blog_post"`).get().c,
};
const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value;
const adminCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user" WHERE role='admin'`).get().c;
const triggerNames = liveDb
.prepare(`SELECT name FROM sqlite_master WHERE type='trigger' AND name LIKE 'trg_user_last_admin_%'`)
.all()
.map((r: any) => r.name)
.sort();
liveDb.close();
expect(counts).toEqual({ user: 1, challenge: 3, blog_post: 2 });
expect(title).toBe('ARCHIVE');
expect(adminCount).toBe(1);
expect(triggerNames).toEqual(['trg_user_last_admin_delete', 'trg_user_last_admin_update']);
expect(fs.existsSync(path.join(uploadDir, 'icons', 'replaced.png'))).toBe(true);
expect(fs.existsSync(path.join(uploadDir, 'icons', 'old.png'))).toBe(false);
expect(fs.readFileSync(path.join(uploadDir, 'icons', 'replaced.png')).toString()).toBe('ARCH');
cleanup();
fs.rmSync(work, { recursive: true, force: true });
});
it('rejects an archive with no admin user without swapping live data', async () => {
const fs = require('fs');
const os = require('os');
const path = require('path');
const Database = require('better-sqlite3');
const work = fs.mkdtempSync(path.join(os.tmpdir(), 'hipctf-job918-noadmin-'));
const dataDir = path.join(work, 'data');
const uploadDir = path.join(dataDir, 'uploads');
fs.mkdirSync(uploadDir, { recursive: true });
fs.writeFileSync(path.join(uploadDir, 'keep.txt'), 'KEEP');
const dbPath = path.join(dataDir, 'db.sqlite');
makeLiveDbWithTrigger(dbPath);
process.env.DATABASE_PATH = dbPath;
process.env.UPLOAD_DIR = uploadDir;
process.env.SYSTEM_OP_STAGING_DIR = path.join(dataDir, '.system-staging');
const { ConfigService } = require('@nestjs/config');
const config = new ConfigService({
DATABASE_PATH: dbPath,
UPLOAD_DIR: uploadDir,
SYSTEM_OP_STAGING_DIR: path.join(dataDir, '.system-staging'),
SYSTEM_OP_RESTORE_STAGE_TTL_MS: 900000,
SYSTEM_OP_CONFIRM_TOKEN_TTL_MS: 300000,
BODY_SIZE_LIMIT: '50mb',
});
const { RestoreService } = require('../../backend/src/modules/admin/system/restore.service');
const { FilesystemTransactionService } = require('../../backend/src/modules/admin/system/filesystem-transaction.service');
const fakeDataSource = { query: jest.fn().mockResolvedValue([{ c: 1 }]) };
const restore = new RestoreService(config, fakeDataSource as any, new FilesystemTransactionService());
const Database2 = require('better-sqlite3');
const db = new Database2(':memory:');
db.exec(`CREATE TABLE "user" (id TEXT PRIMARY KEY, username TEXT, role TEXT, passwordHash TEXT, status TEXT);`);
const archive = {
format: 'hipctf-system-backup',
version: 1,
exportedAt: new Date().toISOString(),
tables: { user: db.prepare(`SELECT * FROM "user"`).all(), setting: [], challenge: [], blog_post: [] },
tableCounts: { user: 0, setting: 0, challenge: 0, blog_post: 0 },
uploads: [],
};
db.close();
const staged = await restore.stageArchive(JSON.stringify(archive), 'admin-live');
await expect(restore.commitRestore(staged.stagingId)).rejects.toMatchObject({
code: ERROR_CODES.SYSTEM_RESTORE_ROLLED_BACK,
});
const liveDb = new Database(dbPath, { readonly: true });
const title = liveDb.prepare(`SELECT value FROM "setting" WHERE key='site_title'`).get().value;
const userCount = liveDb.prepare(`SELECT COUNT(*) AS c FROM "user"`).get().c;
liveDb.close();
expect(title).toBe('MODIFIED TITLE');
expect(userCount).toBe(2);
expect(fs.readFileSync(path.join(uploadDir, 'keep.txt')).toString()).toBe('KEEP');
fs.rmSync(work, { recursive: true, force: true });
});
});