Files
HealthLog/src/app/api/settings/data/route.ts
T
Marc-André Bombeck eda87fd4a7 fix(sync): expire delta cursors issued before a restore or a data wipe
A restore writes rows back with their original updatedAt, behind every
paired client's watermarks, and removes rows without leaving tombstones.
A client that kept its cursor would miss both on its next incremental
pull. The same holds after the person deletes all their data.

`users.sync_reset_at` (migration 0363) records when the record was last
replaced: the restore stamps it as its last write and again right after
the commit, the per-account and the admin wipe stamp it inside their
transactions. Cursors now carry the time they were issued, and
/api/sync/changes answers its existing `cursorExpired` to a cursor issued
before the reset, or one without an issue time on an account that has
one. The comparison is on the issue time, because after a restore a
freshly drained cursor still carries old watermarks.
2026-09-27 13:46:38 +02:00

164 lines
5.8 KiB
TypeScript

import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import {
kickOffhostPurge,
requestOffhostPurge,
} from "@/lib/jobs/offhost-purge";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { NextRequest } from "next/server";
import {
apiHandler,
requireFreshMfaOrElevationIfEnrolled,
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";
import { stampSyncReset } from "@/lib/sync/reset";
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. Over Bearer the step-up is a fresh-factor elevation in the
* `X-Step-Up` header, spent right before the transaction — the same arm the
* account deletion route carries, for the same App Store reason.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const auth = await requireFreshMfaOrElevationIfEnrolled(
MFA_STEP_UP_MAX_AGE_SECONDS,
);
const { user } = auth;
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;
// The confirmation has passed; spend the Bearer elevation before the wipe
// (a no-op on the cookie path).
await auth.commitElevation();
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 });
// Paired clients drop their delta cursors and start over, instead of
// catching up from a position whose rows are gone.
await stampSyncReset(tx, userId);
// The nightly off-host copies of the record are part of "all your
// data". The request commits with the wipe; the purge job deletes
// the objects and removes it.
await requestOffhostPurge(tx, userId, "data_wiped");
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);
await kickOffhostPurge();
return apiSuccess({ cleared: true, deletedRows, models: counts });
});