Merge the backup and restore end-to-end journey, the restore that leaves instance settings alone and the 422 for an unreadable copy

This commit is contained in:
Marc-André Bombeck committed 2026-09-10 06:09:23 +02:00
commit ee73e840e0
27 files changed
+1759 -59

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
@@ -11,6 +11,10 @@
* Metadata-only portable document exports are rejected before mutation; the
* importer never fabricates content. Audit rows remain outside the wipe, and
* cache invalidation runs only after the complete restore transaction.
*
* Everything above is scoped to ONE account. The instance-wide settings a
* disaster-recovery payload also carries are not, and they are opt-in for that
* reason: see the `restoreInstanceSettings` block below.
*/
import { Buffer } from "node:buffer";
@@ -19,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";
@@ -142,17 +150,26 @@ const handler = apiHandler(
const { id } = await params;
annotate({ action: { name: "admin.backups.restore" }, meta: { id } });
let body: { confirm?: string } = {};
let body: { confirm?: string; restoreInstanceSettings?: boolean } = {};
try {
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw) as { confirm?: string };
body = JSON.parse(raw) as {
confirm?: string;
restoreInstanceSettings?: boolean;
};
} catch {
return apiError("Invalid JSON body", 400);
}
// Restoring one account's data and reconfiguring the whole host are two
// different decisions, so they are two different answers. Anything but a
// literal `true` restores the account alone.
const restoreInstanceSettings = body.restoreInstanceSettings === true;
annotate({ meta: { restore_instance_settings: restoreInstanceSettings } });
if (body.confirm !== "RESTORE") {
await auditLog("admin.backups.restore.denied", {
userId: admin.id,
@@ -160,7 +177,7 @@ const handler = apiHandler(
details: { reason: "missing_confirmation", backupId: id },
});
return apiError(
"Confirmation token missing — restoring user data and included instance-wide settings requires confirm: 'RESTORE'",
"Confirmation token missing — replacing an account's data requires confirm: 'RESTORE'",
422,
);
}
@@ -191,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([])`
@@ -292,6 +316,7 @@ const handler = apiHandler(
ownerId,
ownerUsername: owner.username,
snapshotExportedAt: payload.exportedAt,
restoreInstanceSettings,
},
});
@@ -441,7 +466,20 @@ const handler = apiHandler(
where: { userId: ownerId },
});
if (payload.appSettings) {
// The one section of the file that is not this account's.
//
// A disaster-recovery payload carries the singleton `app_settings`
// row — registration, the MFA requirement, the default locale and
// timezone, module availability, the notification and AI
// configuration, the document size cap and quota. Writing it back as
// a side effect of restoring ONE account reconfigures the host for
// everybody on it, silently and hours or weeks out of date: an
// operator putting a single record back on Wednesday has no reason to
// expect Monday's registration switch and Monday's upload cap to come
// with it. Rebuilding a host from a snapshot is a real case, so the
// write stays available — as an answer the operator gives, not one
// the account restore assumes.
if (payload.appSettings && restoreInstanceSettings) {
const settings = payload.appSettings;
const settingsData = {
registrationEnabled: settings.registrationEnabled,
@@ -2084,6 +2122,10 @@ const handler = apiHandler(
backupId: id,
ownerId,
ownerUsername: owner.username,
// Whether this restore also rewrote the host's own settings. The one
// effect of a restore that reaches accounts other than `ownerId`, so
// the trail has to be able to answer it later.
restoreInstanceSettings,
cleared,
// The durable half of the report. The response reaches whoever was
// looking at the screen; the audit row is still here next week when
@@ -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;
+5 -3
View File
@@ -2,9 +2,11 @@
* GET /api/admin/backups — admin-only listing of `DataBackup` rows.
*
* Returns one row per (userId, type) pair. The `data` payload is NOT
* shipped — only metadata (id, userId, username, type, size in bytes,
* createdAt). The encrypted blob remains server-side; admins can trigger
* a re-snapshot but not download another user's payload from the UI.
* shipped by THIS route — only metadata (id, userId, username, type, size in
* bytes, createdAt), so the listing costs a count and not a decrypt. The
* payload has its own route: `/api/admin/backups/<id>/download` decrypts one
* snapshot for the admin who asks for it, and the ciphertext stays in the
* database either way.
*
* Alongside the rows, the response says whether the SCHEDULE is still alive:
* how old the newest scheduled copy is and how the last scheduled run ended.