mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
- Exclude and erase egress claims with recipient-side lookup support\n- Serialize data wipes with managed-profile Guardian authorization\n- Cover last-Guardian and queued-claim wipe races
141 lines
4.8 KiB
TypeScript
141 lines
4.8 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
|
import { NextRequest } from "next/server";
|
|
import {
|
|
apiHandler,
|
|
requireFreshMfaIfEnrolled,
|
|
MFA_STEP_UP_MAX_AGE_SECONDS,
|
|
} from "@/lib/api-handler";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { invalidateUserData } from "@/lib/cache/invalidate";
|
|
import {
|
|
LastManagedGuardianError,
|
|
protectManagedProfilesDuringDataWipe,
|
|
} from "@/lib/managed-profiles/lifecycle";
|
|
import {
|
|
resolveWipeDelegate,
|
|
USER_RESET,
|
|
wipeDelegateKey,
|
|
WIPE_MODELS,
|
|
} from "@/lib/data-wipe/wipe-plan";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* Budget for the wipe transaction.
|
|
*
|
|
* Prisma's default interactive-transaction timeout is 5 s, which was already
|
|
* optimistic for thirteen `deleteMany` statements and is not survivable for
|
|
* the full user-scoped set on an account with a large import behind it. The
|
|
* whole action is one transaction on purpose — a half-applied wipe is worse
|
|
* than none, because the person is told it succeeded either way.
|
|
*/
|
|
const WIPE_TRANSACTION_TIMEOUT_MS = 120_000;
|
|
const WIPE_TRANSACTION_MAX_WAIT_MS = 15_000;
|
|
|
|
/**
|
|
* Delete every row this account owns, keeping the account and the credentials
|
|
* that sign into it.
|
|
*
|
|
* The set of tables is not written here. It is declared once in
|
|
* `@/lib/data-wipe/wipe-plan` and held against `prisma/schema.prisma` by
|
|
* `src/__tests__/data-wipe-completeness.test.ts`, so a model added to the
|
|
* schema cannot quietly land outside the wipe — which is exactly how this
|
|
* route came to delete thirteen tables out of eighty-odd while telling people
|
|
* it had deleted all of them.
|
|
*
|
|
* v1.23 — gated by a fresh step-up for MFA-enrolled accounts (see the account
|
|
* deletion route); single-factor accounts keep the typed-confirmation-only
|
|
* contract.
|
|
*/
|
|
export const DELETE = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
|
|
|
|
let confirm = "";
|
|
try {
|
|
const raw = await request.text();
|
|
if (raw.length > 64 * 1024) {
|
|
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
|
|
}
|
|
const body = JSON.parse(raw);
|
|
confirm = typeof body?.confirm === "string" ? body.confirm : "";
|
|
} catch {
|
|
return apiError("Invalid request", 422);
|
|
}
|
|
|
|
if (confirm !== "DELETE") {
|
|
return apiError("Confirmation missing", 422);
|
|
}
|
|
|
|
const userId = user.id;
|
|
|
|
let counts: Record<string, number>;
|
|
try {
|
|
counts = await prisma.$transaction(
|
|
async (tx) => {
|
|
// This precedes every data delete and holds the managed-record locks
|
|
// through AccountGrant removal. A queued Guardian egress claim thus
|
|
// rechecks after this wipe commits instead of sending from a grant
|
|
// this transaction has already deleted.
|
|
await protectManagedProfilesDuringDataWipe(tx, userId);
|
|
|
|
const perModel: Record<string, number> = {};
|
|
for (const model of WIPE_MODELS) {
|
|
const { count } = await resolveWipeDelegate(tx, model).deleteMany({
|
|
where: { userId },
|
|
});
|
|
if (count > 0) perModel[wipeDelegateKey(model)] = count;
|
|
}
|
|
|
|
// The account row survives; the personal data carried on its own columns
|
|
// does not. Same classification contract as the model list.
|
|
await tx.user.update({ where: { id: userId }, data: USER_RESET });
|
|
|
|
return perModel;
|
|
},
|
|
{
|
|
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
|
|
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof LastManagedGuardianError) {
|
|
return apiError(
|
|
"Add another Guardian or delete the managed profile first",
|
|
409,
|
|
{ errorCode: "managed_profile.guardian.required" },
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const deletedRows = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
|
|
// Written after the transaction commits, so the row cannot be removed by the
|
|
// AuditLog delete inside it. The person's whole audit history goes; this one
|
|
// row stays as the receipt for the erasure they asked for. Account deletion
|
|
// takes the other branch and purges even this, because there is no longer an
|
|
// account for it to belong to.
|
|
await auditLog("user.data.clear", {
|
|
userId,
|
|
ipAddress: getClientIp(request),
|
|
details: { deletedRows, models: counts },
|
|
});
|
|
|
|
annotate({
|
|
action: { name: "settings.data.clear" },
|
|
meta: {
|
|
deleted_rows: deletedRows,
|
|
models_wiped: WIPE_MODELS.length,
|
|
models_with_rows: Object.keys(counts).length,
|
|
},
|
|
});
|
|
|
|
// v1.16.9 — every cached payload was built on the rows just deleted;
|
|
// hard-evict the user's buckets so no surface serves pre-wipe data.
|
|
invalidateUserData(userId);
|
|
|
|
return apiSuccess({ cleared: true, deletedRows, models: counts });
|
|
});
|