Skoolsewa - School Management System Docs
Developer ResourcesBackup

Backup API Reference

Complete API contracts for backup, including routes, auth, DTOs, responses, errors, examples, and integration notes.

Backup - API Reference

Audience: Admin panel engineers, QA, and API consumers with superadmin-level access. Scope: Admin-only. Backup catalog, backup download, upload/catalogue, restore request/live-state/force-clear, the restore traffic lock (read and release), and artifact signing-key custody — all under admin/system/*. The traffic lock itself is engaged and lifted by the restore automatically; no route turns it on. The one route that does not take an admin session is GET .../restores/{publicId}/live, which is @Public() to the session guard and authenticated by the restore's poll token instead.

1. Documentation Evidence

AreaFiles InspectedWhat Was Verified
Controllersapps/api/src/modules/backup/admin/catalog/backup-admin.controller.ts, admin/download/backup-download-admin.controller.ts, admin/restore/backup-restore-admin.controller.ts, admin/upload/backup-upload-admin.controller.tsRoutes, methods, guards, decorators, status codes.
DTOsadmin/catalog/dto/*.ts, admin/restore/dto/restore.dto.ts, admin/restore/dto/restore-lock.dto.ts, admin/download/dto/backup-download-params.dto.ts, admin/upload/dto/backup-upload.dto.tsRequest, query, response, validation.
Servicesadmin/catalog/backup-admin.service.ts, admin/download/backup-download-admin.service.ts, admin/restore/backup-restore-admin.service.ts, admin/upload/backup-upload-admin.service.ts, shared/*.tsBehavior, side effects, response mapping, errors.
Schemapackages/db/src/schema/backup/*.tsIDs, enums, persisted fields, constraints.
Restore executorworkers/backup-restore.processor.ts, workers/restore-runner.tsThe queue consumer and the detached process it spawns — restore genuinely executes, including a pre-restore safety dump and post-restore row-count verification, not just acceptance.
Traffic lockshared/maintenance.service.ts, shared/maintenance.guard.ts (MAINTENANCE_EXEMPT_PATHS, MAINTENANCE_EXEMPT_PATH_PREFIXES), admin/restore/dto/restore-lock.dto.tsHow a restore engages and lifts the lock, which routes stay reachable while it holds, and the read/release DTOs.
Route ground truthapps/api/test/structure/structure.baseline.jsonGrepped for system/backups, system/restores, system/backup-key — 24 distinct route templates: 14 under admin/system/backups (including POST admin/system/backups/upload), 4 under admin/system/restores, and 6 under admin/system/backup-key; none invented.
Rate limitsapps/api/src/common/guards/ip-throttler.config.tsADMIN_READ, ADMIN_WRITE, ADMIN_HEAVY_OP, ADMIN_ASYNC_JOB_SUBMIT, ADMIN_BACKUP_DOWNLOAD, ADMIN_BACKUP_UPLOAD, ADMIN_RESTORE_SUBMIT, ADMIN_RESTORE_POLL, ADMIN_KEY_MATERIAL_READ, ADMIN_KEY_MATERIAL_WRITE.
Permissionspackages/db/src/authorization/permission-catalog.tsBackup, BackupDownload, BackupConfigure, BackupUpload, BackupKey — all superadmin-only.
Envapps/api/src/config/env.validation.tsEvery BACKUP_*/PG_*/TAR_PATH variable, and the admin-editable vs. environment-only split, including BACKUP_ARTIFACT_HMAC_KEY_PATH and the deprecated-but-still-declared BACKUP_ARTIFACT_HMAC_KEY.
Error registryapps/api/src/common/types/error-codes.tsEvery BACKUP_* and SYSTEM_MAINTENANCE_ACTIVE code, including the BACKUP_UPLOAD_* group.
Key custodyadmin/key/backup-key-admin.controller.ts, admin/key/backup-key-admin.service.ts, admin/key/dto/*.ts, shared/backup-key-store.service.tsThe six admin/system/backup-key routes, their DTOs, the on-disk key file format, and the reveal-once contract.
Signature statepackages/db/src/schema/backup/enums.ts, packages/db/src/schema/backup/backup-run.ts, packages/db/src/migrations/0053_backup_signature_state.sqlThe backup_signature_state enum, the two new columns, six CHECK constraints and one partial index.
Trusted client IPapps/api/src/common/interceptors/trusted-client-ip.interceptor.tsThe x-internal-client-ip / x-internal-client-ip-token pair, and that the address is discarded unless the token matches.
Route reachabilityapps/api/test/structure/route-shadowing.spec.tsThat no literal route is shadowed by a parameter route from another controller — the defect that made GET admin/system/backups/key answer 400 publicId must be a UUID and moved these routes to their own prefix.

2. Module Summary

FieldValue
Module namebackup
Module slugbackup
Primary actorssuperadmin — every route in this module requires superadmin in practice, since Backup, BackupDownload, BackupConfigure, BackupUpload and BackupKey are withheld from the admin role.
API surfacesadmin (24 routes)
Base route prefixes/api/admin/system/backups, /api/admin/system/restores, /api/admin/system/backup-key
Auth modelJwtAuthGuard + RoleGuard + IpThrottlerGuard, @Permissions("Module_ACTION") per route. The one exception is GET .../restores/{publicId}/live: @Public() to JwtAuthGuard, authenticated by RestorePollTokenGuard and the x-restore-poll-token header instead.
PersistencePostgreSQL (backup_run, backup_restore), local filesystem (backup root: manifest.json, settings.json, restore-state.json, maintenance.json), Redis (maintenance flag mirror), MongoDB (AuditLog, download only), BullMQ (3 queues)
Runtime source of truthbackup_run/backup_restore tables for the catalog; the backup-root files for anything a restore itself would destroy — see the backend doc's Source of Truth table
Sibling docsBackend, Features and flows

3. Concepts and Terminology

TermMeaningSource FileUsed By
Backup runOne dump attempt — the unit tracked by backup_runbackup-run.tsAll catalog/download endpoints
KindWhy a backup exists: scheduled, manual, pre_restore_safety, uploadedenums.tsFetchBackupsDto, BackupResponseDto
UploadedAn operator-supplied archive catalogued via POST .../backups/upload (SE-4), rather than dumped by this deploymentenums.ts, backup-upload-admin.service.tsFetchBackupsDto, BackupResponseDto, BackupUploadDto
TierThe GFS bucket a scheduled run belongs to: daily, weekly, monthlyenums.tsSame
PinnedOperator-set protection from count-based retention pruningbackup-run.tsUpdateBackupDto
Includes uploadsWhether the run also archived the local upload rootbackup-run.tsCreateBackupDto, BackupResponseDto
Storage modeWhere a run's artifacts are kept: local, both, remote_onlyenums.tsBackupSettingsResponseDto
Replication statusWhether a run's artifacts reached the configured remote host: not_requested, pending, replicated, failedenums.tsSettings health fields
RestoreAn in-progress or completed request to replace the live database from a backup — tracked by backup_restore and, while in flight, by restore-state.jsonbackup-restore.tsRestore endpoints
Live restore stageThe value read from restore-state.json — accepted|queued|draining|safety_dump|restoring|verifying|reconciling|completed|failed. In practice the runner only ever assigns draining, restoring, completed and failed — safety_dump, verifying and reconciling are declared but never reached, since no safety dump or post-restore verification is implemented.backup-state-file.service.ts, restore.dto.ts, workers/restore-runner.tsGET .../live
Artifact signatureKeyed HMAC-SHA256(key, "<dumpSha256>:<manifestSha256>"), the key coming from the key file at BACKUP_ARTIFACT_HMAC_KEY_PATH — the control an uploaded archive is authenticated by, and the control a restore checksbackup-signature.service.ts, backup-key-store.service.tsPOST .../backups/upload, POST .../backups/{publicId}/restore
Retired keyA key that no longer signs but still verifies. Kept so a rotation does not make every existing backup unrestorablebackup-key-store.service.tsPOST .../backup-key/rotate, POST .../backup-key/import
Key fingerprintThe first 8 hex characters of sha256("hs-backup-artifact-key-fp-v1\n" + key). The only thing about a key that ever appears in a response after creationbackup-key-store.service.tsGET .../backup-key
Restore traffic lockThe flag (maintenance.json, mirrored to Redis) that makes MaintenanceGuard refuse non-operator traffic with 503 SYSTEM_MAINTENANCE_ACTIVE. A restore engages it when accepted and lifts it when it completes cleanly; a failed restore leaves it engaged until an operator releases it. No route engages it by hand.maintenance.service.ts, maintenance.guard.tsGET .../restores/lock, POST .../restores/lock/release, MaintenanceGuard
Version (settings)Optimistic-concurrency token for settings.jsonbackup-settings.service.tsBackupSettingsResponseDto, UpdateBackupSettingsDto

4. API Surface Map

Every route below is confirmed against structure.baseline.json.

SurfaceMethodPathActorAuth/GuardPermissionControllerPurpose
AdminGET/api/admin/system/backupsSuperadminJWT+Role+IPBackup_READBackupAdminControllerPaginated list of backup runs.
AdminPOST/api/admin/system/backupsSuperadminJWT+Role+IPBackup_CREATEBackupAdminControllerRun a manual backup now.
AdminGET/api/admin/system/backups/settingsSuperadminJWT+Role+IPBackup_READBackupAdminControllerRead effective backup/retention config. Registered before :publicId.
AdminPUT/api/admin/system/backups/settingsSuperadminJWT+Role+IPBackupConfigure_UPDATEBackupAdminControllerUpdate backup/retention config.
AdminGET/api/admin/system/backups/{publicId}SuperadminJWT+Role+IPBackup_READBackupAdminControllerBackup detail.
AdminPATCH/api/admin/system/backups/{publicId}SuperadminJWT+Role+IPBackup_UPDATEBackupAdminControllerPin/unpin a backup.
AdminDELETE/api/admin/system/backups/{publicId}SuperadminJWT+Role+IPBackup_DELETEBackupAdminControllerPrune a backup now (async, via outbox).
AdminGET/api/admin/system/backups/{publicId}/downloadSuperadminJWT+Role+IPBackupDownload_READBackupDownloadAdminControllerStream the raw pg_dump artifact.
AdminGET/api/admin/system/backups/{publicId}/download/uploadsSuperadminJWT+Role+IPBackupDownload_READBackupDownloadAdminControllerStream uploads.tar.gz alone. Separate from the dump download on purpose — see §8.23.
AdminPOST/api/admin/system/backups/uploadSuperadminJWT+Role+IPBackupUpload_CREATEBackupUploadAdminControllerCatalogue an operator-supplied, HMAC-verified archive (SE-4).
AdminGET/api/admin/system/backups/failuresSuperadminJWT+Role+IPBackup_READBackupAdminControllerDead letters from QueueName.BACKUP/QueueName.BACKUP_REPLICATE, newest first. Registered before :publicId.
AdminPOST/api/admin/system/backups/failures/{publicId}/retry-pruneSuperadminJWT+Role+IPBackup_DELETEBackupAdminControllerReplay a failed backup.prune once, under a fresh job id.
AdminPOST/api/admin/system/backups/failures/{publicId}/retry-replicationSuperadminJWT+Role+IPBackup_UPDATEBackupAdminControllerReplay a failed backup.replicate once, under a fresh job id.
AdminPOST/api/admin/system/backups/{publicId}/restoreSuperadminJWT+Role+IPBackup_RESTOREBackupRestoreAdminControllerAccept a restore request (202) — genuinely executes via BackupRestoreProcessor/restore-runner.ts, including an inline pre-restore safety dump and post-restore row-count verification.
AdminGET/api/admin/system/restores/{publicId}/liveThe operator who started the restore@Public() + RestorePollTokenGuard + IPNone — x-restore-poll-tokenBackupRestoreAdminControllerPoll live restore stage from the state file.
AdminPOST/api/admin/system/restores/{publicId}/force-clearSuperadminJWT+Role+IPBackup_RESTOREBackupRestoreAdminControllerClear a restore that will not finish.
AdminGET/api/admin/system/restores/lockSuperadminJWT+Role+IPBackup_READBackupRestoreAdminControllerRead the traffic lock a restore engages, where its post-restore reconciliation stands, and whether it can be released now.
AdminPOST/api/admin/system/restores/lock/releaseSuperadminJWT+Role+IPBackup_RESTOREBackupRestoreAdminControllerRelease a traffic lock a finished or failed restore left engaged.
AdminGET/api/admin/system/backup-keySuperadminJWT+Role+IPBackupKey_READBackupKeyAdminControllerFingerprints of the keys this deployment holds. Never key material.
AdminPOST/api/admin/system/backup-key/generateSuperadminJWT+Role+IPBackupKey_CREATEBackupKeyAdminControllerCreate the signing key and return it once. 409 if one already exists.
AdminPOST/api/admin/system/backup-key/importSuperadminJWT+Role+IPBackupKey_CREATEBackupKeyAdminControllerAdd another deployment's key to the RETIRED set. Verifies a restore, never an upload.
AdminPOST/api/admin/system/backup-key/rotateSuperadminJWT+Role+IPBackupKey_UPDATEBackupKeyAdminControllerRetire the current key, create a new one, return it once.
AdminPOST/api/admin/system/backup-key/resolve-signaturesSuperadminJWT+Role+IPBackupKey_UPDATEBackupKeyAdminControllerBatched: resolve signature_state for runs that have none.
AdminDELETE/api/admin/system/backup-key/retired/{fingerprint}SuperadminJWT+Role+IPBackupKey_DELETEBackupKeyAdminControllerPermanently remove a retired key. Refuses while anything may still need it.

Why restores/lock cannot be captured by restores/{publicId}/...

BackupRestoreAdminController declares restores/:publicId/live (GET) and restores/:publicId/force-clear (POST) on the same prefix as the two lock routes. Neither can match them: GET restores/lock has one segment after restores where live has two, and POST restores/lock/release ends in release, not force-clear. No GET restores/:publicId exists, which is the shape that would have swallowed restores/lock.

Why the key routes are backup-key, not backups/key

They were originally admin/system/backups/key*, on the same prefix as BackupAdminController's @Get(":publicId"). Express matches in registration order and module-import order decided the winner, so GET /api/admin/system/backups/key was matched by :publicId and answered 400 publicId must be a UUID — every read on the key custody page was dead.

Nothing caught it. Unit tests call the controller method directly and never build a route table; structure.baseline.json records that a route is REGISTERED, which it was; and an unauthenticated probe gets 401 from JwtAuthGuard before the parameter pipe runs, so the collision is invisible from outside a session. It was found by opening the page in a browser, and apps/api/test/structure/route-shadowing.spec.ts now fails the build on the whole class.

24 distinct route templates, matching the unique entries found in structure.baseline.json for these prefixes (the file lists each twice — once per Swagger document it appears in): 14 under admin/system/backups (including POST .../upload), 4 under admin/system/restores, and 6 under admin/system/backup-key.

5. Auth, Identity, and Permissions

SurfaceGuard/DecoratorIdentity ShapePermissionGuest AllowedNotes
Backup catalog/download/restore/uploadJwtAuthGuard, RoleGuard, IpThrottlerGuardreq.user.id (admin)Backup_*, BackupDownload_READ, BackupConfigure_UPDATE, BackupUpload_CREATE — all in SUPERADMIN_ONLY_MODULESNoThe admin role never receives these permissions; only superadmin does, via buildAdminPermissionCatalog's exclusion list.
Restore traffic lockJwtAuthGuard, RoleGuard, IpThrottlerGuardreq.user.id (admin)Backup_READ to read it, Backup_RESTORE to release it — both superadmin-onlyNoReleasing carries the same permission as starting a restore, because lifting the lock early puts users in front of a database that may be mid-replacement. Both routes sit under the /api/admin/system/restores/ prefix MaintenanceGuard exempts, so they answer while the lock holds.
RestoreTwo independent gates—Backup_RESTORE (grantable, superadmin-only) and BACKUP_RESTORE_ENABLED (deploy-time env, never grantable)NoA permission can be mis-granted; an environment variable cannot be granted from inside the app.
Signing-key custodyJwtAuthGuard, RoleGuard, IpThrottlerGuard, plus a password step-up in the servicereq.user.id (admin)BackupKey_READ/CREATE/UPDATE/DELETE — its own superadmin-only moduleNoSplit from BackupConfigure deliberately: that permission changes dump frequency, this one replaces the credential every artifact is authenticated against and hands the caller plaintext key material. Every mutating route re-checks the operator's password, because a session token proves the session and not the person. The step-up limiter is keyed by USER, never by IP.
Live restore stage@Public(), RestorePollTokenGuard, IpThrottlerGuardNone — no req.userNone; the x-restore-poll-token header minted by POST .../restoreNo@Public() only removes the session lookup, which queries tables the restore has locked. The poll token is the credential.

No route in this module accepts a guest. Nothing in the module has a dedicated mobile surface.

6. DTO and Model Reference

6.1 CreateBackupDto (request body — POST /admin/system/backups)

FieldTypeRequiredDefaultValidationExampleSource
includeUploadsbooleanNoServer default (BACKUP_INCLUDE_UPLOADS, ships false)@IsOptional @IsBooleantruecreate-backup.dto.ts
notestringNo—@IsOptional @IsString @MaxLength(200)"pre-migration"same

An explicit false for includeUploads is honoured — only absence falls back to the configured default. Under a remote (non-local) storage driver, the effective value is forced to false regardless of what was requested; the run records includesUploads: false.

6.2 FetchBackupsDto (query — GET /admin/system/backups)

Extends the shared QueryDto (pagination, page, size, sort, order, search — search is accepted by the base class but not applied by BackupAdminService.findAll, which filters only on the three fields below).

FieldTypeRequiredDefaultValidationExampleSource
kindenumNo—@IsIn(["scheduled","manual","pre_restore_safety"])"manual"fetch-backups.dto.ts
statusenumNo—@IsIn(["queued","running","completed","failed","pruned"])"completed"same
tierenumNo—@IsIn(["daily","weekly","monthly"])"daily"same
page (inherited)numberNo1@IsInt @Min(1)1query.dto.ts
size (inherited)numberNo20@IsInt @Min(1) @Max(100)20same
order (inherited)"asc"|"desc"No"desc"@IsEnum"desc"same — sorts by createdAt
sort (inherited)stringNo"updatedAt"accepted but not applied (service always orders by createdAt)—same

6.3 UpdateBackupDto (request body — PATCH /admin/system/backups/{publicId})

FieldTypeRequiredValidationExample
pinnedbooleanYes@IsBooleantrue

6.4 BackupParamsDto / BackupDownloadParamsDto / RestoreParamsDto

FieldTypeRequiredValidationExample
publicIdstring (UUID)Yes@IsUUID("7")018f4e2a-7b3c-7c1e-9b2a-3d4e5f6a7b8c

Identical shape, three separate classes (one per controller's route params).

6.5 BackupResponseDto

FieldTypeNullableNotes
publicIdstringNoOnly identifier ever exposed.
kindenumNoscheduled|manual|pre_restore_safety|uploaded
tierenumYesdaily|weekly|monthly, or null
statusenumNoqueued|running|completed|failed|pruned
pinnedbooleanNo—
includesUploadsbooleanNo—
databaseDumpBytes / uploadsArchiveBytesnumberYesFor uploadsFormat: "tar_gz", uploadsArchiveSha256 is the archive's own sha256, so a downloaded archive can be verified offline. For uploadsFormat: "snapshot", it is the sha256 of uploads.index.json instead — the downloaded snapshot archive carries that index, which lists every file's own sha256.
artifactsAvailableLocallybooleanNoWhether this run's artifacts are still on this host — false once the retention sweep reclaims them to a remote copy, independent of status, which stays completed. A download link offered without checking this answers 410.
totalBytesnumberNoServer-computed sum of the two above (each defaulting to 0).
storedBytesnumberNoBytes this run actually added to disk — the dump plus whatever uploads/audit-log bytes it wrote itself, excluding anything reused by hard link. totalBytes is the logical size of everything the run holds; storedBytes is what pruning this one run would actually free, modulo bytes a later run still links from it (§6.6 in the backend doc).
postgresVersionstringYes—
schemaMigrationTagstringYes—
tableCountnumberYesServer-computed: Object.keys(manifest.tables).length, or null if no manifest.
totalRowsnumberYesServer-computed: sum of every table's row count in the manifest.
requestedByEmailstringYesnull for scheduled.
notestringYes≤200 chars.
startedAt / finishedAtDateYes—
durationMsnumberYesServer-computed: finishedAt - startedAt, or null if either is missing.
errorCode / errorMessagestringYeserrorMessage is always the sanitised excerpt, never raw subprocess output.
isRestorablebooleanNoServer-computed: status === "completed". Does not account for whether local artifacts are still present — see 12.5 in the features doc.
signatureStateenumNounknown|unsigned|signed|signed_with_retired_key|signed_with_unknown_key. Resolved against the keys this deployment holds right now, not stored — a rotation, import or key prune changes it without touching the row.
signingKeyFingerprintstringYesFirst 8 hex characters of a labelled digest of the signing key; identifies the key, says nothing about whether it is still current.
uploadsFormatenumYestar_gz|snapshot, or null when the run has no uploads. tar_gz is one archive (older runs, and every run before the snapshot format landed). snapshot is a file tree in which files unchanged since the run this one linked from are hard links, so each run's own directory is complete on its own regardless of format.
uploadsFileCountnumberYesSnapshot only — files in the snapshot.
uploadsStoredBytesnumberYesBytes of uploads this run actually wrote (copied, not hard-linked).
uploadsSkippedCountnumberYesSnapshot only — entries deliberately left out (symlinks, sockets, devices, names with a control character). Non-zero means the snapshot is not the whole directory.
auditLogsStateenumYesincluded|failed, or null when the run was never meant to carry a MongoDB audit_logs copy (older runs, uploaded archives, pre_restore_safety dumps).
auditLogsDocumentsnumberYes—
auditLogsBytesnumberYesLogical size of every exported audit-log segment.
auditLogsStoredBytesnumberYesAudit-log bytes this run exported; sealed days reused by hard link are not counted again.
auditLogsErrorCodestringYesBACKUP_AUDIT_LOGS_UNAVAILABLE (MongoDB unreachable) or BACKUP_AUDIT_LOGS_EXPORT_FAILED (any other export failure). Set only when auditLogsState = "failed"; the run still completes on its Postgres half.
replicationStatusenumNonot_requested|pending|replicated|failed.
replicatedAtDateYesWhen a remote copy was verified by reading its bytes back.
replicationTransferredBytesnumberYesrsync's transferred-file total for the last successful replication; files already on the remote from an earlier run's hard links are not sent again.
createdAtDateNo—

Deliberately absent, by design (matching RestoreLiveStateDto's frozen contract): the integer PK, artifactDir (a filesystem path), and the database dump's own checksum — its integrity is enforced server-side before every restore rather than exposed for a caller to check.

6.6 BackupSettingsResponseDto / UpdateBackupSettingsDto

FieldTypeEditable?Validation (on PUT)Notes
versionnumberEchoed back, required on PUT@IsInt @Min(0)Optimistic concurrency; mismatch is 409 BACKUP_SETTINGS_CONFLICT.
backupEnabledbooleanAdmin@IsBoolean—
scheduleCronstringAdmin@IsString, service re-validates as a 5-field cron expression6-field (seconds) rejected explicitly.
scheduleTimezonestringAdmin@IsString, service re-validates as a recognised IANA zone—
includeUploadsByDefaultbooleanAdmin@IsBoolean—
retainDaily/Weekly/Monthly/Manual/SafetynumberAdmin@IsInt @Min(1) @Max(365) each—
minFreeDiskMbnumberAdmin@IsInt @Min(256) @Max(1048576)—
dumpTimeoutMinutes / restoreTimeoutMinutes / restoreDrainSeconds / restoreAbandonSecondsnumberAdmin@IsInt @Min(1) eachdumpTimeoutMinutes is read fresh per dump, not cached — a saved change takes effect on the next dump. restoreTimeoutMinutes and restoreDrainSeconds are read once per restore and injected into the detached runner's own process environment by BackupRestoreProcessor.spawnRunner (the runner is a standalone process with no DI, so this is what makes them editable at all); restoreAbandonSeconds is passed directly to the in-process supervisor. All three are genuinely effective on the next restore submitted after a save.
storageModeenumAdmin@IsIn(["local","both","remote_only"]) + cross-checkRejected with BACKUP_REMOTE_NOT_CONFIGURED unless a remote target is configured and remoteReplicationEnabled is true.
remoteReplicationEnabledbooleanAdmin@IsBooleanThe runtime toggle only — never the destination.
restoreEnabledbooleanEnvironment-only (BACKUP_RESTORE_ENABLED)—Read-only in the response; not accepted by UpdateBackupSettingsDto at all.
restoreAdminDatabaseConfiguredbooleanEnvironment-only (presence of BACKUP_ADMIN_DATABASE_URL)—Never the connection string itself.
pgDumpPath / pgRestorePathstringEnvironment-only (PG_DUMP_PATH, PG_RESTORE_PATH)——
remoteConfiguredbooleanEnvironment-only (derived from BACKUP_REMOTE_ENABLED + BACKUP_REMOTE_TARGET presence)—Never the target string, which carries a username and a path.
lastReplicationFailureAtDateServer-computed (read-only)—Most recent updated_at among rows currently replication_status='failed'.
replicationFailureStreaknumberServer-computed (read-only)—Consecutive most-recent replication attempts ending in failed, reset by any replicated.

Why the environment-only fields can never be form fields: pgDumpPath/pgRestorePath/TAR_PATH (not shown in the response, but the same class) are absolute binary paths passed directly to child_process.spawn — an admin-editable path is arbitrary code execution on every scheduled backup. BACKUP_DIR (not in the response at all) is validated at boot against every statically-served root; an admin-editable value could not receive that boot-time safety check and would risk publishing every database dump over HTTP with no guard able to intervene (ServeStaticModule sits outside the guard pipeline). The remote replication destination and both replication credentials (BACKUP_REMOTE_TARGET, BACKUP_REMOTE_SSH_KEY_PATH) are a lower bar than BackupDownload_READ itself — an admin who could edit the destination could redirect every future backup to a host of their choosing, and one who could edit the key path could substitute their own key. BACKUP_RESTORE_ENABLED is a deploy-time kill switch specifically because an admin-editable kill switch is not a kill switch — its entire value is that arming the single most destructive endpoint in the product requires shell access to the box.

6.7 RestoreBackupDto (request body — POST .../restore)

FieldTypeRequiredValidationNotes
confirmDatabaseNamestringYes@IsString @MaxLength(128)Compared server-side against current_database() — the dialog is a speed bump, this is the control.
acknowledgeDataLosstrueYes@IsBoolean @Equals(true)Must be literally true. Everything written since the backup is gone, every session is invalidated, and any password changed since the backup reverts to its earlier value.
acknowledgeMongoNotRestoredbooleanNo@IsOptional @IsBooleanDeprecated and ignored — to be removed after 2026-12-31. The MongoDB audit_logs copy is now merged automatically on restore (entries missing from the live log are inserted, nothing is removed or overwritten — see RestoreAuditLogsResultDto below), which needs no acknowledgement. Accepted, and its value ignored, so a client built before this change does not receive 400 for sending it.

6.7a RestoreAuditLogsResultDto

The outcome of the MongoDB audit_logs merge a completed restore performs — see §7.5, step 8 in the backend doc for the full merge sequence and the signature-verified anchor it runs from. It is returned as auditLogs on RestoreLiveStateDto (§6.8) once the restore has completed and the merge step has run, and is absent before that. The merge runs after every restore; its outcome is also written to the restore state file and logged.

FieldTypeNullableNotes
statusenumNomerged — entries from the backup missing in the live log were inserted; failed — see errorCode, the database restore itself still completed regardless; not_in_backup — the source run carried no audit-log copy.
segmentsnumberNoDay-segments processed.
insertednumberNoEntries inserted into the live audit log.
alreadyPresentnumberNoEntries with the same _id, left untouched.
expiredSkippednumberNoEntries whose backed-up expiresAt had already passed by merge time — not re-inserted, since the collection's own TTL would immediately remove them again.
errorCodestringYesBACKUP_AUDIT_LOGS_UNVERIFIED (the source's manifest.json did not verify against a currently-held signing key at preflight time, so its audit-log index is not trusted enough to merge) or one of the BACKUP_AUDIT_LOGS_* merge-failure codes. null when status is merged or not_in_backup.

6.8 RestoreLiveStateDto (response — POST .../restore, GET .../live)

FieldTypeNullableNotes
restorePublicIdstringNo—
stageenumNoaccepted|queued|draining|safety_dump|restoring|verifying|reconciling|completed|failed. queued is written by the request handler; safety_dump is written by BackupRestoreProcessor.takeSafetyDump while the inline pre-restore dump runs; draining, restoring, verifying, completed and failed are written by restore-runner.ts as it fences, runs pg_restore, and checks row counts against the manifest. Only reconciling remains declared but never assigned by any current code path.
messagestringYes (optional)—
errorCodestringYes (optional)—
startedAt / heartbeatAtstring (ISO)NoheartbeatAt is written by restore-runner.ts every 5 seconds while it runs; a gap past BACKUP_RESTORE_ABANDON_SECONDS means the runner died, not that the restore failed, per the controller's own doc comment — BackupRestoreProcessor treats that gap as BACKUP_RESTORE_ABANDONED.
finishedAtstringYes (optional)—
safetyBackupPublicIdstringYes (optional)Written by BackupRestoreProcessor.takeSafetyDump into restore-state.json before the safety dump even runs (so a crash mid-dump still names the half-written artifact), and surfaced on every RestoreLiveStateDto response from stage:"safety_dump" onward. This is the id an operator uses to find the way back after a failed or wrong restore.
pollTokenstringYes (optional)Returned only by POST .../restore, never by the live read. Sent back as x-restore-poll-token to keep polling while pg_restore --clean holds every table and an ordinary authenticated poll cannot complete. Store it where a page reload can find it.
auditLogsRestoreAuditLogsResultDtoYes (optional)Present once the restore has completed and the audit-log merge has run — see §6.7a.

6.9 RestoreLockDto (response — GET .../restores/lock)

Every field is always present. When the lock is not engaged, active and releasable are false and every other field except blockingRestorePublicId is null — including when the state file records an earlier release, so a consumer never reads a stale engagedAt as a live one. When maintenance.json exists but cannot be read, active is true, reason is "unreadable", the other lock fields are null, and releasable depends only on blockingRestorePublicId being null.

FieldTypeNullableNotes
activebooleanNotrue while MaintenanceGuard refuses non-operator traffic with 503 SYSTEM_MAINTENANCE_ACTIVE.
restorePublicIdstringYesThe restore that engaged the lock. null when the lock is not engaged, or when the stored state carries no restore id.
reasonstringYesWhy the lock was engaged. A restore writes "Database restore in progress". null when not engaged.
engagedAtstring (ISO 8601)YesWhen the lock was engaged. null when not engaged.
engagedBystringYesThe requester recorded by the restore that engaged the lock (requestedByEmail on the restore). null when not engaged.
reconciliation"pending" | "reconciled" | "reconcile_failed"YesWhere the restore that engaged the lock stands on its post-restore reconciliation (new change-feed epoch, every integration key revoked, every webhook paused). pending: the restore has not settled — the database may be replaced and not yet reconciled. reconciled: reconciliation ran after the database was replaced. reconcile_failed: it ran and failed; restarting the API retries it. null when the lock is not engaged, was not engaged by a restore, belongs to a restore that failed before pg_restore committed (nothing to reconcile), or the file is unreadable.
blockingRestorePublicIdstringYesThe restore that blocks a release and any new restore: the open (queued, safety_dump, restoring, verifying) backup_restore row, or else the restore whose runner process is alive. Force-clear it by this id when its runner has died. null when nothing blocks. Reported whether or not the lock is engaged.
releasablebooleanNotrue only when the lock is engaged, blockingRestorePublicId is null, and reconciliation is neither pending nor reconcile_failed. Exactly the conditions under which 8.13 answers released: true, unless a restore is accepted between the read and the release.

6.10 BackupUploadDto (multipart request body — POST .../backups/upload)

FieldTypeRequiredValidationNotes
filebinary (multipart/form-data)YesEnforced by BackupUploadAdminController.upload checking presence, not class-validator (Swagger shape only)The pg_dump custom-format archive (database.dump). Streamed straight to a .partial file by BackupUploadStorageEngine, hashed as it writes.
manifestbinaryYesSamemanifest.json exactly as downloaded from the source deployment. Never written to disk — parsed in memory for schemaMigrationTag and manifest.tables only, then discarded.
signaturebinaryYesSameartifact.sig exactly as downloaded — HMAC-SHA256(key, "<dumpSha256>:<manifestSha256>"). The control this endpoint rests on.

All three parts are required — there is no partial-upload path. Response is a 201 BackupResponseDto (the same shape as 6.5), with kind: "uploaded", pinned: true and status: "completed" immediately (the upload is synchronous; there is no async job).

6.11 RestoreLockReleaseDto (response — POST .../restores/lock/release)

FieldTypeNullableNotes
releasedbooleanNotrue when this call lifted the lock; false when the lock was not engaged, so there was nothing to release. Both answer 200.

The route takes no request body. Both DTOs live in admin/restore/dto/restore-lock.dto.ts and carry Swagger decorators only, like RestoreLiveStateDto — they are responses, so there is nothing to validate.

6.12 FetchBackupFailuresDto / BackupFailureParamsDto / BackupJobFailureDto

FieldTypeRequiredNotes
unreplayedOnlybooleanNo (query, FetchBackupFailuresDto extends QueryDto)Filters to failures not yet retried.
publicIdstring (UUIDv7)Yes (path, BackupFailureParamsDto)The dead-letter row's own public id — not the backup_run the job was about.

BackupJobFailureDto (response, list and both retry routes):

FieldTypeNullableNotes
publicIdstringNo—
queueNameenumNobackup|backup_replicate.
jobNamestringNobackup.run, backup.prune or backup.replicate.
backupRunPublicIdstringYesThe backup_run the job was about, when it named one.
reasonstringYesFor backup.prune: scheduled|retention|manual.
attemptsnumberNo—
lastErrorstringYesWith any URL credentials masked.
failedAtDateNo—
replayedAtDateYes—
retryenumNoprune|replication|none — which retry route applies, or none for a terminal backup.run failure (start a new backup instead).

7. Enum Reference

EnumValueMeaningRuntime EffectSource
backup_kindscheduledNightly cron-created runCarries a non-null tier; no requesterenums.ts
manualAdmin-triggered "Run now"Requires a requestedByEmail; no tier
pre_restore_safetyTaken automatically, inline, immediately before a restore beginsNo tier; not pinned by default; BackupRestoreProcessor.takeSafetyDump calls BackupRunHandler.run() directly and waits for it before spawnRunner ever runs pg_restore --clean — the restore aborts rather than proceeding if the dump does not reach completed. Never exports a MongoDB audit-log copy of its own, since the restore's own audit merge is additive and needs no inverse.
uploadedCatalogued via POST .../backups/upload (SE-4) rather than dumped by this deploymentNo tier; requires requestedByEmail like manual; pinned: true always; excluded from count-based GFS retention entirely
backup_tierdaily|weekly|monthlyGFS retention bucket for a scheduled runGoverns which retain* setting appliesenums.ts
backup_statusqueued|running|completed|failed|prunedRun lifecyclepruned is terminal and distinct from a delete — the row survives with artifactDir=nullenums.ts
backup_restore_statusqueued|safety_dump|restoring|verifying|completed|failedRestore lifecycleOnly queued and the two terminal values (completed, failed) are ever persisted to the backup_restore.status column — safety_dump, draining (not in this enum, file-only), restoring and verifying are all genuinely reached, but only in restore-state.json, since pg_restore --clean replaces the table holding its own row mid-restoreenums.ts
backup_signature_stateunsignedNo key was held when the artifact was written. PERMANENT for that artifactRenders "Unsigned"; the restore preflight proceeds loudly rather than refusingenums.ts
signedAn HMAC over "<dumpSha256>:<manifestSha256>" exists and signing_key_fingerprint names the keyA missing artifact.sig beside a row in this state now REFUSES the restore — the signature having been removed is exactly the attack it guards
signed_unknown_keyA signature exists and matched nothing this deployment currently holdsA cache, not a fact: importing a key changes the answer, so every resolution predicate reads signature_state IS NULL OR signature_state = 'signed_unknown_key' — never IS NULL alone
(NULL)Not yet resolved, or the key store was unreadable when the run completedCounts as unresolved, which is what blocks a retired-key prune. Writing unsigned here instead would be a permanent lie about an artifact that becomes signable the moment the key file is repaired
backup_storage_modelocal|both|remote_onlyWhere a run's artifacts are keptRecorded per run at completion timeenums.ts
backup_replication_statusnot_requested|pending|replicated|failedWhether a run's artifacts reached the remote hostfailed here does not fail the backup itselfenums.ts
backup_uploads_formattar_gzOne pre-built archive — every run written before incremental snapshots landeduploadsArchiveSha256 is the archive's own sha256enums.ts
snapshotA file tree where files unchanged since the base run are hard linksuploadsArchiveSha256 is the sha256 of uploads.index.json instead; uploadsFileCount/uploadsStoredBytes/uploadsSkippedCount are populated
backup_audit_logs_stateincludedThe run's MongoDB audit_logs copy exported successfully (or reused sealed segments by hard link)auditLogsDocuments/Bytes/StoredBytes/Sha256 populatedenums.ts
failedThe export failed; the run still completed on its Postgres halfauditLogsErrorCode populated, the four fields above null

8. Endpoint Reference

8.1 GET /api/admin/system/backups

Purpose

Lists backup runs for the admin catalog view, with optional filtering by kind, status and tier and standard offset pagination. Used to populate the backup history table and to check whether a scheduled run completed.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (findAll)
DTOfetch-backups.dto.ts
Servicebackup-admin.service.ts (findAll)
Schemapackages/db/src/schema/backup/backup-run.ts
Testsbackup-admin.service.spec.ts

Auth and Permissions

  • Auth: JwtAuthGuard + RoleGuard
  • Guard chain: JwtAuthGuard, RoleGuard, IpThrottlerGuard
  • Permission: Backup_READ
  • Guest support: none
  • Rate limit: ADMIN_READ — 30/min, IP-keyed
  • Idempotency: N/A (read)

Request

PartRequiredDetails
QueryNokind?, status?, tier?, pagination?, page?, size?, order?, sort?
GET /api/admin/system/backups?status=completed&page=1&size=20 HTTP/1.1
Authorization: Bearer TOKEN

Response

{
  "success": true,
  "message": "Backups fetched",
  "data": [
    {
      "publicId": "018f4e2a-7b3c-7c1e-9b2a-3d4e5f6a7b8c",
      "kind": "scheduled",
      "tier": "daily",
      "status": "completed",
      "pinned": false,
      "includesUploads": false,
      "databaseDumpBytes": 23012582,
      "uploadsArchiveBytes": null,
      "totalBytes": 23012582,
      "postgresVersion": "PostgreSQL 16.4",
      "schemaMigrationTag": "a1b2c3",
      "tableCount": 42,
      "totalRows": 18342,
      "requestedByEmail": null,
      "note": null,
      "startedAt": "2026-08-30T02:00:03.000Z",
      "finishedAt": "2026-08-30T02:00:41.000Z",
      "durationMs": 38000,
      "errorCode": null,
      "errorMessage": null,
      "isRestorable": true,
      "createdAt": "2026-08-30T02:00:01.000Z"
    }
  ],
  "meta": { "count": 42, "page": 1, "size": 20 }
}

Side Effects

None — this is a read-only endpoint.

Error Cases

HTTP StatusError CodeConditionUser-Facing MeaningSource
400(class-validator)Invalid kind/status/tier/pagination valueFix the queryDTO validation
401/403—Missing/invalid JWT, or not superadminNot authorizedJwtAuthGuard/RoleGuard
429—Rate limit exceededRetry laterIpThrottlerGuard

Edge Cases

  • Empty result: data: [], meta.count: 0.
  • pagination=false: meta is undefined and every matching row is returned unpaged.
  • No filters: returns every run, newest first.

Example Requests

curl -X GET "$API_URL/api/admin/system/backups?status=completed" \
  -H "Authorization: Bearer TOKEN"

8.2 POST /api/admin/system/backups

Purpose

Triggers a manual backup ("Run now"). Validates that backups are enabled, no run is currently in progress, and there is enough free disk space, then inserts a queued row and returns immediately — the actual dump runs asynchronously via the outbox and QueueName.BACKUP.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (create)
DTOcreate-backup.dto.ts
Servicebackup-admin.service.ts (create) → backup-run-create.service.ts (createRun)
Schemabackup-run.ts
Testsbackup-run-create.service.spec.ts

Auth and Permissions

  • Permission: Backup_CREATE
  • Rate limit: ADMIN_ASYNC_JOB_SUBMIT — 10/hour, IP-keyed
  • Idempotency: none declared (@IdempotentCreate not used) — a duplicate click is refused synchronously by BACKUP_ALREADY_RUNNING as soon as the first row is queued, not only once a worker has claimed it into running.

Request

{ "includeUploads": true, "note": "before catalog migration" }

Minimal valid request: {} (both fields optional).

Response

201, same shape as 8.1's list item, status: "queued".

Side Effects

  • Database write: INSERT backup_run + outbox_events (same transaction).
  • Queue: backup.run reaches QueueName.BACKUP via the outbox dispatcher (~5s poll interval).
  • Audit: ActivityRecordService.recordActivity (action: "backup.create"), fire-and-forget.

Error Cases

HTTP StatusError CodeConditionSource
503BACKUP_DISABLEDbackupEnabled=false in settingsbackup-run-create.service.ts
409BACKUP_ALREADY_RUNNINGA row is already queued or runningsame
503BACKUP_INSUFFICIENT_DISK_SPACEFree disk below minFreeDiskMb + 1.2x last dumpsame
400(class-validator)note over 200 chars, or wrong typeDTO

Edge Cases

  • First-ever backup: the disk-space check uses a stated floor (no prior dump to multiply against), never dividing by/multiplying NULL.
  • includeUploads: false explicit: honoured even though the configured default is also false — ??, not ||.
  • Under a remote storage driver: includeUploads: true is silently overridden to false at dump time; the response later reports includesUploads: false.

Example Requests

curl -X POST "$API_URL/api/admin/system/backups" \
  -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{"includeUploads": true, "note": "before catalog migration"}'

8.3 GET /api/admin/system/backups/settings

Purpose

Reads the full effective backup/retention configuration for the settings screen — the merge of admin-edited settings.json (or env-seeded defaults if never written) plus read-only environment fields plus computed replication health. Registered ahead of :publicId so "settings" is never parsed as a UUID.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (getSettings)
Servicebackup-admin.service.ts (getSettings), backup-settings.service.ts
DTObackup-settings-response.dto.ts

Auth and Permissions

  • Permission: Backup_READ
  • Rate limit: ADMIN_READ

Response

See 6.6 for every field.

{
  "version": 3,
  "backupEnabled": true,
  "scheduleCron": "0 2 * * *",
  "scheduleTimezone": "Asia/Kathmandu",
  "includeUploadsByDefault": false,
  "retainDaily": 7, "retainWeekly": 4, "retainMonthly": 6, "retainManual": 10, "retainSafety": 3,
  "minFreeDiskMb": 2048,
  "dumpTimeoutMinutes": 30, "restoreTimeoutMinutes": 60,
  "restoreDrainSeconds": 120, "restoreAbandonSeconds": 900,
  "storageMode": "local",
  "remoteReplicationEnabled": false,
  "restoreEnabled": false,
  "restoreAdminDatabaseConfigured": false,
  "pgDumpPath": "/usr/bin/pg_dump",
  "pgRestorePath": "/usr/bin/pg_restore",
  "remoteConfigured": false,
  "lastReplicationFailureAt": null,
  "replicationFailureStreak": 0
}

Side Effects

None.

Error Cases

None beyond auth/rate-limit — getEffectiveSettings never throws.


8.4 PUT /api/admin/system/backups/settings

Purpose

Updates the admin-editable backup/retention policy in one full-replace write. The admin panel reads 8.3, edits it, and sends the whole shape back with the version it read.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (updateSettings)
DTOupdate-backup-settings.dto.ts
Servicebackup-admin.service.ts (updateSettings) → backup-settings.service.ts (updateSettings)

Auth and Permissions

  • Permission: BackupConfigure_UPDATE — not Backup_UPDATE, which today gates only pin/unpin on one row.
  • Rate limit: ADMIN_WRITE

Request

Full valid request — every field in 6.6 except the read-only ones, plus version:

{
  "version": 3,
  "backupEnabled": true,
  "scheduleCron": "0 3 * * *",
  "scheduleTimezone": "Asia/Kathmandu",
  "includeUploadsByDefault": false,
  "retainDaily": 7, "retainWeekly": 4, "retainMonthly": 6, "retainManual": 10, "retainSafety": 3,
  "minFreeDiskMb": 2048,
  "dumpTimeoutMinutes": 30, "restoreTimeoutMinutes": 60,
  "restoreDrainSeconds": 120, "restoreAbandonSeconds": 900,
  "storageMode": "local",
  "remoteReplicationEnabled": false
}

Response

200, same shape as 8.3, reflecting the new version.

Side Effects

  • Filesystem write: settings.json (atomic temp-file + rename).
  • If scheduleCron/scheduleTimezone changed: BackupScheduleScheduler.reload() re-registers the cron job immediately, without an API restart.
  • Audit: activityRecordService.recordActivity with a full before/after changes[] diff.

Error Cases

HTTP StatusError CodeCondition
409BACKUP_SETTINGS_CONFLICTversion does not match the current value
400BACKUP_INVALID_SCHEDULEscheduleCron not a valid 5-field expression, or scheduleTimezone not a recognised IANA zone
400BACKUP_REMOTE_NOT_CONFIGUREDstorageMode is both/remote_only without a configured remote target, or without remoteReplicationEnabled: true
400BACKUP_SETTINGS_INVALIDAny other field fails its bound

Example Requests

curl -X PUT "$API_URL/api/admin/system/backups/settings" \
  -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{"version":3,"backupEnabled":true,"scheduleCron":"0 3 * * *", "...": "..."}'

8.5 GET /api/admin/system/backups/{publicId}

Purpose

Fetches one backup's detail for the catalog detail view.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (findByPublicId)
Servicebackup-admin.service.ts → backup-retention.service.ts (getRowByPublicId)

Auth and Permissions

  • Permission: Backup_READ; Rate limit: ADMIN_READ

Request

PartRequiredDetails
ParamsYespublicId (UUID)

Response

Same shape as one item from 8.1.

Error Cases

HTTP StatusError CodeCondition
404BACKUP_NOT_FOUNDNo row for that publicId

8.6 PATCH /api/admin/system/backups/{publicId}

Purpose

Pins or unpins a backup, protecting (or exposing) it to count-based retention pruning.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (updatePinned)
DTOupdate-backup.dto.ts
Servicebackup-admin.service.ts (updatePinned)

Auth and Permissions

  • Permission: Backup_UPDATE; Rate limit: ADMIN_WRITE

Request

{ "pinned": true }

Response

200, updated BackupResponseDto.

Side Effects

  • UPDATE backup_run SET pinned = ...
  • Audit: backup.pin / backup.unpin with a changes: [{field:"pinned", from, to}] entry.

Error Cases

HTTP StatusError CodeCondition
404BACKUP_NOT_FOUNDNo row for that publicId

8.7 DELETE /api/admin/system/backups/{publicId}

Purpose

Requests that a backup be pruned. Validates the three retention floors synchronously (so an ineligible delete gets an immediate 409, never a silently-skipped async job), then enqueues the actual deletion through the outbox.

Source Evidence

EvidencePath
Controllerbackup-admin.controller.ts (remove)
Servicebackup-admin.service.ts (remove) → backup-retention.service.ts (assertPrunable)

Auth and Permissions

  • Permission: Backup_DELETE; Rate limit: ADMIN_WRITE

Response

{ "success": true, "message": "Prune requested", "data": { "publicId": "018f..." } }

Side Effects

  • Outbox enqueue: backup.prune {reason:"manual", backupRunPublicId}, dedupeKey: "manual-prune-<publicId>".
  • Audit: backup.delete_requested.
  • The actual filesystem rm -rf and row update to pruned happen later, in BackupPruneHandler, which re-checks assertPrunable — the floors can change between the click and the worker picking up the job.

Error Cases

HTTP StatusError CodeCondition
404BACKUP_NOT_FOUNDNo row for that publicId
409BACKUP_PINNED_CANNOT_DELETERow is pinned
409BACKUP_LAST_REMAINING_CANNOT_DELETENewest overall, newest in its bucket, or the safety dump of the most recent restore

8.8 GET /api/admin/system/backups/{publicId}/download

Purpose

Streams the raw pg_dump artifact for a completed backup. The highest-consequence read this module offers — every check runs before the first byte, and the audit write is the final gate and fails closed.

Source Evidence

EvidencePath
Controllerbackup-download-admin.controller.ts
Servicebackup-download-admin.service.ts (prepareDownload)

Auth and Permissions

  • Permission: BackupDownload_READ — a separate permission module from Backup_*, deliberately: seeing that a backup succeeded and walking out with the entire customer database are different acts with different audiences.
  • Rate limit: ADMIN_BACKUP_DOWNLOAD — 3/hour, user-keyed (not IP), because an IP-keyed cap on the endpoint that streams every customer record and password hash is bypassed by rotating source IPs.

Request

PartRequiredDetails
ParamsYespublicId (UUID)

Response

Raw application/octet-stream, streamed via createReadStream(...).pipe(res) — never buffered. Headers: Content-Type: application/octet-stream, Content-Disposition: attachment; filename="backup-<publicId>.dump" (filename derived from publicId alone, never the operator-editable note, to avoid CRLF header injection), Content-Length, Cache-Control: no-store.

Side Effects

  • stat() and streaming sha256 read of the dump file (integrity checks, size first then checksum).
  • MongoDB write: AuditLog.create({action:"backup.download", ...}) — must succeed, or the download itself is refused with 500 BACKUP_DOWNLOAD_AUDIT_FAILED. This is the one write in the whole module that is not fire-and-forget.

Error Cases

HTTP StatusError CodeCondition
409BACKUP_NOT_RESTORABLEstatus !== "completed"
410BACKUP_ARTIFACT_MISSING!localArtifactPresent, or the file is absent from disk
409BACKUP_SIZE_MISMATCHActual file size ≠ recorded databaseDumpBytes
409BACKUP_CHECKSUM_MISMATCHActual sha256 ≠ recorded databaseDumpSha256
500BACKUP_DOWNLOAD_AUDIT_FAILEDThe AuditLog write itself failed

Example Requests

curl -X GET "$API_URL/api/admin/system/backups/018f.../download" \
  -H "Authorization: Bearer TOKEN" -o backup.dump

8.9 POST /api/admin/system/backups/{publicId}/restore

Purpose

Accepts a request to replace the entire live database from a completed backup. Irreversible in intent. Runs every safety check synchronously, then engages maintenance, writes a live-state file, and enqueues backup.restore, which BackupRestoreProcessor consumes: it takes and links an inline pre-restore safety dump, then spawns and supervises restore-runner.ts, which fences the database, drains connections, runs pg_restore, and verifies row counts against the manifest before reporting success — see the note below for exactly what still is not covered.

Source Evidence

EvidencePath
Controllerbackup-restore-admin.controller.ts (restore)
DTOrestore.dto.ts
Servicebackup-restore-admin.service.ts (requestRestore)
Preflightbackup-restore-preflight.service.ts

Auth and Permissions

  • Permission: Backup_RESTORE — superadmin-only.
  • Additionally armed by BACKUP_RESTORE_ENABLED=true (deploy-time env; preflight refuses otherwise).
  • Rate limit: ADMIN_RESTORE_SUBMIT — 2/day, user-keyed, matching AUTH_DAILY_IRREVERSIBLE.

Request

{
  "confirmDatabaseName": "skoolsewa_production",
  "acknowledgeDataLoss": true,
  "acknowledgeMongoNotRestored": true
}

Response

202, RestoreLiveStateDto:

{
  "restorePublicId": "018f...",
  "stage": "queued",
  "message": "Accepted. Waiting for the restore runner to start.",
  "startedAt": "2026-08-30T10:00:00.000Z",
  "heartbeatAt": "2026-08-30T10:00:00.000Z"
}

Side Effects

  • Database: INSERT backup_restore (status: 'queued', maintenanceEngaged: true) + outbox enqueue, one transaction.
  • Maintenance is engaged immediately (MaintenanceService.engageIf) — not deferred to a worker, because the outbox dispatcher polls every ~5s and any gap would let customers keep writing to a database about to be replaced. It is refused while the lock still owes a previous restore's reconciliation (see the 409 below).
  • Filesystem: restore-state.json written with stage: "queued", after the lock is engaged.
  • Audit: backup.restore.requested.
  • Server log: warn-level "RESTORE ACCEPTED" line.

8.9a Known gap: verification is row-count only

A 202 from this endpoint means preflight passed, maintenance is engaged, backup.restore reached QueueName.BACKUP_RESTORE, and BackupRestoreProcessor genuinely: (1) takes an inline kind:'pre_restore_safety' backup, links it to the restore via backup_restore.safety_backup_run_id, and surfaces its id as RestoreLiveStateDto.safetyBackupPublicId — aborting the whole restore if the dump does not reach completed or the link cannot be recorded; (2) spawns workers/restore-runner.ts as a detached process, which fences the database, drains existing connections (up to the admin-configured restoreDrainSeconds) before terminating them, runs pg_restore --clean --single-transaction (up to the admin-configured restoreTimeoutMinutes), and — at the new verifying stage — compares every manifest-listed table's row count against the freshly-restored database (excluding RESTORE_VERIFICATION_EXCLUDED_TABLES: session tables, backup_run, backup_restore, outbox_events, drizzle_migrations — tables the restore and the safety dump themselves are expected to change), reporting BACKUP_RESTORE_VERIFICATION_FAILED specifically if any mismatch; and (3), on full success, disengages maintenance. The restore genuinely happens, is genuinely safeguarded by an automatic, linked, discoverable backup, and is genuinely checked afterward.

What still is not covered: verification is row-count only, not content-level — identical counts with different row contents, or --no-owner/--no-privileges permission side effects, would not be caught. BackupRestoreVerification's structured shape ({tablesChecked, tablesMatched, mismatches[], excluded[]}) is never written to backup_restore.verification, even though the comparison itself now happens — only pass/throw is observed. There is also still no one-click "undo": restoring the safety dump back requires an operator to submit it (now discoverable via safetyBackupPublicId) as the source of a second, ordinary restore request. See the backend doc's 16.8 Risk Register. If a runner dies or never spawns, force-clear marks the row failed and disengages maintenance without confirming whether pg_restore actually ran to completion.

Error Cases

HTTP StatusError CodeCondition
503BACKUP_RESTORE_DISABLEDBACKUP_RESTORE_ENABLED=false, or BACKUP_ADMIN_DATABASE_URL unset
409BACKUP_RESTORE_APP_ROLE_IS_SUPERUSERThe application's own DB role has rolsuper
400BACKUP_RESTORE_CONFIRMATION_MISMATCHEither acknowledgement is not literally true, or the typed database name does not match current_database()
404→ mappedBACKUP_NOT_FOUNDSource backup does not exist
409BACKUP_NOT_RESTORABLESource status !== "completed"
410BACKUP_ARTIFACT_MISSINGSource has no local artifact
409BACKUP_RESTORE_SCHEMA_VERSION_MISMATCHBackup's schema tag differs from (or is unknown vs.) the running app's — no override
409BACKUP_SIZE_MISMATCH / BACKUP_CHECKSUM_MISMATCHArtifact integrity check failed
409BACKUP_RESTORE_ALREADY_ACTIVEAnother restore is non-terminal. details.blockingRestorePublicId names it (null when the partial unique index refused a concurrent submit and the winner could not be read back)
409BACKUP_RESTORE_ALREADY_ACTIVEThe traffic lock still owes a previous restore's reconciliation: it names another restore whose reconciliation is pending or reconcile_failed, or maintenance.json is unreadable. details.blockingRestorePublicId is the lock's restorePublicId (null when the file is unreadable). Checked before the row is written and again when the lock is engaged; refused at that second check, the row just written is closed failed with this code, and the lock and restore-state.json are left as they were
409BACKUP_RESTORE_RUN_IN_PROGRESSA backup dump is queued/running

Example Requests

curl -X POST "$API_URL/api/admin/system/backups/018f.../restore" \
  -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" \
  -d '{"confirmDatabaseName":"skoolsewa_production","acknowledgeDataLoss":true,"acknowledgeMongoNotRestored":true}'

8.10 GET /api/admin/system/restores/{publicId}/live

Purpose

The one endpoint that stays answerable while a restore is running — it reads only restore-state.json, never a database table, so it does not block behind pg_restore --clean's table locks or behind JwtStrategy reading admin_sessions. The admin UI is expected to poll this with backoff, to show progress through safety_dump → draining → restoring → verifying → completed/failed, and to treat a network failure as expected, not as "failed".

Source Evidence

EvidencePath
Controllerbackup-restore-admin.controller.ts (live)
Servicebackup-restore-admin.service.ts (getLiveState)
Guardrestore-poll-token.guard.ts

Auth and Permissions

  • Auth: @Public() to JwtAuthGuard, because the session lookup reads admin_sessions, which the restore locks. RestorePollTokenGuard requires the x-restore-poll-token header to match the token stored in restore-state.json.
  • Permission: none. Rate limit: ADMIN_RESTORE_POLL.

Response

Same shape as 8.9's response.

Error Cases

HTTP StatusError CodeCondition
410BACKUP_RESTORE_NOT_FOUNDNo state file, or it names a different publicId

8.11 POST /api/admin/system/restores/{publicId}/force-clear

Purpose

Clears a restore that will not finish — for a runner that was killed, a host that rebooted mid-operation, or an operator who wants out regardless of a still-running pg_restore. Without this, a stuck row blocks every future restore via uq_backup_restore_single_active, with no code path out other than hand-written SQL. It does not stop a genuinely running subprocess — see 8.9a.

Source Evidence

EvidencePath
Controllerbackup-restore-admin.controller.ts (forceClear)
Servicebackup-restore-admin.service.ts (forceClear)

Auth and Permissions

  • Permission: Backup_RESTORE; Rate limit: ADMIN_RESTORE_SUBMIT

Response

{ "cleared": true }

Side Effects

  • UPDATE backup_restore SET status='failed', errorCode='BACKUP_RESTORE_ABANDONED', ...
  • The traffic lock lifts (MaintenanceService.disengageIf) when restore-state.json shows this restore never reached pg_restore (stage queued, draining or safety_dump, or failed with BACKUP_RESTORE_FAILED), or when the lock's reconciliation is neither pending nor reconcile_failed. Otherwise it stays engaged with its marker, for the supervisor — or a restarted API — to settle; { "cleared": true } is answered either way. Read 8.12 to see which.
  • Server log: warn-level.

Error Cases

HTTP StatusError CodeCondition
410BACKUP_RESTORE_NOT_FOUNDNo row with that publicId
409BACKUP_RESTORE_ALREADY_ACTIVErestore-state.json names this restore and its runnerPid is a live process. details.blockingRestorePublicId is this restore
409BACKUP_RESTORE_NOT_FOUNDThe row is already completed or failed — there is nothing to clear

There is no age threshold, so force-clearing a fresh restore whose runner has not yet written a pid is indistinguishable from force-clearing a stuck one. Because force-clear refuses a terminal row, it cannot lift a lock that a restore which already failed left engaged — that is what 8.13 is for.


8.12 GET /api/admin/system/restores/lock

Purpose

Reads the traffic lock a restore engages, so the Backups page can show that the API is refusing non-operator traffic, which restore engaged it, where its post-restore reconciliation stands, what blocks a release, and whether it can be released now. A restore engages the lock when it is accepted (reconciliation: "pending") and lifts it when it completes cleanly and has reconciled the restored database; a failed restore leaves it engaged on purpose, because a half-known database is not put back in front of users until an operator has looked.

Source Evidence

EvidencePath
Controllerbackup-restore-admin.controller.ts (lock)
DTOadmin/restore/dto/restore-lock.dto.ts (RestoreLockDto)
Servicebackup-restore-lock.service.ts (getLockState)
Stateshared/maintenance.service.ts (getState), shared/backup-state-file.service.ts (readRestore)

Auth and Permissions

  • Guard chain: JwtAuthGuard, RoleGuard, IpThrottlerGuard
  • Permission: Backup_READ — superadmin-only.
  • Rate limit: ADMIN_READ
  • Reachable while the lock holds: the path is under the /api/admin/system/restores/ prefix MaintenanceGuard exempts.

Request

No params, query, or body.

GET /api/admin/system/restores/lock HTTP/1.1
Authorization: Bearer TOKEN

Response

200, RestoreLockDto. Not engaged:

{
  "active": false,
  "restorePublicId": null,
  "reason": null,
  "engagedAt": null,
  "engagedBy": null,
  "reconciliation": null,
  "blockingRestorePublicId": null,
  "releasable": false
}

Left engaged by a restore that failed after pg_restore committed, and reconciled:

{
  "active": true,
  "restorePublicId": "0199a3c2-7f10-7c4e-9b1a-2d5e8f3a6b01",
  "reason": "Database restore in progress",
  "engagedAt": "2026-10-05T09:12:44.118Z",
  "engagedBy": "Super Admin",
  "reconciliation": "reconciled",
  "blockingRestorePublicId": null,
  "releasable": true
}

A restore that has not settled yet answers "reconciliation": "pending" with "releasable": false.

Side Effects

None — read-only. Reads backup_restore for a non-terminal row and restore-state.json for the runner pid (blockingRestorePublicId), then the lock through MaintenanceService.getState() (in-process 1s cache → Redis → maintenance.json).

Error Cases

HTTP StatusError CodeCondition
500—restore-state.json exists but cannot be read or parsed. An unreadable maintenance.json is not an error: it answers 200 with reason: "unreadable" (see 6.9), MaintenanceGuard keeps refusing traffic, and 8.13 overwrites it.

8.13 POST /api/admin/system/restores/lock/release

Purpose

Releases a traffic lock that a finished or failed restore left engaged. This is the operator's way back after a failed restore: check the database, then release the lock from the Backups page. It refuses while any restore can still be running, because lifting the lock while pg_restore --clean holds every table puts users in front of a database mid-replacement; and while the restore that engaged the lock has not reconciled the restored database, because the restored rows hold every integration key revoked after the dump as active.

Source Evidence

EvidencePath
Controllerbackup-restore-admin.controller.ts (releaseLock)
DTOadmin/restore/dto/restore-lock.dto.ts (RestoreLockReleaseDto)
Servicebackup-restore-lock.service.ts (releaseLock), shared/maintenance.service.ts (readSnapshot, disengageIf)
Testsadmin/restore/backup-restore-admin-lock.integration.spec.ts, workers/backup-restore-settle.integration.spec.ts

Auth and Permissions

  • Guard chain: JwtAuthGuard, RoleGuard, IpThrottlerGuard
  • Permission: Backup_RESTORE — superadmin-only, the same permission that starts a restore.
  • Rate limit: ADMIN_WRITE — 10/min, IP-keyed, the ordinary admin write budget. A release is refused until the restore settles, so an operator retries it; it does not share the restore's 2/day budget.
  • Idempotent: releasing a lock that is not engaged answers 200 { "released": false } and writes nothing.
  • Reachable while the lock holds: the path is under the /api/admin/system/restores/ prefix MaintenanceGuard exempts.

Request

No params, query, or body.

POST /api/admin/system/restores/lock/release HTTP/1.1
Authorization: Bearer TOKEN

Response

200, RestoreLockReleaseDto:

{ "released": true }

Side Effects

The checks run in this order, and the first that fails answers:

  1. Reads maintenance.json directly, past every cache — the snapshot the write is compared against.
  2. Any backup_restore row in queued, safety_dump, restoring or verifying → 409 BACKUP_RESTORE_ALREADY_ACTIVE.
  3. restore-state.json names a runnerPid that is a live process, whichever restore it belongs to → 409 BACKUP_RESTORE_ALREADY_ACTIVE.
  4. Inside MaintenanceService's critical section, re-reads maintenance.json:
    • not engaged → 200 { "released": false };
    • engaged by a different restorePublicId than step 1 read (a restore accepted meanwhile) → 409 BACKUP_RESTORE_ALREADY_ACTIVE;
    • reconciliation: "pending" → 409 BACKUP_RESTORE_RECONCILE_PENDING;
    • reconciliation: "reconcile_failed" → 409 BACKUP_RESTORE_RECONCILE_FAILED;
    • otherwise writes active: false to maintenance.json first, then mirrors it to Redis, and clears this process's in-process cache. An unreadable file, unreadable at both reads, is overwritten. Every API process stops refusing traffic once its own 1-second in-process cache expires.
  • Audit: a Mongo audit entry with action backup.restore.lock_released, module system, resource backup_restore/the restorePublicId that engaged the lock, metadata restorePublicId and unreadableState — beside the ActivityAuditInterceptor entry for the request.
  • Server log: warn-level [backup] restore lock released (<restorePublicId>).
  • The backup_restore row is not touched.

Error Cases

HTTP StatusError CodeCondition
409BACKUP_RESTORE_ALREADY_ACTIVEA restore is still in progress — the message names it; details.blockingRestorePublicId is its id. Wait, or force-clear it if its runner has died.
409BACKUP_RESTORE_ALREADY_ACTIVEA restore runner process is alive — the message names its pid; details.blockingRestorePublicId is the restore in restore-state.json.
409BACKUP_RESTORE_ALREADY_ACTIVEA restore engaged the lock after this request read it — details.blockingRestorePublicId is the new restore. Reload the lock.
409BACKUP_RESTORE_RECONCILE_PENDINGThe restore that engaged the lock has not settled its post-restore reconciliation. Wait for it; a restarted API resumes an unsettled restore at boot.
409BACKUP_RESTORE_RECONCILE_FAILEDThe post-restore reconciliation failed. Restart the API — it retries the reconciliation at boot — then release.
429—ADMIN_WRITE exhausted.

Example 409 body:

{
  "statusCode": 409,
  "errorCode": "BACKUP_RESTORE_ALREADY_ACTIVE",
  "message": "A restore is still in progress (0199a3c2-7f10-7c4e-9b1a-2d5e8f3a6b01). Wait for it to finish, or force-clear it if its runner has died.",
  "details": { "blockingRestorePublicId": "0199a3c2-7f10-7c4e-9b1a-2d5e8f3a6b01" }
}

Example Requests

curl -X POST "$API_URL/api/admin/system/restores/lock/release" \
  -H "Authorization: Bearer TOKEN"

8.14 POST /api/admin/system/backups/upload

Purpose

Catalogues an operator-supplied backup archive (SE-4) — a pg_dump produced by this deployment, or by another one whose key this deployment generated (an imported key does not authenticate an upload; see §8.18) — so the existing, unmodified restore path can act on it. Used when a database needs to be seeded from a backup taken outside the normal scheduled/manual flow, or moved between deployments. Does not restore anything itself.

Source Evidence

EvidencePath
Controlleradmin/upload/backup-upload-admin.controller.ts (upload)
DTOadmin/upload/dto/backup-upload.dto.ts
Storage engineadmin/upload/backup-upload-storage.engine.ts
Serviceadmin/upload/backup-upload-admin.service.ts (upload)
Signatureshared/backup-signature.service.ts
Content scanshared/backup-archive-content.util.ts (scanArchiveSql)
Testsadmin/upload/backup-upload-admin.service.spec.ts

Auth and Permissions

  • Auth: JwtAuthGuard + RoleGuard
  • Guard chain: JwtAuthGuard, RoleGuard, IpThrottlerGuard
  • Permission: BackupUpload_CREATE — a separate permission module from Backup_*, deliberately: introducing a database from outside is not the same act as running a dump of this one.
  • Guest support: none
  • Rate limit: ADMIN_BACKUP_UPLOAD — 3/hour, user-keyed (matching ADMIN_BACKUP_DOWNLOAD's reasoning — both move a whole database dump over HTTP)
  • Idempotency: none declared; a duplicate upload of the same archive inserts a second uploaded row, eventually rejected by BACKUP_UPLOAD_RETAINED_LIMIT_REACHED

Request

PartRequiredDetails
BodyYesmultipart/form-data with exactly three parts: file (the dump), manifest (manifest.json), signature (artifact.sig) — see 6.10
POST /api/admin/system/backups/upload HTTP/1.1
Authorization: Bearer TOKEN
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

Response

201, same shape as 8.1's list item, with kind: "uploaded", pinned: true, status: "completed" — immediately, since there is no async job.

Side Effects

  • Filesystem: moves the streamed .partial dump into a real artifact directory; writes a fresh, server-generated manifest.json and artifact.sig (never the operator's uploaded manifest — see the backend doc's 6.9).
  • Subprocess: pg_restore --list (format check) and pg_restore -f - --schema-only (renders SQL for the content scan) — read-only, no database connection.
  • Database write: INSERT backup_run (kind:'uploaded').
  • Audit: ActivityRecordService.recordActivity (action: "backup.upload"), fire-and-forget.

Error Cases

HTTP StatusError CodeConditionSource
400BACKUP_ARCHIVE_UNREADABLEThe file part is missingController
400BACKUP_UPLOAD_MANIFEST_INVALIDThe manifest part is missing, not valid JSON, not an object, or missing schemaMigrationTag/databaseDumpSha256Controller/Service
400BACKUP_UPLOAD_SIGNATURE_INVALID (missing-part case)The signature part is missingController
409BACKUP_UPLOAD_RETAINED_LIMIT_REACHEDBACKUP_UPLOAD_MAX_RETAINED completed uploaded backups already existService
409BACKUP_CHECKSUM_MISMATCHThe dump's sha256 does not match the value the manifest claims (corruption check)Service
503BACKUP_UPLOAD_SIGNING_NOT_CONFIGUREDThis deployment holds no current signing keyService
409BACKUP_UPLOAD_SIGNATURE_INVALID (mismatch case)The HMAC does not match this archive+manifest — not produced by a deployment holding this system's keyService
409BACKUP_ARCHIVE_UNREADABLEpg_restore --list cannot read the file as a PostgreSQL archiveService
409BACKUP_UPLOAD_TOC_REJECTEDThe archive's schema SQL contains a construct a restore must not executeService

Edge Cases

  • All three parts required — there is no partial upload.
  • The operator's own manifest.json is discarded after two fields are read from it; a fresh one is always written, so re-downloading and re-uploading an already-catalogued row works (it is re-signed over its own new manifest).
  • A truncated dump passes pg_restore --list (the format check only reads the table of contents at the front of the archive) — the sha256-vs-manifest comparison is what actually catches truncation.
  • Uploaded rows are pinned by default and excluded from GFS retention entirely; the only way to remove one is DELETE /admin/system/backups/{publicId} (8.7).

Example Requests

curl -X POST "$API_URL/api/admin/system/backups/upload" \
  -H "Authorization: Bearer TOKEN" \
  -F "file=@database.dump" \
  -F "manifest=@manifest.json" \
  -F "signature=@artifact.sig"

8.15 While the traffic lock holds — what answers and what refuses

Not a route: the behaviour every consumer sees while a restore's traffic lock is engaged, enforced by MaintenanceGuard (shared/maintenance.guard.ts), which app.module.ts registers as an APP_GUARD, so it runs in front of every HTTP route in the application.

A request passes when any of these holds, checked in this order:

RuleCoversWhy
The path is one of the literal MAINTENANCE_EXEMPT_PATHS/health/live, /health/ready, /api/auth/login/email, /api/auth/refresh, /api/auth/me, /api/auth/permissionsThe orchestrator's probes, and the operator's way back in after a restore has replaced every session, plus the two session reads the admin panel needs to render at all.
The path starts with a MAINTENANCE_EXEMPT_PATH_PREFIXES entry/api/admin/system/restores/ — .../live, .../force-clear, .../lock, .../lock/releaseThe operator watching the restore, clearing a dead one, and reading and releasing the lock it left. Each keeps its own guards; only the maintenance refusal is removed.
The lock is not engagedEverything—
The handler is not @Public() and declares a non-empty @Permissions(...)Every permission-gated admin routeOperator traffic.

Everything else is refused with 503:

{
  "statusCode": 503,
  "errorCode": "SYSTEM_MAINTENANCE_ACTIVE",
  "message": "The store is briefly unavailable while we carry out maintenance. Please try again shortly."
}

Outside production, AllExceptionsFilter adds timestamp and path. That includes every other @Public() route and every signed-in route that carries no @Permissions(...) — the /api/mobile audience surfaces among them. The direction is deny by default: a route added later is refused unless it is named here.

An unreadable maintenance.json counts as engaged, so a corrupt lock file refuses traffic rather than letting it through; 8.13 overwrites it.

Consumer handling. Treat 503 SYSTEM_MAINTENANCE_ACTIVE as "a restore holds the API", not as an outage: show a holding message and retry with backoff. On the Backups page, read 8.12 to tell an operator the lock is still engaged and offer 8.13 when releasable is true.

8.16 GET /api/admin/system/backup-key

FieldValue
AuthJwtAuthGuard + RoleGuard + IpThrottlerGuard
PermissionBackupKey_READ
Rate limitADMIN_KEY_MATERIAL_READ — 30/min, IP-keyed
RequestNone
Success200 BackupKeyStateResponseDto

Never returns key material. There is no endpoint anywhere in this module that returns an existing key: material appears in exactly two responses, both at the moment it is created. A standing "download the key" route would be a permanently wider target that buys nothing, since the key is only needed when it is made.

{
  "keyPathConfigured": true,
  "storeState": "ready",
  "current": {
    "fingerprint": "1289db31",
    "provenance": "generated",
    "createdAt": "2026-09-01T08:30:59.185Z",
    "retiredAt": null
  },
  "retired": [
    {
      "fingerprint": "c035c8a9",
      "provenance": "generated",
      "createdAt": "2026-09-01T08:53:28.278Z",
      "retiredAt": "2026-09-01T08:56:46.128Z",
      "retainedBackupCount": 1
    }
  ],
  "unresolvedCount": 0
}

storeState is one of no_path_configured · legacy_env · no_key · ready · unreadable. unreadable is not no_key, and the distinction is load-bearing: "no key configured" makes the restore preflight SKIP signature verification, which is correct for a deployment that never had a key and catastrophic for one whose key file was truncated by a crash. A corrupt file therefore degrades to unreadable rather than failing the boot, and the preflight refuses on it.

retainedBackupCount counts completed backups on this host carrying that fingerprint. An archive kept off-box has no row here and cannot be counted — and that is exactly the population import exists for, so a 0 never means "nothing depends on this key".

unresolvedCount is why the prune refusal can be trusted. It counts completed runs whose signature_state is NULL or signed_unknown_key, and it is deliberately not filtered on local_artifact_present: a run reclaimed to a remote copy is invisible to retainedBackupCount (its fingerprint is NULL) and would be invisible here too, so the operator would read 0, 0, prune, and destroy the only key able to verify archives that still exist.

8.17 POST /api/admin/system/backup-key/generate

FieldValue
PermissionBackupKey_CREATE
Rate limitADMIN_KEY_MATERIAL_WRITE — 5 per 15 min, USER-keyed
Request{ "currentPassword": "..." }
Success201 RevealedBackupKeyResponseDto — { fingerprint, key }
{ "fingerprint": "60bb212b", "key": "6d4e7c6f26f34ea3…142cd75f" }

This is the only time the value is returned. Nothing can retrieve it later. If the response is lost, rotate again.

No @IdempotentCreate on this route, or on rotate — deliberately. IdempotencyInterceptor persists responseJson to Redis for 24 hours, which would park a plaintext signing key in a store an operator may snapshot. generate is already idempotent by refusal: it 409s once a current key exists, so a retry cannot mint a second key.

StatuserrorCodeMeaning
400BACKUP_KEY_REAUTH_REQUIREDcurrentPassword was absent. Distinct from a wrong one, so the panel can say "you left it blank".
401BACKUP_KEY_REAUTH_FAILEDThe password did not match.
409BACKUP_KEY_ALREADY_EXISTSA current key is already held — replacing one silently is a rotation nobody audited. Use rotate.
409BACKUP_KEY_NO_PASSWORD_SETThe admin account has no password (OAuth-only), so the step-up cannot be performed.
422BACKUP_KEY_PATH_NOT_CONFIGUREDBACKUP_ARTIFACT_HMAC_KEY_PATH is unset; there is nowhere to write the key.
422BACKUP_KEY_STORE_UNREADABLEThe key file exists and cannot be parsed. Repair it before mutating it.

8.18 POST /api/admin/system/backup-key/import

FieldValue
PermissionBackupKey_CREATE
Rate limitADMIN_KEY_MATERIAL_WRITE
Request{ "currentPassword": "...", "key": "<the material>" }
Success201 ImportedBackupKeyResponseDto — { fingerprint, retainedBackupCount }

An imported key joins the retired set. It never signs and never becomes current.

provenance is load-bearing, not metadata. An imported key is a legitimate verification anchor on RESTORE, where backup_run.database_dump_sha256 is an independent server-computed value the backup directory cannot forge. It is not an anchor on UPLOAD: there the caller supplies the archive, the signature and — via this endpoint — the key that validates it. Three operands from one actor prove nothing, so BackupSignatureService filters verification candidates on this field. It lives in the key file's format rather than in a branch somebody can forget to write.

StatuserrorCodeMeaning
409BACKUP_KEY_ALREADY_EXISTSThat fingerprint is already held.
422BACKUP_KEY_INVALID_MATERIALShorter than BACKUP_KEY_MIN_IMPORT_LENGTH.

8.19 POST /api/admin/system/backup-key/rotate

FieldValue
PermissionBackupKey_UPDATE
Rate limitADMIN_KEY_MATERIAL_WRITE
Request{ "currentPassword": "...", "confirmFingerprint": "1289db31" }
Success201 RevealedBackupKeyResponseDto

confirmFingerprint must equal the fingerprint of the key being retired — 8 lowercase hex characters. It stops a rotation aimed at a key the operator was not looking at.

The retired key is kept, and that is the entire point. A retired key is not a dead key: it is what still verifies every artifact signed before the rotation, which is the difference between "rotate the signing key" and "make every existing backup unrestorable". Rotating and then restoring a pre-rotation backup is verified end to end.

StatuserrorCodeMeaning
409BACKUP_KEY_FINGERPRINT_UNKNOWNconfirmFingerprint does not match the current key.
422BACKUP_KEY_NOT_FOUNDThere is no current key to rotate. Use generate.

8.20 POST /api/admin/system/backup-key/resolve-signatures

FieldValue
PermissionBackupKey_UPDATE
Rate limitADMIN_HEAVY_OP — 20/min
RequestNone
Success200 ResolveSignaturesResponseDto — { resolved, remaining }

Batched: call again while remaining is above zero.

It reveals no key material and mutates no key — it recomputes derived state on backup_run by reading each artifact's artifact.sig — so it takes ADMIN_HEAVY_OP rather than the step-up budget, whose shape assumes every attempt is a password guess. It requires no password for the same reason.

It re-examines runs previously resolved as signed_unknown_key, because importing a key changes that answer, and that is precisely what makes the retired-key prune count trustworthy.

remaining reports work this host can do — rows whose artifacts are readable here. That is deliberately a different set from unresolvedCount in §8.16, which is "reasons not to prune" and includes rows this host can never resolve.

8.21 DELETE /api/admin/system/backup-key/retired/{fingerprint}

FieldValue
PermissionBackupKey_DELETE
Rate limitADMIN_KEY_MATERIAL_WRITE
Path paramfingerprint — 8 lowercase hex characters
Request{ "currentPassword": "...", "confirmFingerprint": "…", "force": false }
Success200 ImportedBackupKeyResponseDto

Irreversible. The only way back is to import the same key material again — if the operator still has it.

Two independent refusals, and force overrides both:

StatuserrorCodeRefuses because
409BACKUP_KEY_STILL_IN_USECompleted backups still carry this fingerprint, and would become unverifiable.
409BACKUP_KEY_STILL_IN_USESome completed run is still unresolved, so it is not yet known whether this key is the only thing able to verify it. Run resolve-signatures first.
404BACKUP_KEY_NOT_FOUNDNo retired key has that fingerprint.

The server-side confirmFingerprint check is not evidence of intent. It is compared against the path segment, and both come from the same client, so it catches a malformed request and nothing more. It is not the analogue of the restore dialog's confirmDatabaseName, which is checked against SELECT current_database() — a fact only the server holds and a client that has not been told the answer cannot satisfy. There is no server-side fact about a key fingerprint that the client does not already supply, so for an accidental prune the admin panel's typed-confirmation dialog is the control, and calling it a speed bump in front of one would misdescribe what protects an irreversible delete.

8.23 GET /api/admin/system/backups/{publicId}/download/uploads

FieldValue
PermissionBackupDownload_READ — the same as the dump download
Rate limitADMIN_BACKUP_DOWNLOAD — the same constant, but a separate bucket
ParamsBackupDownloadParamsDto — @IsUUID("7")
Success200 · application/gzip · Content-Length · Cache-Control: no-store · attachment; filename="backup-<publicId>-uploads.tar.gz"

Why this is a second route rather than a bigger tar. §8.8 hands out exactly database.dump, manifest.json and artifact.sig, because those three are what §8.14's upload endpoint consumes. Adding uploads to that response would break that round trip and turn every routine dump download into a multi-gigabyte transfer. The operator picks.

Before this route existed, an operator who ticked "Also archive uploaded files" got a download containing none of them, and nothing said so.

Two source formats, one response shape. row.uploadsFormat === "tar_gz" (older runs) streams the run's pre-built uploads.tar.gz straight from disk with a server-computed sha256 and a size check against the recorded byte count. row.uploadsFormat === "snapshot" (current runs) has no single pre-built archive to stream — the run stored a hard-linked file tree instead — so the handler tars uploads/, uploads.index.json, manifest.json and artifact.sig (when present) fresh, on the fly, from the artifact directory. Both paths answer the same headers and the same backup-<publicId>-uploads.tar.gz filename, so a caller never needs to know which format a given run used.

The same constant does not mean a shared budget. IpThrottlerGuard keys the bucket as `throttle:ip:${ControllerClass}:${handlerName}:${principal}` — the RATE_LIMITS constant supplies only limit, windowSeconds and keyStrategy, and contributes nothing to the key. So each handler has its own 3/hour and the two downloads never compete. Reusing the constant is therefore a naming decision, not a capacity one; per-operator capacity is 3+3 either way.

Refusals consume the budget. The guard runs before the handler, so a 409 or 410 costs a slot exactly like a delivered file does.

Refusals

StatuserrorCodeCondition
400VALIDATION_FAILEDpublicId is not a UUIDv7
404BACKUP_NOT_FOUNDNo such run
409BACKUP_NOT_RESTORABLEstatus !== 'completed'
409BACKUP_UPLOADS_ARCHIVE_NOT_INCLUDEDThe run archived no uploaded files
409BACKUP_SIZE_MISMATCHRecorded byte count ≠ the file on disk
410BACKUP_ARTIFACT_MISSINGReclaimed to a remote copy, or not openable
500BACKUP_DOWNLOAD_AUDIT_FAILEDThe audit write failed — the download is refused, never left unaudited

BACKUP_UPLOADS_ARCHIVE_NOT_INCLUDED is 409, not 404: the backup exists and its database half downloads fine. A 404 would send the operator hunting a wrong id instead of a missing option chosen at backup time.

No server-side hash, and why that is not a weakening

§8.8 computes the dump's sha256 before streaming it. This route does not, and the reason is specific rather than general:

  • The dump's hash is justified by RESTORE — "a silently corrupted dump is worse than a missing one, because it restores and produces a wrong database rather than an error." Nothing restores uploads.tar.gz; the only consumer is the operator.
  • Hashing reads the whole file before the first response header, and the admin BFF abandons a download whose headers have not arrived in time. On the artifact that is large by design, that turns every download into a timeout — and since refusals consume the rate budget, a few deterministic retries lock the operator out during the incident the feature exists for.

Integrity moves to where it can be acted on: uploadsArchiveSha256 is on the backup detail response and rendered in the panel, so the operator verifies the file they actually received —

sha256sum backup-<publicId>-uploads.tar.gz

Content-Length is set (unlike §8.8, whose tar is built on the fly), so a truncated transfer is detectable without any hashing.

The open happens before any header is set

A failure opening the archive is an ordinary thrown exception rendered as a normal envelope. That ordering is load-bearing and was arrived at by measurement:

ShapeBehaviour on a failed open
stream.pipe(res) + an error handler that logsResponse hangs with no status — pipe does not forward a source error to the destination
pipeline(stream, res) + a catch answering 410Connection reset, no status — pipeline destroys the response before the catch runs
Open first, then set headers, then pipeline410 BACKUP_ARTIFACT_MISSING

This matters because the failure is reachable: deleteHeavyArtifacts removes uploads.tar.gz before it flips local_artifact_present, so a completed, locally-present run can lose its file at any moment, and the window between the size check and the open spans the audit write.

Audit

backup.download_uploads — distinct from §8.8's backup.download. After an incident the question is which artifact left this host, and customer files leaving is a different disclosure from a database dump leaving. One shared action string cannot answer it.

Like §8.8, the record is written before the first byte and the download is refused if it fails. Note the corollary: the row says success before delivery is attempted, so an aborted transfer leaves an optimistic record.

8.22 Forwarding the operator's address

Not an endpoint — a header pair every admin route accepts, added because the admin panel is a BFF and so every admin action landed in the activity log as 127.0.0.1: literally accurate, and useless for a log whose purpose is "who did this, from where".

HeaderValue
x-internal-client-ipThe address to record
x-internal-client-ip-tokenMust equal INTERNAL_CLIENT_IP_TOKEN

Both halves are required and the address is discarded outright unless the token matches. Trust is bound to a shared secret rather than to Express's trust proxy because a numeric hop count does not bind to the peer at all — measured against Express 5.2.1, with the socket peer on loopback and a single-entry x-forwarded-for, req.ip becomes whatever the caller sent. The token authenticates the caller, which is the thing a hop count cannot express.

Verified by observation, three ways: a forged address with a wrong token records 127.0.0.1; a forged address with no token records 127.0.0.1; the address with the correct token is recorded.

Unset is a no-op that preserves today's behaviour exactly, which is what lets this ship without a coordinated deploy. The forwarded address reaches the activity log and nothing else — in particular it is not wired into IpThrottlerGuard, because doing so would hand an unauthenticated attacker a rate-limit bypass on admin credential stuffing.

8.24 GET /api/admin/system/backups/failures

FieldValue
PermissionBackup_READ
Rate limitADMIN_READ
QueryFetchBackupFailuresDto — pagination fields plus unreplayedOnly
Success200 · paginated BackupJobFailureDto[]

Dead letters from QueueName.BACKUP and QueueName.BACKUP_REPLICATE — backup.run, backup.prune and backup.replicate jobs that exhausted their BullMQ attempts — newest first. A failed backup.run is listed here for the record (retry: "none") but cannot be retried through either route below; the operator starts a new backup instead. Declared BEFORE GET :publicId in the controller so route matching does not take the literal "failures" as a publicId and answer 400.

8.25 POST /api/admin/system/backups/failures/{publicId}/retry-prune

FieldValue
PermissionBackup_DELETE — a prune retry is a deletion, and must not be reachable under the pin/note permission
Rate limitADMIN_WRITE
ParamsBackupFailureParamsDto — the dead-letter row's own publicId, not the backup_run's
Success200 · BackupJobFailureDto

Replays the failed backup.prune once, under a fresh job id. The worker re-checks the run against the current retention settings and floors before deleting anything — a stale approval from before the original attempt is never trusted, since settings or the retention floors may have changed since.

Refusals

StatuserrorCodeCondition
404JOB_FAILURE_NOT_FOUNDNo dead letter with that publicId in this scope
409BACKUP_FAILURE_NOT_RETRYABLEThe named failure is not a backup.prune job — including a terminal backup.run failure
409JOB_FAILURE_ALREADY_REPLAYEDAlready retried once

8.26 POST /api/admin/system/backups/failures/{publicId}/retry-replication

FieldValue
PermissionBackup_UPDATE
Rate limitADMIN_WRITE
ParamsBackupFailureParamsDto
Success200 · BackupJobFailureDto

Replays the failed backup.replicate once, under a fresh job id — an ordinary re-run of the same off-host copy BackupReplicationHandler performs after any run completion.

Refusals

Same three codes as §8.25, with BACKUP_FAILURE_NOT_RETRYABLE firing when the named failure is not a backup.replicate job.

9. Flow Diagrams

9.1 Route Ownership

9.2 Request Sequence — manual backup

9.3 Error Branch — restore request

EndpointPagination TypeDefault SizeMax SizeSort FieldsFiltersResult Cap
GET /admin/system/backupsoffset (PaginationUtil)20100createdAt (only field actually applied, via order)kind, status, tierNone beyond size

sort is accepted by the inherited QueryDto but not read by BackupAdminService.findAll — only order (asc/desc on createdAt) is applied. search is likewise accepted but not used. pagination=false returns every matching row with meta omitted from the response envelope.

No caching, no relevance scoring — this is a straightforward filtered/ordered/offset-paginated list over backup_run.

11. Caching, Jobs, and External Integrations

IntegrationUsed?DetailsSource
Redis cacheYessystem:flag=maintenance — the restore traffic lock — mirrored on every engage/disengage (restore accept, restore success, force-clear, lock release), in-process 1s cache on topmaintenance.service.ts
BullMQYesbackup.run/backup.prune on QueueName.BACKUP; backup.replicate on QueueName.BACKUP_REPLICATE; backup.restore on QueueName.BACKUP_RESTORE, consumed by BackupRestoreProcessor, which takes and links an inline safety dump then spawns restore-runner.ts — see 8.9asee the backend doc's Section 9
External process (not a network API)Yespg_dump (dumps), pg_restore (restores live via restore-runner.ts; also --list/-f - format-check and schema-render an uploaded archive), tar, rsync, ssh — all spawned as local subprocesses, not HTTP callsbackup-artifact.service.ts, backup-replication.service.ts, workers/restore-runner.ts, backup-upload-admin.service.ts
Direct database queries (not a subprocess)Yesrestore-runner.ts's verifyRowCounts() opens its own pg Client against BACKUP_ADMIN_DATABASE_URL and runs SELECT count(*) per manifest table, separate from pg_restore itselfworkers/restore-runner.ts
MongoDBYes (download only)AuditLog.create() — the one non-fire-and-forget write in the modulebackup-download-admin.service.ts

13. Mandatory Deep API Documentation Pack

13.1 Route-by-Route Completeness Matrix

RouteController MethodDTOsService MethodGuardsPermissionsCacheJobsDB TouchesErrorsTestsDocumented?
GET /admin/system/backupsfindAllFetchBackupsDtoBackupAdminService.findAllJWT+Role+IPBackup_READN/AN/Abackup_run (read)400backup-admin.service.spec.tsYes
POST /admin/system/backupscreateCreateBackupDto.create → BackupRunCreateService.createRunJWT+Role+IPBackup_CREATEN/Abackup.run (outbox)backup_run (write), outbox_events503/409/400backup-run-create.service.spec.tsYes
GET /admin/system/backups/settingsgetSettingsN/A.getSettingsJWT+Role+IPBackup_READN/AN/Abackup_run (read, health)nonebackup-admin.service.spec.tsYes
PUT /admin/system/backups/settingsupdateSettingsUpdateBackupSettingsDto.updateSettingsJWT+Role+IPBackupConfigure_UPDATEN/AN/A (may reload() cron in-process)none (writes settings.json)409/400backup-settings.service.spec.tsYes
GET /admin/system/backups/{publicId}findByPublicIdBackupParamsDto.findByPublicIdJWT+Role+IPBackup_READN/AN/Abackup_run (read)404—Yes
PATCH /admin/system/backups/{publicId}updatePinnedBackupParamsDto, UpdateBackupDto.updatePinnedJWT+Role+IPBackup_UPDATEN/AN/Abackup_run (write)404—Yes
DELETE /admin/system/backups/{publicId}removeBackupParamsDto.removeJWT+Role+IPBackup_DELETEN/Abackup.prune (outbox)outbox_events404/409backup-retention.service.spec.tsYes
GET /admin/system/backups/{publicId}/downloaddownloadBackupDownloadParamsDto.prepareDownloadJWT+Role+IPBackupDownload_READN/AN/Abackup_run (read)409/410/500backup-download-admin.service.spec.tsYes
POST /admin/system/backups/uploaduploadBackupUploadDto.uploadJWT+Role+IPBackupUpload_CREATEN/AN/A (synchronous)backup_run (write)400/409/503backup-upload-admin.service.spec.tsYes
POST /admin/system/backups/{publicId}/restorerestoreRestoreParamsDto, RestoreBackupDto.requestRestoreJWT+Role+IPBackup_RESTOREN/Abackup.restore (outbox) → BackupRestoreProcessor takes an inline safety dump, then spawns restore-runner.tsbackup_restore (write), outbox_events, backup_run (inline pre_restore_safety insert+update via BackupRunHandler)503/409/400/410workers/backup-restore-wiring.spec.tsYes
GET /admin/system/restores/{publicId}/liveliveRestoreParamsDto.getLiveState@Public() + RestorePollTokenGuard + IPNone (poll token)N/AN/Anone (file only)410—Yes
POST /admin/system/restores/{publicId}/force-clearforceClearRestoreParamsDto.forceClearJWT+Role+IPBackup_RESTORERedis mirror (write)N/Abackup_restore (write)410/409—Yes
GET /admin/system/restores/locklockRestoreLockDtoBackupRestoreLockService.getLockStateJWT+Role+IPBackup_READRedis mirror (read)N/Abackup_restore (read)500 on an unreadable restore-state.jsonbackup-restore-admin-lock.integration.spec.tsYes
POST /admin/system/restores/lock/releasereleaseLockRestoreLockReleaseDtoBackupRestoreLockService.releaseLockJWT+Role+IPBackup_RESTORERedis mirror (write)N/Abackup_restore (read)409/429backup-restore-admin-lock.integration.spec.ts, backup-restore-settle.integration.spec.tsYes

13.2 Request/Response Exhaustiveness

Covered per-endpoint in Section 8 — minimal and full request bodies, success responses, and every declared error code are shown for each mutating route. The public/guest row of the standard checklist applies to no route: every route requires superadmin-level permissions, except the live restore read, which requires the restore's poll token.

13.3 API Diagram Pack

See Section 9 for route ownership, request sequence, and error-branch diagrams. A dedicated auth/permission-flow diagram is omitted as redundant: every route in this module except GET .../restores/{publicId}/live uses the identical three-guard chain (JwtAuthGuard, RoleGuard, IpThrottlerGuard) with only the @Permissions(...) argument varying, fully enumerated in Section 4.

13.4 Consumer Integration Notes

ConsumerRequired KnowledgeFailure HandlingContract Stability
Admin panelEvery route requires superadmin; regular admin accounts will see 403 on all of themShow the error code, not just the HTTP status — BACKUP_* codes are specificStable
Admin panel — Backups page, traffic lockAfter a restore ends, read GET .../restores/lock. When active is true, tell the operator the API is still refusing non-operator traffic, and which restore engaged it. Show reconciliation: pending means the restore has not settled, reconcile_failed means the API must be restarted to retry it. Offer release only when releasable is true, and only after they have checked the database. Offer force-clear for blockingRestorePublicId when its runner has died.409 BACKUP_RESTORE_ALREADY_ACTIVE means a restore is still running, its runner process is alive, or one was accepted meanwhile — details.blockingRestorePublicId names it. 409 BACKUP_RESTORE_RECONCILE_PENDING / BACKUP_RESTORE_RECONCILE_FAILED mean the restored database is not reconciled. released: false means there was nothing to release.Stable
Any client during a restoreNon-operator routes answer 503 SYSTEM_MAINTENANCE_ACTIVE while the lock holds — see 8.15Show a holding message and retry with backoff; do not treat it as an outageStable
Admin panel — restore screenPOST .../restore returns 202, not a completed restore. Poll GET .../live with backoff and treat network failures as expected, never as "failed"stage genuinely advances (queued → safety_dump → draining → restoring → verifying → completed/failed) as BackupRestoreProcessor and restore-runner.ts run. Only reconciling remains declared but never assigned. safetyBackupPublicId is populated from stage:"safety_dump" onward — surface it in the UI as the operator's way back.Stable for accept/poll/force-clear, and for the safety-dump/verification behavior itself; verification is row-count only, not content-level — see 8.9a.
Admin panel — upload screenPOST .../backups/upload is synchronous — 201 means the archive is already catalogued, not queuedAll three multipart parts are required; surface BACKUP_UPLOAD_SIGNATURE_INVALID distinctly from BACKUP_CHECKSUM_MISMATCH (wrong deployment's key vs. a corrupted transfer)Stable
Admin panel — settings pageGET/PUT .../backups/settings back a dedicated settings screen; PUT is a full-replace with the version read from the last GETStale version on save is 409 BACKUP_SETTINGS_CONFLICT — re-fetch and retry, do not silently overwriteStable
QAThe four retention floors (pinned / newest-overall / newest-in-bucket / most-recent-restore's-safety-dump) make certain deletes always fail with 409 regardless of permission — this is intended, not a bug—Stable
Internal serviceNone of these endpoints are intended for service-to-service calls; there is no service-token surface in this module—N/A

13.5 API Tradeoffs and Rationale

DecisionChosen BehaviorAlternatives ConsideredWhy This TradeoffRiskMitigation
Restore accept vs. synchronous execution202 immediately, execution genuinely async via a detached subprocessSynchronous restore in the request handlerA restore can take minutes; holding an HTTP connection open that long is fragile, and pg_restore --clean needs to survive the API process itself being redeployed mid-restoreThe client must poll GET .../live rather than trust the accept response; no built-in push notification on completionforce-clear is the escape hatch when polling reveals a stuck restore
Download rate limit keyed by user, not IPkeyStrategy: "user", 3/hourDefault IP-keyedAn IP-keyed cap on a full-database-exfiltration endpoint is bypassed by rotating source IPs with a stolen token——
Upload authenticity via keyed HMACAn archive signed by a key this deployment holds and generatedAccept any pg_dump with a client-supplied checksumA checksum authenticates nothing when the uploader supplies both the file and the hash in the same requestOperators must provision and protect one more secret, shared across the deployments that need to move backups between each other503 BACKUP_UPLOAD_SIGNING_NOT_CONFIGURED if the key is unset — fails closed rather than silently accepting unauthenticated archives
Settings full-replace PUT with versionWhole-object write with optimistic concurrencyPartial PATCH per fieldBackup policy fields interact (storageMode needs remoteReplicationEnabled); a partial write could leave an inconsistent combination mid-editClient must always read-then-write the whole object409 BACKUP_SETTINGS_CONFLICT on stale version
Settings response includes read-only environment fieldsrestoreEnabled, pgDumpPath, etc. surfaced but not editable via this DTOOmit them entirelyVisibility without editability — an operator should be able to see why restore is armed without being able to arm it from the UINone — fields are documented as read-only in the DTO's own comments—
Pre-restore safety dump taken inline, synchronously, before the runner spawnsBackupRestoreProcessor awaits BackupRunHandler.run() directly rather than enqueuing itEnqueue the safety dump like any other backup.run jobEnqueuing returns immediately; the runner could start draining connections and running pg_restore --clean while the safety dump was still mid-snapshot, letting the two raceThe restore job is blocked on the safety dump's own duration before pg_restore even startsIf the safety dump is ever slow enough to threaten BULL_QUEUE_BACKUP_RESTORE_ATTEMPTS/timeout budgets
The traffic lock is engaged only by a restore, and released only by an operator after a failureNo route engages the lock; POST .../restores/lock/release lifts one a failed restore leftA general-purpose operator toggle that engages and disengages it at willA toggle can lift the lock while pg_restore --clean holds every table, putting users in front of a database mid-replacement. Release refuses while any restore row is non-terminal, a runner pid is alive, or the restore's reconciliation is pending or reconcile_failed, so it cannotA runner pid reused by an unrelated process reads as alive and blocks release until that process exitsGET .../restores/lock reports releasable, so the panel shows why release is refused
Release refuses on any live runner pid, not only the restore that engaged the lockChecks the runnerPid in restore-state.json regardless of which restore it namesMatch the pid to the lock's restorePublicId, as force-clear doesA live runner of any restore is running pg_restore against this database; which row it belongs to does not change thatSame pid-reuse risk as above—

13.6 API Change Impact

ChangeAffected ConsumersBackend ImpactData ImpactMigration Needed?Compatibility Plan
backup_restore.verification (structured, not just row-count) is wired inAdmin panel restore screenNo RestoreLiveStateDto field currently exposes it; a new field would be additivebackup_restore.verification jsonb will begin being writtenNo schema migration — the column already existsAdditive only if/when exposed through the DTO
BACKUP_DIR becomes admin-editableDeployment runbookWould remove a boot-time safety check—N/A (deliberately not planned — see 6.6)Not recommended

14. Zero-Omission API Checklist

  • Every controller route is documented (24 route templates across 5 controllers, cross-checked against structure.baseline.json).
  • Every parent route prefix and runtime URL is documented.
  • Every DTO field, enum, default, and validator is documented.
  • Every response field, nullable field, and server-computed field is documented.
  • Every auth, guard, and permission is documented.
  • Every success and failure branch is documented, including what the restore path still does not do (safety dump, verification).
  • Every database read/write, queue job, and external call is documented.
  • Every mutating route has example requests and responses.
  • Route-ownership, sequence, and error-branch diagrams are included.
  • Every tradeoff and compatibility risk is documented, including the remaining restore-safety gap.
  • Links to backend and features/flows docs are present.

15. Integration Checklist

  • Every route from controllers is documented.
  • Every DTO field is documented.
  • Every enum value is documented.
  • Every response envelope is documented.
  • Every error code is documented.
  • Every auth guard and permission is documented.
  • Every queue job and external call is documented.
  • Every diagram matches the current code, including the restore queue's real consumer.
  • The API doc links to backend and features/flows docs.

See Also

On this page

Backup - API Reference1. Documentation Evidence2. Module Summary3. Concepts and Terminology4. API Surface MapWhy restores/lock cannot be captured by restores/{publicId}/...Why the key routes are backup-key, not backups/key5. Auth, Identity, and Permissions6. DTO and Model Reference6.1 CreateBackupDto (request body — POST /admin/system/backups)6.2 FetchBackupsDto (query — GET /admin/system/backups)6.3 UpdateBackupDto (request body — PATCH /admin/system/backups/{publicId})6.4 BackupParamsDto / BackupDownloadParamsDto / RestoreParamsDto6.5 BackupResponseDto6.6 BackupSettingsResponseDto / UpdateBackupSettingsDto6.7 RestoreBackupDto (request body — POST .../restore)6.7a RestoreAuditLogsResultDto6.8 RestoreLiveStateDto (response — POST .../restore, GET .../live)6.9 RestoreLockDto (response — GET .../restores/lock)6.10 BackupUploadDto (multipart request body — POST .../backups/upload)6.11 RestoreLockReleaseDto (response — POST .../restores/lock/release)6.12 FetchBackupFailuresDto / BackupFailureParamsDto / BackupJobFailureDto7. Enum Reference8. Endpoint Reference8.1 GET /api/admin/system/backupsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.2 POST /api/admin/system/backupsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.3 GET /api/admin/system/backups/settingsPurposeSource EvidenceAuth and PermissionsResponseSide EffectsError Cases8.4 PUT /api/admin/system/backups/settingsPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesExample Requests8.5 GET /api/admin/system/backups/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseError Cases8.6 PATCH /api/admin/system/backups/{publicId}PurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError Cases8.7 DELETE /api/admin/system/backups/{publicId}PurposeSource EvidenceAuth and PermissionsResponseSide EffectsError Cases8.8 GET /api/admin/system/backups/{publicId}/downloadPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesExample Requests8.9 POST /api/admin/system/backups/{publicId}/restorePurposeSource EvidenceAuth and PermissionsRequestResponseSide Effects8.9a Known gap: verification is row-count onlyError CasesExample Requests8.10 GET /api/admin/system/restores/{publicId}/livePurposeSource EvidenceAuth and PermissionsResponseError Cases8.11 POST /api/admin/system/restores/{publicId}/force-clearPurposeSource EvidenceAuth and PermissionsResponseSide EffectsError Cases8.12 GET /api/admin/system/restores/lockPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError Cases8.13 POST /api/admin/system/restores/lock/releasePurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesExample Requests8.14 POST /api/admin/system/backups/uploadPurposeSource EvidenceAuth and PermissionsRequestResponseSide EffectsError CasesEdge CasesExample Requests8.15 While the traffic lock holds — what answers and what refuses8.16 GET /api/admin/system/backup-key8.17 POST /api/admin/system/backup-key/generate8.18 POST /api/admin/system/backup-key/import8.19 POST /api/admin/system/backup-key/rotate8.20 POST /api/admin/system/backup-key/resolve-signatures8.21 DELETE /api/admin/system/backup-key/retired/{fingerprint}8.23 GET /api/admin/system/backups/{publicId}/download/uploadsRefusalsNo server-side hash, and why that is not a weakeningThe open happens before any header is setAudit8.22 Forwarding the operator's address8.24 GET /api/admin/system/backups/failures8.25 POST /api/admin/system/backups/failures/{publicId}/retry-pruneRefusals8.26 POST /api/admin/system/backups/failures/{publicId}/retry-replicationRefusals9. Flow Diagrams9.1 Route Ownership9.2 Request Sequence — manual backup9.3 Error Branch — restore request10. Pagination, Sorting, Filtering, and Search11. Caching, Jobs, and External Integrations13. Mandatory Deep API Documentation Pack13.1 Route-by-Route Completeness Matrix13.2 Request/Response Exhaustiveness13.3 API Diagram Pack13.4 Consumer Integration Notes13.5 API Tradeoffs and Rationale13.6 API Change Impact14. Zero-Omission API Checklist15. Integration ChecklistSee Also