You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A backup that exists only inside the container is a restore point that cannot survive the container. #349 adds download, which gets a copy out; this is the other half — bringing one back, after a volume
is recreated, a host is replaced, or every stored backup has been removed to clear the quota.
It is also the only path that recovers an installation whose stored backups are all gone or all
unreadable. Everything else in this cluster assumes a usable file is already in {dataDir}/backups/.
What needs to be done
POST /api/v1/admin/backups/upload, multipart, in the Backup OpenAPI category behind the admin
API key and the admin rate-limit policy. An explicit action route rather than POST /backups,
which would otherwise have to mean both "accept these bytes" and "create one from the live
database" — see Admin endpoints to list, delete and report status for database backups #349's create endpoint. UploadBackup / "Upload a backup" per the naming convention,
with the WithName value held in a private const string shared with its logging tag.
The bytes are validated as a SQLite database before the file is accepted — the SQLite format 3
header magic and an actual open plus a read that proves it is usable, not the extension and not the
magic alone. Storing a file that only reveals itself as unusable later, at restore time, is the exact
failure mode this requirement exists to design out.
An explicit maximum upload size, configurable, refused with a stated reason — never a truncated
write, and never the framework's bare 413 with no detail about what the limit is.
The stored file name is sanitised server-side — never taken from the client as-is. No separators,
no traversal segments, and the resolved path verified to sit inside BackupsPath.
The stored name must be unique, and a collision is refused rather than resolved. An upload whose
sanitised name already exists returns a 409 naming the existing file, and stores nothing. Silently
auto-renaming to a free name was considered and rejected: it makes "unique" true by fiat and hands
back a file under a name the operator did not choose, which is worse than being told.
Uniqueness is decided case-insensitively.Backup.db and backup.db are one file on Windows and
two on Linux; without this, whether an upload destroys an existing restore point would depend on the
host filesystem. Consistent with CLAUDE.md's case-insensitive-by-default rule, applied here to a
file name rather than a column.
An existing backup is replaced only on explicit permission, via a parameter that is never a
default. Overwriting destroys a restore point, so the caller states that intent per call and the
audit entry records which file was replaced, not merely that an upload happened — the same
"why is there no backup from that date" property Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348 established for a skipped backup.
This is deliberately not the pattern the endpoint side-effect policy forbids, and the distinction
is stated here so it is not later "corrected" away: an opt-in flag is forbidden when it bolts a
second, independent action onto the endpoint (the rejected restore=true below). This flag adds no
second action — it authorises how this endpoint's own single job resolves a collision, exactly as allowNoBackup does for a Reset that cannot be backed up.
The endpoint stores the file and does nothing else (developer decision, 2026-08-29). An optional restore flag was considered and rejected: a parameter that changes what data survives the
call is the shape CLAUDE.md's endpoint side-effect policy names explicitly, and the recovery path
does not need it — upload, then call restore (Restore a stored backup, refusing one taken ahead of this build #352). Two explicit actions, neither doing the other's
job.
Failed validation stores nothing. A rejected upload leaves the backups folder exactly as it was.
An upload writes an audit entry.
The endpoint answers while the database is degraded — recovering an installation with no usable
backup is a primary use, and that installation is degraded by definition. Nothing here reads
database content.
docs/api-endpoints.md and the [Description] attributes are updated in the same commit.
Upload_WithOverwritePermitted_ReplacesTheExistingBackup is the positive control for requirements 6–8:
a suite that only ever asserts refusals would stay green against a build that refused every upload.
Definition of done
All expected tests listed above start red before implementation
All requirements implemented
All expected tests pass (green)
No regression in related tests
Findings summarised in a closing comment
Scope boundary
This endpoint does not restore. Restoring an uploaded backup is a second, explicit call to the
restore endpoint (#352). The two have no build-order dependency in either direction.
Background
A backup that exists only inside the container is a restore point that cannot survive the container.
#349 adds download, which gets a copy out; this is the other half — bringing one back, after a volume
is recreated, a host is replaced, or every stored backup has been removed to clear the quota.
It is also the only path that recovers an installation whose stored backups are all gone or all
unreadable. Everything else in this cluster assumes a usable file is already in
{dataDir}/backups/.What needs to be done
POST /api/v1/admin/backups/upload, multipart, in theBackupOpenAPI category behind the adminAPI key and the
adminrate-limit policy. An explicit action route rather thanPOST /backups,which would otherwise have to mean both "accept these bytes" and "create one from the live
database" — see Admin endpoints to list, delete and report status for database backups #349's create endpoint.
UploadBackup/ "Upload a backup" per the naming convention,with the
WithNamevalue held in aprivate const stringshared with its logging tag.The bytes are validated as a SQLite database before the file is accepted — the
SQLite format 3header magic and an actual open plus a read that proves it is usable, not the extension and not the
magic alone. Storing a file that only reveals itself as unusable later, at restore time, is the exact
failure mode this requirement exists to design out.
An explicit maximum upload size, configurable, refused with a stated reason — never a truncated
write, and never the framework's bare
413with no detail about what the limit is.The quota is checked before anything is written. An upload consumes the same budget Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348
governs, so a full folder refuses in the same vocabulary (
BudgetExceeded) with the same remedies,rather than pushing the folder past a limit every other path respects.
The stored file name is sanitised server-side — never taken from the client as-is. No separators,
no traversal segments, and the resolved path verified to sit inside
BackupsPath.The stored name must be unique, and a collision is refused rather than resolved. An upload whose
sanitised name already exists returns a
409naming the existing file, and stores nothing. Silentlyauto-renaming to a free name was considered and rejected: it makes "unique" true by fiat and hands
back a file under a name the operator did not choose, which is worse than being told.
Uniqueness is decided case-insensitively.
Backup.dbandbackup.dbare one file on Windows andtwo on Linux; without this, whether an upload destroys an existing restore point would depend on the
host filesystem. Consistent with
CLAUDE.md's case-insensitive-by-default rule, applied here to afile name rather than a column.
An existing backup is replaced only on explicit permission, via a parameter that is never a
default. Overwriting destroys a restore point, so the caller states that intent per call and the
audit entry records which file was replaced, not merely that an upload happened — the same
"why is there no backup from that date" property Reset returns an unhandled 500 when no backup can be taken, and the five backup failure causes are indistinguishable #348 established for a skipped backup.
This is deliberately not the pattern the endpoint side-effect policy forbids, and the distinction
is stated here so it is not later "corrected" away: an opt-in flag is forbidden when it bolts a
second, independent action onto the endpoint (the rejected
restore=truebelow). This flag adds nosecond action — it authorises how this endpoint's own single job resolves a collision, exactly as
allowNoBackupdoes for a Reset that cannot be backed up.The endpoint stores the file and does nothing else (developer decision, 2026-08-29). An
optional restoreflag was considered and rejected: a parameter that changes what data survives thecall is the shape
CLAUDE.md's endpoint side-effect policy names explicitly, and the recovery pathdoes not need it — upload, then call restore (Restore a stored backup, refusing one taken ahead of this build #352). Two explicit actions, neither doing the other's
job.
Failed validation stores nothing. A rejected upload leaves the backups folder exactly as it was.
An upload writes an audit entry.
The endpoint answers while the database is degraded — recovering an installation with no usable
backup is a primary use, and that installation is degraded by definition. Nothing here reads
database content.
docs/api-endpoints.mdand the[Description]attributes are updated in the same commit.Expected tests
Upload_WithOverwritePermitted_ReplacesTheExistingBackupis the positive control for requirements 6–8:a suite that only ever asserts refusals would stay green against a build that refused every upload.
Definition of done
Scope boundary
This endpoint does not restore. Restoring an uploaded backup is a second, explicit call to the
restore endpoint (#352). The two have no build-order dependency in either direction.