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.", + }, +};