mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
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:
commit
ee73e840e0
27 files changed
+1759
-59
No files matched your search
@@ -124,10 +124,7 @@ function idempotentOperationsFromContract(): Set<string> {
|
||||
* Listed rather than filtered silently, so the exclusion stays visible in the
|
||||
* file that would otherwise claim full coverage.
|
||||
*/
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
|
||||
"/api/admin/backups/{id}/restore":
|
||||
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
|
||||
};
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {};
|
||||
|
||||
describe("idempotency — the contract matches the wrapper", () => {
|
||||
it("finds idempotent operations in the source tree at all", () => {
|
||||
|
||||
@@ -217,14 +217,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
|
||||
"/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": {
|
||||
kind: "adminConsole",
|
||||
methods: ["GET"],
|
||||
},
|
||||
"/api/admin/backups/{id}/restore": {
|
||||
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}/summary": { kind: "adminConsole", methods: ["GET"] },
|
||||
"/api/admin/central-codex": {
|
||||
kind: "adminConsole",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -140,12 +140,21 @@ describe("BackupsSection — empty state", () => {
|
||||
expect(className).not.toMatch(/\b(?:opacity-\d+|text-\S+\/\d+)\b/);
|
||||
});
|
||||
|
||||
it("warns in both catalogs that restore overwrites instance-wide settings", () => {
|
||||
expect(de.admin.section.backups.restoreDescription).toMatch(
|
||||
it("names the instance-wide effect on the opt-in that carries it, in both catalogs", () => {
|
||||
// The restore rewrites the host's own settings only when the operator
|
||||
// ticks the box, so the warning belongs to the box rather than to the
|
||||
// sentence above it — and the box has to say what it turns on.
|
||||
expect(de.admin.section.backups.restoreInstanceSettingsLabel).toMatch(
|
||||
/instanzweite Einstellungen/i,
|
||||
);
|
||||
expect(en.admin.section.backups.restoreDescription).toMatch(
|
||||
/instance-wide settings/i,
|
||||
expect(de.admin.section.backups.restoreInstanceSettingsHint).toMatch(
|
||||
/Installation/,
|
||||
);
|
||||
expect(en.admin.section.backups.restoreInstanceSettingsLabel).toMatch(
|
||||
/instance settings/i,
|
||||
);
|
||||
expect(en.admin.section.backups.restoreInstanceSettingsHint).toMatch(
|
||||
/installation/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -124,13 +125,18 @@ function RestoreRowDialog({
|
||||
}: {
|
||||
row: BackupRow;
|
||||
pending: boolean;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (options: { restoreInstanceSettings: boolean }) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [typed, setTyped] = useState("");
|
||||
const matched = typed.trim() === "RESTORE";
|
||||
// Off unless the operator says otherwise. A disaster-recovery snapshot
|
||||
// carries the host's own settings alongside the account's data, and putting
|
||||
// one account back is not a reason to reconfigure the installation for
|
||||
// everybody on it — so the second effect is a second decision.
|
||||
const [withInstanceSettings, setWithInstanceSettings] = useState(false);
|
||||
|
||||
// v1.37.20 — restore preview: fetch what the file contains the moment the
|
||||
// dialog opens, so the typed confirmation is an informed one. Derived from
|
||||
@@ -170,13 +176,17 @@ function RestoreRowDialog({
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setTyped("");
|
||||
if (!next) {
|
||||
setTyped("");
|
||||
setWithInstanceSettings(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
data-testid="backup-restore-trigger"
|
||||
disabled={pending}
|
||||
aria-label={t("admin.section.backups.restoreAria", {
|
||||
username: row.username,
|
||||
@@ -254,12 +264,33 @@ function RestoreRowDialog({
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{/* The second decision, before the typed gate rather than after it:
|
||||
the account's data comes back either way, the host's settings only
|
||||
if this is ticked. */}
|
||||
<label
|
||||
className="flex items-start gap-2 text-sm"
|
||||
data-slot="restore-instance-settings"
|
||||
>
|
||||
<Checkbox
|
||||
data-testid="backup-restore-instance-settings"
|
||||
checked={withInstanceSettings}
|
||||
onCheckedChange={(next) => setWithInstanceSettings(next === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
{t("admin.section.backups.restoreInstanceSettingsLabel")}
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{t("admin.section.backups.restoreInstanceSettingsHint")}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`restore-prompt-${row.id}`}>
|
||||
{t("admin.section.backups.restorePromptLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`restore-prompt-${row.id}`}
|
||||
data-testid="backup-restore-prompt"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
@@ -270,13 +301,15 @@ function RestoreRowDialog({
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
data-testid="backup-restore-confirm"
|
||||
disabled={!matched || pending}
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (!matched) return;
|
||||
setOpen(false);
|
||||
setTyped("");
|
||||
onConfirm();
|
||||
onConfirm({ restoreInstanceSettings: withInstanceSettings });
|
||||
setWithInstanceSettings(false);
|
||||
}}
|
||||
>
|
||||
{pending
|
||||
@@ -778,14 +811,20 @@ export function BackupsSection() {
|
||||
// and used inline by `<RestoreRowDialog>` below — keeping the
|
||||
// mutation here lets the parent invalidate the list query on success.
|
||||
const restore = useMutation({
|
||||
mutationFn: async (row: BackupRow) => {
|
||||
mutationFn: async ({
|
||||
row,
|
||||
restoreInstanceSettings,
|
||||
}: {
|
||||
row: BackupRow;
|
||||
restoreInstanceSettings: boolean;
|
||||
}) => {
|
||||
// Idempotency-Key prevents a double-click from re-running the
|
||||
// destructive transaction. Include the row id so two different
|
||||
// backups can both be restored independently in the same minute.
|
||||
const idempotencyKey = `restore-${row.id}-${randomId()}`;
|
||||
return apiPost<{ restored: true; skipped?: RestoreSkipSummary }>(
|
||||
`/api/admin/backups/${row.id}/restore`,
|
||||
{ confirm: "RESTORE" },
|
||||
{ confirm: "RESTORE", restoreInstanceSettings },
|
||||
{ headers: { "Idempotency-Key": idempotencyKey } },
|
||||
);
|
||||
},
|
||||
@@ -978,6 +1017,11 @@ export function BackupsSection() {
|
||||
// primitive. The header already exposes "Backup now" but a
|
||||
// brand-new admin lands inside the card and benefits from a
|
||||
// duplicate CTA right next to the explanation.
|
||||
//
|
||||
// Its own test id, because both buttons are in the tree while the list
|
||||
// is empty and they do the same thing: one id on two elements forces
|
||||
// every caller to pick one blind, and a third button would join the
|
||||
// pick without anything failing.
|
||||
<div>
|
||||
<EmptyState
|
||||
icon={<Database className="size-6" />}
|
||||
@@ -986,6 +1030,7 @@ export function BackupsSection() {
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="backup-run-now-empty"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
@@ -1033,7 +1078,12 @@ export function BackupsSection() {
|
||||
it holds at any viewport. */}
|
||||
<tbody data-slot="backup-rows" className="divide-border divide-y">
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.id} className={i % 2 === 0 ? "bg-muted/30" : ""}>
|
||||
<tr
|
||||
key={row.id}
|
||||
data-backup-id={row.id}
|
||||
data-backup-username={row.username}
|
||||
className={i % 2 === 0 ? "bg-muted/30" : ""}
|
||||
>
|
||||
<td className="px-3 py-2 font-medium">{row.username}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -1051,6 +1101,7 @@ export function BackupsSection() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="backup-download"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
@@ -1069,9 +1120,11 @@ export function BackupsSection() {
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending &&
|
||||
restore.variables?.id === row.id
|
||||
restore.variables?.row.id === row.id
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1095,7 +1148,7 @@ export function BackupsSection() {
|
||||
key={row.id}
|
||||
className="bg-muted/30 border-border"
|
||||
>
|
||||
<li>
|
||||
<li data-backup-id={row.id} data-backup-username={row.username}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{row.username}</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
@@ -1111,6 +1164,7 @@ export function BackupsSection() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
data-testid="backup-download"
|
||||
disabled={downloadingId === row.id}
|
||||
onClick={() => handleDownload(row)}
|
||||
aria-label={t("admin.section.backups.downloadAria", {
|
||||
@@ -1128,9 +1182,12 @@ export function BackupsSection() {
|
||||
<RestoreRowDialog
|
||||
row={row}
|
||||
pending={
|
||||
restore.isPending && restore.variables?.id === row.id
|
||||
restore.isPending &&
|
||||
restore.variables?.row.id === row.id
|
||||
}
|
||||
onConfirm={(options) =>
|
||||
restore.mutate({ row, ...options })
|
||||
}
|
||||
onConfirm={() => restore.mutate(row)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
@@ -1149,6 +1206,7 @@ export function BackupsSection() {
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
size="sm"
|
||||
data-testid="backup-run-now"
|
||||
disabled={runBackup.isPending}
|
||||
onClick={() => runBackup.mutate()}
|
||||
className="min-h-11"
|
||||
|
||||
@@ -223,6 +223,28 @@ export async function packBackupBlobStreaming(
|
||||
return pieces.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* The refusal a caller gets when a stored copy will not open, as one string.
|
||||
*
|
||||
* Two causes, one answer, because the envelope cannot tell them apart and the
|
||||
* operator's next step is the same either way: the key that wrote the copy is
|
||||
* no longer in `ENCRYPTION_KEYS` (a rotation that dropped the legacy entry too
|
||||
* early — the case `docs/ops/encryption-key-rotation.md` warns about), or the
|
||||
* stored bytes are not the bytes that were written. Neither is a server fault,
|
||||
* so neither is a 500: it is bad stored input, refused with 422 like every
|
||||
* other bad input on these routes, and it never reaches the error reporter as
|
||||
* if the process had broken.
|
||||
*
|
||||
* Shared rather than retyped, so the three routes that decrypt a stored copy —
|
||||
* restore, download, summary — cannot drift into three different sentences for
|
||||
* the same condition.
|
||||
*/
|
||||
export const BACKUP_UNDECRYPTABLE_ERROR =
|
||||
"Backup payload could not be decrypted — either the key that wrote this copy is no longer in ENCRYPTION_KEYS, or the stored copy is not the one that was written. Nothing was changed.";
|
||||
|
||||
/** The stable machine-readable half of {@link BACKUP_UNDECRYPTABLE_ERROR}. */
|
||||
export const BACKUP_UNDECRYPTABLE_CODE = "backup.payload.undecryptable";
|
||||
|
||||
/**
|
||||
* A stored `DataBackup.data` string → the backup JSON.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* OpenAPI route table — the two backups-console routes that read a stored copy.
|
||||
*
|
||||
* 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 copy in
|
||||
* `data_backups.data`, and both therefore have a refusal an operator meets on
|
||||
* the day a key rotation went one step too far — the case
|
||||
* `docs/ops/encryption-key-rotation.md` warns about. That refusal is a promise
|
||||
* (422, `backup.payload.undecryptable`, nothing changed) rather than whatever
|
||||
* the handler happens to answer, and a promise belongs somewhere it can be
|
||||
* quoted from.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
|
||||
import { dataEnvelope, errorEnvelope, idempotencyKeyParameter } from "./shared";
|
||||
|
||||
const restoreRequest = z
|
||||
.object({
|
||||
confirm: z.literal("RESTORE"),
|
||||
restoreInstanceSettings: z.boolean().optional(),
|
||||
})
|
||||
.meta({
|
||||
id: "AdminBackupRestoreRequest",
|
||||
description:
|
||||
"`confirm` is the literal typed gate; anything else is refused with 422 and nothing is read. `restoreInstanceSettings` defaults to false: the restore then replaces the snapshot owner's data tables and leaves the singleton instance settings — registration, MFA requirement, default locale and timezone, module availability, notification and AI configuration, document cap and quota — exactly as they are. Send `true` only when rebuilding a host from a snapshot, because those settings apply to every account on the installation.",
|
||||
});
|
||||
|
||||
const restoreSkipEntry = z
|
||||
.object({
|
||||
catalogue: z.string(),
|
||||
key: z.string(),
|
||||
links: z.number().int(),
|
||||
})
|
||||
.meta({
|
||||
id: "AdminBackupRestoreSkipEntry",
|
||||
description:
|
||||
"One catalogue key the file referenced and this instance could not resolve, with the number of links it cost.",
|
||||
});
|
||||
|
||||
const restoreResult = z
|
||||
.object({
|
||||
restored: z.literal(true),
|
||||
summary: z.record(z.string(), z.unknown()),
|
||||
skipped: z.object({
|
||||
catalogueKeys: z.array(restoreSkipEntry),
|
||||
links: z.number().int(),
|
||||
}),
|
||||
cleared: z.record(z.string(), z.number().int()),
|
||||
})
|
||||
.meta({
|
||||
id: "AdminBackupRestoreResult",
|
||||
description:
|
||||
"What the transaction wrote. `cleared` counts the rows each class held before it was rebuilt from the file, `summary` counts what the file carried, and `skipped.links` is the number of links dropped because a catalogue key in the file does not exist here — zero is the normal answer.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The one refusal both routes share, worded once.
|
||||
*
|
||||
* A stored copy that will not open is bad stored input, not a fault in the
|
||||
* server: either the key that wrote it is no longer in `ENCRYPTION_KEYS`, or
|
||||
* the stored bytes are not the bytes that were written. It answers 422 like
|
||||
* every other bad input on these routes, and never as a 500.
|
||||
*/
|
||||
const undecryptableResponse = {
|
||||
description:
|
||||
"The stored copy could not be opened: either the key that wrote it is no longer in `ENCRYPTION_KEYS` (a rotation that dropped the legacy entry too early), or the stored bytes are not the ones that were written. `meta.errorCode` = `backup.payload.undecryptable`. Nothing was changed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const adminOnlyResponse = {
|
||||
description:
|
||||
"Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
const notFoundResponse = {
|
||||
description: "No stored copy with this id.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
};
|
||||
|
||||
export const adminBackupPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/admin/backups/{id}/download": {
|
||||
get: {
|
||||
tags: ["Admin"],
|
||||
summary: "Download one stored backup as JSON",
|
||||
description:
|
||||
"Decrypts one stored copy and returns the backup document itself, with `Content-Disposition: attachment` and `Cache-Control: no-store`. The response is the raw document, not the standard envelope — it is the artefact an operator keeps. The ciphertext stays in the database. Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: z.string().meta({ description: "`DataBackup.id`." }),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The decrypted backup document.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.record(z.string(), z.unknown()).meta({
|
||||
id: "AdminBackupDocument",
|
||||
description:
|
||||
"The backup payload as the writer serialised it: `schemaVersion`, `exportedAt`, `userId`, and one array or object per serialised class.",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": adminOnlyResponse,
|
||||
"404": notFoundResponse,
|
||||
"422": undecryptableResponse,
|
||||
"500": {
|
||||
description:
|
||||
"The copy decrypted and then failed schema validation — a document this instance wrote and cannot parse. This one genuinely is a server fault, and is reported as one.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/admin/backups/{id}/restore": {
|
||||
post: {
|
||||
tags: ["Admin"],
|
||||
summary: "Restore one stored backup over its owner's record",
|
||||
description:
|
||||
"Replaces the snapshot owner's data tables from the stored copy, in one transaction, under the ids the file carries. Replacing is not merging: every row the account gained after the snapshot was taken is deleted with the rest of its class. The target is the account the snapshot was taken for, never the admin running it, and a payload declaring a different owner than the stored row is refused. Admin session cookie required; Bearer tokens cannot reach admin endpoints.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: z.string().meta({ description: "`DataBackup.id`." }),
|
||||
}),
|
||||
},
|
||||
// The one write here, and the destructive one: the console sends a
|
||||
// per-row key so a double-click replays the first answer instead of
|
||||
// running the transaction twice.
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: restoreRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The restore ran.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(restoreResult, "AdminBackupRestoreResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body was not JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": adminOnlyResponse,
|
||||
"404": notFoundResponse,
|
||||
"409": {
|
||||
description:
|
||||
"Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The request or the file was refused before anything was written: `confirm` missing, the copy undecryptable (`meta.errorCode` = `backup.payload.undecryptable`), the payload failing schema validation, an unsupported `schemaVersion`, a section the file's own manifest says it carries and does not (`meta.errorCode` = `backup.section.missing`), a metadata-only document entry, or an owner who no longer exists here.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
recordScopeParameter,
|
||||
} from "./account-sharing";
|
||||
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
|
||||
import { adminBackupPaths } from "./admin-backups";
|
||||
import { allergyPaths } from "./allergies";
|
||||
import { analyticsPaths } from "./analytics";
|
||||
import { authPaths } from "./auth";
|
||||
@@ -163,6 +164,12 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...awardsPaths,
|
||||
...environmentPaths,
|
||||
...ingestPaths,
|
||||
// The two backups-console routes that open a stored copy. The rest of the
|
||||
// family stays unpublished — it is a cookie-only console — but these two
|
||||
// carry a refusal an operator has to be able to look up rather than
|
||||
// discover: a copy this instance can no longer decrypt (appended, spread
|
||||
// order is load-bearing).
|
||||
...adminBackupPaths,
|
||||
// The environmental-context overview. Its own module because nothing else
|
||||
// owns the surface (appended, spread order is load-bearing).
|
||||
//
|
||||
|
||||
Reference in new issue
Block a user