Repository navigation
Admin endpoints to list, delete and report status for database backups #349
Description
Activity
- added 14 commits that reference this issue
on Aug 27, 2026 Two items were split out of this issue rather than built here, and one was added to it.
Deferred
AuditOperationas an enum — AuditOperation is a string-constant set where the project's convention is an enum #351.Audit_Entry.Operationis aTEXTcolumn backed by
const stringmembers with noCHECKconstraint. This issue adds a thirteenth member in that shape;
converting it needs a table-rebuild migration, a baseline update and a schema-drift test, which is a
schema issue rather than an endpoint one.- A per-file record for each backup — File metadata: sidecar and database record for every file we create or inspect #330. The audit schema reserves
RecordIdfor an affected
row's UUID and documentsnullfor admin actions, so an audit entry records that a backup was
removed but not which. The file name is carried by the log line instead. Extending the audit schema
was rejected in favour of File metadata: sidecar and database record for every file we create or inspect #330's file-keyed record, which exists for exactly this; File metadata: sidecar and database record for every file we create or inspect #330 covers it in
principle ("applies to all files we create") but its establishment hook is the import path, which a
backup never passes through.
Added during implementation
The scope grew from three endpoints to five (2026-08-29):
GET /{name}/contentto download a backup,
andPOST /createto take one on demand. The issue body was updated in place to match. Create exists
because #352's restore deliberately does not take a backup of its own — the operator takes one here,
explicitly, if they want one.Not audited, deliberately
A
BackupDownloadedaudit operation was added and then removed.docs/logging.mdstates that read
operations are not in the audit trail, and a download leaves the backup in place — it copies rather than
removes (developer decision, 2026-08-29). Reads are logged at Debug; create and delete at Information.- added a commit that references this issue
on Aug 29, 2026
Background
Quotinator writes a safety backup before every migration, seed and Reset, into
{dataDir}/backups/. Nothing can see them. There is no endpoint to list what exists, and no way to remove one — the only route is filesystem access to the container's volume, which a Home Assistant add-on user does not meaningfully have.That gap has a concrete consequence rather than being merely untidy. The backups folder carries a hard size budget (
MaxBackupStorageGb, default 1 GB). When it fills, backups stop being possible — and since #348 landed, that is a refusal: a startup degrades, or a Reset declines to run, because the folder is full. The remedy is to remove old backups, and there is currently no in-app way to do it.There is a second gap of the same kind. A backup today only ever happens as a side effect of a migration, a seed or a Reset — an operator who wants a restore point before doing something has no way to ask for one, and no way to get a copy of one off the container.
Endpoints are preferred over telling an operator to delete or copy files by hand, for the reason stated in that issue's direction: an endpoint carries authorization and writes an audit trail, which is exactly what is wanted when diagnosing a problem that arises from the action later.
What needs to be done
GET /api/v1/admin/backups— list the backup files that exist, each with the facts needed to decide which to remove: file name, size, and when it was taken. Paginated perCLAUDE.md's standard pagination contract, with the full eight-case test matrix that contract requires of every new paginated GET.DELETE /api/v1/admin/backups/{name}— remove one backup, identified by the file name the list returns.GET /api/v1/admin/backups/status— the one question an operator actually has, answered before they act rather than discovered when a Reset declines: can a backup be made right now, and are we within quota? (developer decision, 2026-08-27).Two halves, deliberately in one endpoint rather than two:
It also reports real free disk space alongside the quota figures. These are independent constraints — the quota is self-imposed, free space is physical — and the backup path checks both, so showing only one would answer half the question and mislead exactly when the other is the binding one.
It reads no database content and touches the filesystem only to the extent of the pre-flight's own zero-byte writability probe, which is what makes it safe to call while degraded (corrected 2026-08-29 — this was previously described as read-only, which the probe write makes untrue; a check that writes nothing proves nothing, per Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348's own finding).
GET /api/v1/admin/backups/{name}/content— download one backup, identified by the name the list returns, so a restore point can survive the container it was taken in. Streamed rather than buffered, with aContent-Dispositionattachment name matching the stored file.Note the interaction with the
adminpolicy: it is concurrency-1, so a download holds the only admin permit until it completes. At the expected size (~8 MB, perDatabaseOptions' own sizing note) that is immaterial; over a slow link it locks out every other admin route for the duration. Accepted rather than worked around, and recorded here so it is a known trade-off rather than a surprise.POST /api/v1/admin/backups/create— take a backup of the live database on demand (developer decision, 2026-08-29). Today a backup is only ever a side effect of something else; this makes it an action an operator can invoke when they want a restore point, and it is what Restore a stored backup, refusing one taken ahead of this build #352's restore endpoint deliberately does not do on their behalf.It refuses in Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348's vocabulary — the obstacle plus its remedies — when a backup cannot be taken, exactly as a refused Reset does, rather than returning a success that produced no file. The response names the file it created, so the caller can then download it via requirement 4. An explicit action route rather than
POST /backups, which would otherwise have to mean both this and Upload a backup file #353's upload.All five are
admin-group endpoints, behind the admin API key and theadminconcurrency-1 rate-limit policy, like every other/api/v1/admin/**route.All five must answer while the database is degraded, which is the state they exist for: an operator whose startup refused for want of a backup needs to see the quota, remove something, take a fresh one, and retry. They must therefore reach their handlers rather than being answered by the health gate — the property Startup crashes instead of degrading when the data directory is read-only and a migration is pending #326's
Startup_DataDirectoryNotWritable_OpenApiRemainsReachableForRecoveryalready asserts for the admin surface, tested here for these routes specifically rather than assumed to follow.A deletion and a creation each write an audit entry. This is the reason for preferring an endpoint over manual file management, so it is a requirement rather than a nicety: a later "why is there no backup from that date" question must be answerable.
AuditOperation.Backupalready exists for the creation case and has never had a producer; the deletion needs a new member.No
{name}route can escape the backups folder. The parameter is a file name, never a path — anything containing a separator or traversal segment is rejected before it reaches the filesystem, and the resolved path is verified to sit insideBackupsPath. One guard, shared by delete and download, not two implementations of the same check.A
{name}that does not exist is a404, for both delete and download, not a silent success and not an empty file — so a caller can tell "removed" from "was never there".docs/api-endpoints.mdand the endpoints'[Description]attributes are updated in the same commit, perCLAUDE.md's "Keeping API documentation in sync".These endpoints form their own OpenAPI category,
Backup(developer decision, 2026-08-27) — a newApiTags.Backupconstant, not theApiTags.Adminthat every/admin/**route carries today. A deliberate departure from that group-level default, stated here so the next reader does not "correct" it back: backup management is a distinct operator task, and burying these endpoints among Admin's reseed/reset/audit routes hides them from exactly the operator who has come looking because a backup could not be taken.That means their own
MapGroupcarrying.WithTags(ApiTags.Backup), while still chaining the admin API-key requirement and.RequireRateLimiting(RateLimitPolicies.Admin)— the category is a documentation grouping, never a relaxation of who may call them.The tag is declared with a description in the same commit, per ADR 020:
.WithTags(...)puts an endpoint in a group but gives it neither description nor position, which come from the hand-maintaineddocument.Tagsarray inProgram.cs'sAddDocumentTransformer. A tag used but not declared still renders — bare, undescribed, and after the declared ones — which is exactly howApiTags.Notificationssurvived two releases before Restructure the T2 suite into docs/automated-testing/, one document per test #339 found it.OpenApiSpecEndpointTests.EveryTagAnEndpointUses_IsDeclaredWithADescriptionenforces it, and an empty description fails the same rule.Endpoint naming follows the convention:
GetAllBackups/ "List backups" for the list, and a PascalCase verb+object with an imperative summary for the actions —DeleteBackup,CreateBackup/ "Create a backup" — perCLAUDE.md's endpoint naming convention, with eachWithNamevalue held in aprivate const stringreferenced by both the registration and its logging tag.Two endpoints are none of the three standard shapes, and each notes its deviation at the call site as the convention requires rather than being forced into wording that would misdescribe it. The status endpoint is not a list, not a fetch by id, and not an action that changes anything:
GetBackupStatus/ "Backup status". The download is a fetch, but by name rather than by id:GetBackupContent/ "Backup content by name".Expected tests
DeleteBackup_PathTraversalAttempt_IsRejectedAndDeletesNothingasserts both halves deliberately: a rejected status alone would pass even if the file had already been removed.Download_PathTraversalAttempt_IsRejectedAndServesNothingdoes the same for the read side.GetStatus_AgreesWithWhatACreateAttemptActuallyDoesis the check-and-attempt-agree property #348 found was worth its own test: a status endpoint that says a backup is possible when the create endpoint then refuses is worse than no status endpoint.Definition of done
Scope boundary
Restoring a backup is #352, and uploading one from outside the container is #353 — both filed 2026-08-29, both independent of this issue in either direction.
This issue is the file-management half: everything here either reads the backups folder or adds to it, and nothing here writes to the live database. That is what keeps it separable from #352, which is the only endpoint in the group that replaces the database, and it is why the create endpoint here is not folded into #352 — a restore taking its own backup would bolt a second data-retention decision onto an endpoint with one job.