From 9baeb95babbb684e68c2524c08ac2a7a07f2873f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 20:11:08 +0100 Subject: [PATCH] feat(backup): say whether an account can take an erased account's copy A restore replaces the account's records and has no merge, so the copy of an account that no longer exists may only go into an account that holds none of the records a backup carries. The check reads every backed-up model by its userId, reaches the owner-less children through their parent, and is held to the schema so a new model cannot be read as empty. Managed profiles and accounts pending deletion are refused as well. The admin console asks GET /api/admin/backups/targets/{userId} before it sends a file again, so a refusal costs no upload. --- .../openapi-route-coverage-guard.test.ts | 4 + .../admin/backups/targets/[userId]/route.ts | 59 +++++++ .../__tests__/restore-target-models.test.ts | 45 +++++ src/lib/export/restore-target.ts | 158 ++++++++++++++++++ 4 files changed, 266 insertions(+) create mode 100644 src/app/api/admin/backups/targets/[userId]/route.ts create mode 100644 src/lib/export/__tests__/restore-target-models.test.ts create mode 100644 src/lib/export/restore-target.ts diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index bee50ca55..bafc8d560 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -225,6 +225,10 @@ const UNPUBLISHED: Readonly> = { // 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/backups/targets/{userId}": { + kind: "adminConsole", + methods: ["GET"], + }, "/api/admin/central-codex": { kind: "adminConsole", methods: ["DELETE", "GET"], diff --git a/src/app/api/admin/backups/targets/[userId]/route.ts b/src/app/api/admin/backups/targets/[userId]/route.ts new file mode 100644 index 000000000..e66ac2eb3 --- /dev/null +++ b/src/app/api/admin/backups/targets/[userId]/route.ts @@ -0,0 +1,59 @@ +/** + * GET /api/admin/backups/targets/{userId} — whether the copy of an erased + * account may be restored into this account. + * + * The backups console asks before it sends a file a second time with that + * account as its target, so a refusal costs no upload. The upload decides + * again on its own (`checkRestoreTarget` in `restore-target.ts`); this answer + * is advice, not a reservation. It says no for a managed profile, an account + * pending deletion, and an account that already holds records, naming the + * models that hold them. Counts and record content are not read. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues } from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { checkRestoreTarget } from "@/lib/export/restore-target"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +const paramsSchema = z.object({ userId: z.string().min(1).max(64) }); + +export const GET = apiHandler( + async ( + _request: NextRequest, + { params }: { params: Promise<{ userId: string }> }, + ) => { + const { user: admin } = await requireAdmin(); + annotate({ action: { name: "admin.backups.target.check" } }); + + const rl = await checkRateLimit( + `admin-backups-target:${admin.id}`, + 30, + 60 * 1000, + ); + if (!rl.allowed) { + throw new HttpError(429, "Too many account checks"); + } + + const parsed = paramsSchema.safeParse(await params); + if (!parsed.success) return returnAllZodIssues(parsed.error); + + const verdict = await checkRestoreTarget(prisma, parsed.data.userId); + if (!verdict.ok && verdict.reason === "not_found") { + throw new HttpError(404, "User not found"); + } + const target = verdict.target!; + return apiSuccess({ + id: target.id, + username: target.username, + eligible: verdict.ok, + reason: verdict.ok ? null : verdict.reason, + models: verdict.ok ? [] : verdict.models, + }); + }, +); diff --git a/src/lib/export/__tests__/restore-target-models.test.ts b/src/lib/export/__tests__/restore-target-models.test.ts new file mode 100644 index 000000000..f6bfffaaf --- /dev/null +++ b/src/lib/export/__tests__/restore-target-models.test.ts @@ -0,0 +1,45 @@ +/** + * The emptiness check behind restoring an erased account's copy into another + * account reads every backed-up model by its `userId`, except the children + * that have none, which it reaches through their parent. Both halves are held + * to `prisma/schema.prisma` here, so a backed-up model added later is either + * checked or fails this file; it cannot be skipped and read as empty. + * + * Mutation check: drop a model from `BACKED_UP_CHILD_MODELS` and the first + * case names it (no `userId`); add an owned model to it and the second does. + */ +import { describe, expect, it } from "vitest"; + +import { parseWipeSchema } from "@/__tests__/helpers/wipe-schema-shape"; +import { BACKED_UP_MODELS } from "@/lib/export/backup-plan"; +import { + BACKED_UP_CHILD_MODELS, + RESTORE_TARGET_OWNED_MODELS, +} from "@/lib/export/restore-target"; + +const models = parseWipeSchema(); + +describe("restore target models", () => { + it("reads every owned backed-up model by its userId", () => { + const missing = RESTORE_TARGET_OWNED_MODELS.filter( + (model) => !models.get(model)?.scalars.includes("userId"), + ); + expect(missing).toEqual([]); + expect(RESTORE_TARGET_OWNED_MODELS.length).toBeGreaterThan(60); + }); + + it("reaches each child through a required cascade to an owned parent", () => { + for (const [child, parent] of Object.entries(BACKED_UP_CHILD_MODELS)) { + expect(BACKED_UP_MODELS, child).toContain(child); + const shape = models.get(child); + expect(shape?.scalars, child).not.toContain("userId"); + expect( + shape?.relations.some( + (r) => r.target === parent && r.cascade && !r.optional, + ), + `${child} -> ${parent}`, + ).toBe(true); + expect(RESTORE_TARGET_OWNED_MODELS, parent).toContain(parent); + } + }); +}); diff --git a/src/lib/export/restore-target.ts b/src/lib/export/restore-target.ts new file mode 100644 index 000000000..9f371d94b --- /dev/null +++ b/src/lib/export/restore-target.ts @@ -0,0 +1,158 @@ +/** + * Which account the copy of an erased account may be restored into. + * + * A restore replaces: every row of the account in a section the copy carries + * is deleted and the copy's rows are written in their place + * (`docs/ops/backup-restore.md`). There is no merge. So putting the record of + * an account that no longer exists into another account is only safe when + * that account holds none of those rows, and the check is exactly that: a row + * of any model the backup carries ({@link BACKED_UP_MODELS}) under the + * account refuses it. Everything a restore leaves alone (credentials, + * sessions, devices, grants) does not count. + * + * The other two refusals keep the account rules intact. A managed profile is + * its Guardians' record, which an admin does not write into (the users list + * offers no admin deletion on one for the same reason). An account pending + * deletion is locked and will be erased, with whatever was restored into it. + * Neither the restore nor this check touches the role, so the last-admin rule + * is not in play. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { BACKED_UP_MODELS } from "@/lib/export/backup-plan"; + +/** + * The backed-up models with no owner column of their own, each with the owned + * model it hangs off through a required cascade. A row of one cannot exist + * without a row of its parent, so checking the parent checks it. + * `restore-target-models.test.ts` holds this list to the schema. + */ +export const BACKED_UP_CHILD_MODELS: Readonly> = { + MedicationSchedule: "Medication", + MedicationScheduleRevision: "Medication", + MedicationDoseChange: "Medication", + MedicationEfficacyTarget: "Medication", + MedicationInventoryEvent: "Medication", + MedicationCategoryAssignment: "Medication", + ReminderPhaseConfig: "Medication", + MoodEntryTagLink: "MoodEntry", + IllnessSymptomLink: "IllnessDayLog", + CycleSymptomLink: "CycleDayLog", + WorkoutRoute: "Workout", + WorkoutSamples: "Workout", + CoachMessage: "CoachConversation", + CoachConversationDocument: "CoachConversation", +}; + +/** Every backed-up model that carries `userId`, in the plan's order. */ +export const RESTORE_TARGET_OWNED_MODELS: readonly string[] = + BACKED_UP_MODELS.filter((model) => !(model in BACKED_UP_CHILD_MODELS)); + +interface OwnedDelegate { + findFirst(args: { + where: { userId: string }; + select: { userId: true }; + }): Promise; +} + +function ownedDelegate(db: object, model: string): OwnedDelegate { + const key = model.charAt(0).toLowerCase() + model.slice(1); + const delegate = (db as Record)[key]; + if (!delegate || typeof delegate.findFirst !== "function") { + // Never narrower than the plan: a model this cannot read is a model it + // would otherwise call empty. + throw new Error(`No Prisma delegate for backed-up model "${model}"`); + } + return delegate; +} + +/** The backed-up models under which `userId` holds at least one row. */ +export async function modelsHoldingRecords( + db: Prisma.TransactionClient, + userId: string, +): Promise { + const held: string[] = []; + for (const model of RESTORE_TARGET_OWNED_MODELS) { + const row = await ownedDelegate(db, model).findFirst({ + where: { userId }, + select: { userId: true }, + }); + if (row) held.push(model); + } + return held; +} + +export type RestoreTargetRefusal = + "not_found" | "managed_profile" | "pending_deletion" | "holds_records"; + +export type RestoreTargetVerdict = + | { ok: true; target: { id: string; username: string } } + | { + ok: false; + reason: RestoreTargetRefusal; + target: { id: string; username: string } | null; + /** For `holds_records`: the models that hold rows, in plan order. */ + models: string[]; + }; + +/** Whether the copy of an erased account may go into `userId`. */ +export async function checkRestoreTarget( + db: Prisma.TransactionClient, + userId: string, +): Promise { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { + id: true, + username: true, + managedProfileAt: true, + deletionRequestedAt: true, + }, + }); + if (!row) { + return { ok: false, reason: "not_found", target: null, models: [] }; + } + const target = { id: row.id, username: row.username }; + if (row.managedProfileAt) { + return { ok: false, reason: "managed_profile", target, models: [] }; + } + if (row.deletionRequestedAt) { + return { ok: false, reason: "pending_deletion", target, models: [] }; + } + const models = await modelsHoldingRecords(db, row.id); + if (models.length > 0) { + return { ok: false, reason: "holds_records", target, models }; + } + return { ok: true, target }; +} + +/** The status and stable code each refusal answers with. */ +export const RESTORE_TARGET_REFUSALS: Readonly< + Record< + RestoreTargetRefusal, + { status: number; code: string; message: string } + > +> = { + not_found: { + status: 404, + code: "backup.target.not_found", + message: "The chosen account does not exist.", + }, + managed_profile: { + status: 409, + code: "backup.target.managed_profile", + message: + "The chosen account is a managed profile. Its Guardians run it, so a copy cannot be restored into it.", + }, + pending_deletion: { + status: 409, + code: "backup.target.pending_deletion", + message: + "The chosen account is pending deletion. Undo the deletion first, or choose another account.", + }, + holds_records: { + status: 409, + code: "backup.target.holds_records", + message: + "The chosen account already holds records, and a restore replaces them rather than adding to them. Choose an empty account or create a new one.", + }, +};