fix(backup): refuse a stored copy that will not open with 422

A `DataBackup` row that fails to decrypt answered 500 on the restore, the
download and the preview. It is not a server fault: either the key that wrote
the copy is no longer in `ENCRYPTION_KEYS` — a rotation that dropped the legacy
entry before the script reported zero rows, the case the rotation runbook warns
about — or the stored bytes are not the ones that were written. Every other
bad-input arm on those routes already answers 4xx, and the 500 also paged the
error reporter as if the process had broken.

All three now answer 422 with `meta.errorCode = backup.payload.undecryptable`
and one shared sentence, so the three routes cannot drift into three wordings
for one condition. The download's second 500 stays: a payload this instance
wrote and cannot parse really is a fault here.

The two routes that open a stored copy move from the unpublished list into the
contract, because the refusal is now a promise an operator can look up rather
than whatever the handler happened to answer. The restore publishes its
`Idempotency-Key` header with them.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 05:25:30 +02:00
1 parent 6b366b4f27
commit d3f087475c
11 files changed
+471 -27

No files matched your search

@@ -19,7 +19,11 @@ import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
} from "@/lib/export/backup-blob";
import { annotate } from "@/lib/logging/context";
import { parseBackupPayload } from "@/lib/validations/backup";
@@ -54,9 +58,10 @@ export const GET = apiHandler(
try {
plaintext = unpackBackupBlob(backup.data);
} catch (err) {
// Decryption failure is rare but real — a rotated/missing key, or a
// corrupted blob from a partially-failed write. Surface it as a 500
// and audit the event so the admin has something to investigate.
// A rotated or dropped key, or a stored copy that is no longer the one
// that was written. Both are bad stored input rather than a fault in
// this process, so the answer is 422 with a reason the operator can act
// on — and the audit row keeps the underlying message.
await auditLog("admin.backups.download.failed", {
userId: admin.id,
ipAddress: getClientIp(request),
@@ -66,7 +71,9 @@ export const GET = apiHandler(
reason: err instanceof Error ? err.message : "decrypt_failed",
},
});
return apiError("Failed to decrypt backup payload", 500);
return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, {
errorCode: BACKUP_UNDECRYPTABLE_CODE,
});
}
// Validate the payload BEFORE handing it back. This catches the
@@ -23,7 +23,11 @@ import { prisma, toJson } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
} from "@/lib/export/backup-blob";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptContextToBytes } from "@/lib/labs/biomarker-store";
@@ -204,7 +208,14 @@ const handler = apiHandler(
reason: err instanceof Error ? err.message : "decrypt_failed",
},
});
return apiError("Failed to decrypt backup payload", 500);
// Bad stored input, not a broken server: a copy written under a key the
// operator has since dropped, or one whose bytes have changed. Every
// other bad-input arm on this route answers 4xx, and a 500 here would
// also page the error reporter for a rotation mistake. Refused above the
// transaction, so nothing was touched.
return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, {
errorCode: BACKUP_UNDECRYPTABLE_CODE,
});
}
// Parsed once and kept, because the schema's per-section `.default([])`
@@ -81,12 +81,18 @@ describe("GET /api/admin/backups/[id]/summary", () => {
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
});
it("degrades to an error response when the file cannot be decrypted", async () => {
it("refuses a file it cannot decrypt with the documented 422", async () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
const res = await GET(request, params);
expect(res.status).toBe(500);
// Bad stored input — a key dropped from `ENCRYPTION_KEYS`, or bytes that
// are not the ones written — and not a fault in this process, so it does
// not answer 500 and does not reach the error reporter as one.
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.payload.undecryptable" },
});
});
it("422s a file that fails schema validation", async () => {
@@ -16,7 +16,11 @@ import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { unpackBackupBlob } from "@/lib/export/backup-blob";
import {
BACKUP_UNDECRYPTABLE_CODE,
BACKUP_UNDECRYPTABLE_ERROR,
unpackBackupBlob,
} from "@/lib/export/backup-blob";
import { annotate } from "@/lib/logging/context";
import {
isCompatibleSchemaVersion,
@@ -46,7 +50,12 @@ export const GET = apiHandler(
try {
plaintext = unpackBackupBlob(backup.data);
} catch {
return apiError("Failed to decrypt backup payload", 500);
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
// is the first place an operator meets it.
return apiError(BACKUP_UNDECRYPTABLE_ERROR, 422, {
errorCode: BACKUP_UNDECRYPTABLE_CODE,
});
}
let payload;