fix(api): publish the backup upload

POST /api/admin/backups/upload stores a backup file as a new copy, the
copy whose id the published restore takes, and it refuses a file for
reasons an operator has to be able to look up: a key this server does
not hold (backup.key.missing, with the key ids), an owner account that
does not exist here, a newer schema, a copy too large to store
(backup.upload.too_large). It was exempted from the contract as console
plumbing while its two siblings were published for exactly that kind
of refusal.

It is registered with the three bodies it reads (raw JSON, raw gzip, a
small multipart form) and every status it answers; the summary is the
three identity fields plus one integer count per class, so a class the
backup format gains later does not break a decoder. The round-trip
test calls it through the contract now.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 07:08:02 +02:00
1 parent 579789ec38
commit d396945f44
4 files changed
+257 -12

No files matched your search

+142
View File
@@ -24785,6 +24785,93 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/admin/backups/upload:
post:
tags:
- Admin
summary: Store an uploaded backup file as a new copy
description: "Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT
restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review
what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed
from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as
`multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the
same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the
backup schema, was written by a newer schema version than this server reads, needs an encryption key this server
does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account
that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens
cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give."
requestBody:
required: true
content:
application/json:
schema:
type: object
propertyNames:
type: string
additionalProperties: {}
description: The backup document, as `GET /api/admin/backups/{id}/download` or the account export wrote it.
application/gzip:
schema:
type: string
format: binary
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
description: The backup file, plain or gzip-compressed, up to 10 MB.
required:
- file
responses:
"201":
description: The file was stored as a new copy.
content:
application/json:
schema:
$ref: "#/components/schemas/AdminBackupUploadEnvelope"
"400":
description: A `multipart/form-data` body that could not be parsed as a form. Nothing was stored.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401":
description: "No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with
`meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403": *a25
"413":
description: "Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this
server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` =
`backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as
gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not
hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist
here."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: More than three uploads in a minute by this administrator. Nothing was stored. The `Retry-After` and
`X-RateLimit-*` headers describe the bucket.
headers: *a8
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/admin/backups/{id}/download:
get:
tags:
@@ -88681,6 +88768,61 @@ components:
- data
- error
additionalProperties: false
AdminBackupUploadEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AdminBackupUploadResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AdminBackupUploadResponse:
type: object
properties:
id:
type: string
description: "`DataBackup.id` of the stored copy; pass it to `POST /api/admin/backups/{id}/restore`."
valid:
type: boolean
const: true
summary:
$ref: "#/components/schemas/AdminBackupUploadSummary"
required:
- id
- valid
- summary
additionalProperties: false
description: The file was read, checked and stored as a new copy. Nothing was restored.
AdminBackupUploadSummary:
type: object
properties:
schemaVersion:
type: string
userId:
type: string
exportedAt:
type: string
required:
- schemaVersion
- userId
- exportedAt
additionalProperties:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: "What the stored file holds: its `schemaVersion`, the account it was exported from (`userId`) and when
(`exportedAt`), and one count per serialised class (`measurements`, `medications`, `intakeEvents`,
`moodEntries`, …). The set of counts grows with the backup format; read the ones you know and show the rest
generically."
AdminBackupDocument:
type: object
propertyNames:
@@ -216,11 +216,13 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
"/api/admin/backup/test": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/backups/run": { kind: "adminConsole", methods: ["POST"] },
"/api/admin/backups/upload": { kind: "adminConsole", methods: ["POST"] },
// `/api/admin/backups/{id}/download` and `/api/admin/backups/{id}/restore`
// are published rather than exempted: both open the encrypted stored copy,
// and the refusal they answer when it will not open is a promise an operator
// needs to be able to look up. See `src/lib/openapi/routes/admin-backups.ts`.
// `/api/admin/backups/{id}/download`, `/api/admin/backups/{id}/restore` and
// `/api/admin/backups/upload` are published rather than exempted: the first
// two open the encrypted stored copy, and the refusal they answer when it
// will not open is a promise an operator needs to be able to look up; the
// upload refuses a file for reasons (a missing key, an unknown owner, a
// newer schema) an operator has to be able to look up the same way. See
// `src/lib/openapi/routes/admin-backups.ts`.
"/api/admin/backups/{id}/summary": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/central-codex": {
kind: "adminConsole",
+108 -4
View File
@@ -1,13 +1,13 @@
/**
* OpenAPI route table — the backups-console routes that read a stored copy, and
* the restore job they start.
* the restore job they start, and the upload that stores a copy to restore.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*
* Most of `/api/admin/backups/*` is deliberately unpublished: it is a
* cookie-only console, `requireAdmin()` refuses every Bearer caller, and a
* contract nobody can call against is a contract nobody reads. These two are
* here for the opposite reason. Both open the encrypted stored copy (pieces in
* contract nobody can call against is a contract nobody reads. The two readers
* are here for the opposite reason. Both open the encrypted stored copy (pieces in
* `data_backup_chunks`, or one value in `data_backups.data` for a copy written
* before v1.39.2), and both therefore have a refusal an operator meets on
* the day a key rotation went one step too far — the case
@@ -16,6 +16,11 @@
* the handler happens to answer, and a promise belongs somewhere it can be
* quoted from.
*
* The upload is here for the same reason: it refuses a file the operator has
* in hand for reasons they have to be able to look up (a key this server does
* not hold, an owner that does not exist here, a newer schema), and its answer
* is the copy id the restore takes.
*
* The restore answers 202 and runs as a background job, so the two routes that
* report on that job are published with it: a 202 without its status route is
* half a contract.
@@ -23,7 +28,12 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, errorEnvelope, idempotencyKeyParameter } from "./shared";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
stdResponses,
} from "./shared";
const restoreRequest = z
.object({
@@ -168,7 +178,101 @@ const notFoundResponse = {
content: { "application/json": { schema: errorEnvelope } },
};
const uploadSummary = z
.object({
schemaVersion: z.string(),
userId: z.string(),
exportedAt: z.string(),
})
.catchall(z.number().int())
.meta({
id: "AdminBackupUploadSummary",
description:
"What the stored file holds: its `schemaVersion`, the account it was exported from (`userId`) and when (`exportedAt`), and one count per serialised class (`measurements`, `medications`, `intakeEvents`, `moodEntries`, …). The set of counts grows with the backup format; read the ones you know and show the rest generically.",
});
const uploadStored = z
.object({
id: z.string().meta({
description:
"`DataBackup.id` of the stored copy; pass it to `POST /api/admin/backups/{id}/restore`.",
}),
valid: z.literal(true),
summary: uploadSummary,
})
.meta({
id: "AdminBackupUploadResponse",
description:
"The file was read, checked and stored as a new copy. Nothing was restored.",
});
export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/admin/backups/upload": {
post: {
tags: ["Admin"],
summary: "Store an uploaded backup file as a new copy",
description:
"Reads a backup file, checks it, and stores it encrypted as a new copy beside the weekly one; it does NOT restore anything. Restoring is the separate `POST /api/admin/backups/{id}/restore`, so an operator can review what was uploaded first. The file goes up as the raw request body, plain JSON or gzip-compressed JSON (sniffed from the bytes, not the content type), up to 512 MB as sent; or, for a file up to 10 MB, as `multipart/form-data` with one `file` field. It is read as a stream, checked as it arrives, and stored in the same pass; a file that fails any check leaves nothing behind. It is refused when it is not valid JSON, fails the backup schema, was written by a newer schema version than this server reads, needs an encryption key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds` naming them), or belongs to an account that does not exist here. Three uploads a minute per administrator. Admin session cookie required; Bearer tokens cannot reach admin endpoints. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give.",
requestBody: {
required: true,
content: {
"application/json": {
schema: z.record(z.string(), z.unknown()).meta({
description:
"The backup document, as `GET /api/admin/backups/{id}/download` or the account export wrote it.",
}),
},
"application/gzip": {
schema: z.string().meta({ format: "binary" }),
},
"multipart/form-data": {
schema: z.object({
file: z.string().meta({
format: "binary",
description:
"The backup file, plain or gzip-compressed, up to 10 MB.",
}),
}),
},
},
},
responses: {
"201": {
description: "The file was stored as a new copy.",
content: {
"application/json": {
schema: dataEnvelope(uploadStored, "AdminBackupUploadEnvelope"),
},
},
},
"400": {
description:
"A `multipart/form-data` body that could not be parsed as a form. Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": {
description:
"No session (`auth.missing`), or a session without a recent proof (`auth.reproof.required`, with `meta.methods`). A Bearer token lands here too: the admin gate reads only the session cookie.",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description:
"Too large: over 512 MB as sent (10 MB in a form), a gzip file that expands past what any backup this server wrote does, or larger than the stored-copy limit once encrypted (`meta.errorCode` = `backup.upload.too_large`, the numbers and the setting in the message). Nothing was stored.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"The file was refused and nothing was stored: no file in the request, not valid JSON or not readable as gzip, failed the backup schema (`meta.issues`), an unsupported schema version, a key this server does not hold (`meta.errorCode` = `backup.key.missing`, `meta.keyIds`), or an owner account that does not exist here.",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
...stdResponses["429"],
description:
"More than three uploads in a minute by this administrator. Nothing was stored. The `Retry-After` and `X-RateLimit-*` headers describe the bucket.",
},
},
},
},
"/api/admin/backups/{id}/download": {
get: {
tags: ["Admin"],
@@ -1124,9 +1124,6 @@ describe("every setting a person can choose", () => {
route: "/api/admin/backups/upload",
body: exported.body,
auth: operator,
contract: {
skip: "the operator's backup routes are not in the published contract",
},
});
expect(
uploaded.status,