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,