From d396945f443c884b9f212f8fa0cb4c5b4aa4b467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 07:08:02 +0200 Subject: [PATCH] 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. --- docs/api/openapi.yaml | 142 ++++++++++++++++++ .../openapi-route-coverage-guard.test.ts | 12 +- src/lib/openapi/routes/admin-backups.ts | 112 +++++++++++++- tests/integration/settings-round-trip.test.ts | 3 - 4 files changed, 257 insertions(+), 12 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e6762c399..a3bddb974 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index a1210c215..cf7bf490b 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -216,11 +216,13 @@ const UNPUBLISHED: Readonly> = { "/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", diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index 673eb6912..686c2adf5 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -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 = { + "/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"], diff --git a/tests/integration/settings-round-trip.test.ts b/tests/integration/settings-round-trip.test.ts index d89a6a4e2..a04500096 100644 --- a/tests/integration/settings-round-trip.test.ts +++ b/tests/integration/settings-round-trip.test.ts @@ -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,