fix(admin): wipe every account's data, not nine tables

The admin "Delete all data" action carried its delete list inline. That
list named nine tables, which was most of the schema on the day it was
written, and it stayed at nine while the schema grew to a hundred and
twenty-two models. Laboratory results, documents, cycle logs, journals,
mood, workouts, sleep and Coach conversations all survived the wipe an
operator runs before handing a box on, and the toast said it was done.

The per-account wipe had the same defect and grew a derived plan for it.
The admin route now reads that plan: ADMIN_WIPE_MODELS is WIPE_MODELS
minus a single documented subtraction, so a model added to the schema
lands in both wipes at once.

- AccountGrant is the one difference, with its reason in the plan: it is
  the only access path to a managed profile, and this action preserves
  accounts by definition. Deleting every grant would leave records
  nobody can open, administer or delete.
- The audit history now goes with everything else. It is a record of who
  did what from where, which is exactly what clearing a box is for; the
  receipt written after the transaction commits replaces it.
- One transaction with the same budget as the account wipe. A
  half-applied wipe is worse than none, because the person is told it
  succeeded either way.
- The result line reports rows and tables instead of nine fixed names,
  which is how nobody noticed: it read the same whether the wipe had
  cleared nine tables or ninety.

Guards, both watched red before landing:
admin-data-wipe-completeness.test.ts fails when a user-scoped model
reaches neither the wipe nor a written exemption, and when the route
names a table inline again. The integration guard seeds every table in
the schema for two accounts, runs the route, and asks the database what
is left — drop one model from the plan and it names the surviving table.
This commit is contained in:
Marc-André Bombeck committed 2026-08-08 01:05:31 +02:00
1 parent 93980ac7a1
commit ef35fe681d
15 files changed
+966 -484

No files matched your search

+63 -50
View File
@@ -6,12 +6,36 @@ import { annotate } from "@/lib/logging/context";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextRequest, NextResponse } from "next/server";
import { invalidateAllCaches } from "@/lib/cache/invalidate";
import {
ADMIN_WIPE_MODELS,
resolveGlobalWipeDelegate,
USER_RESET,
wipeDelegateKey,
WIPE_TRANSACTION_MAX_WAIT_MS,
WIPE_TRANSACTION_TIMEOUT_MS,
} from "@/lib/data-wipe/wipe-plan";
export const dynamic = "force-dynamic";
/**
* Admin-only global wipe of all health/integration data.
* Admin-only global wipe of every account's data.
* Keeps users/passkeys so access to the app remains possible.
*
* The set of tables is not written here. It is declared once in
* `@/lib/data-wipe/wipe-plan` — the same declaration the per-account wipe
* reads — and held against `prisma/schema.prisma` by
* `src/__tests__/data-wipe-completeness.test.ts` plus its admin-scope sibling,
* so a model added to the schema cannot quietly land outside this wipe. It had
* done exactly that: the inline list this route used to carry named nine
* tables, written when nine was most of the schema, and stayed at nine while
* the schema grew past a hundred. Laboratory results, documents, cycle logs,
* journals, mood, workouts, sleep and Coach conversations all survived the
* wipe an operator runs before handing the box on.
*
* `ADMIN_WIPE_MODELS` is `WIPE_MODELS` minus the entries in
* `ADMIN_WIPE_EXEMPT`, and every survivor — sign-in credentials, instance
* configuration, the delegation grants that keep managed profiles reachable —
* carries its reason in that file.
*/
export const DELETE = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request);
@@ -59,72 +83,61 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
}
// Audit-log the *intent* before the transaction begins so the trail
// describing "an admin is about to wipe data" survives even if the
// operation crashes midway. AuditLog rows are intentionally NOT
// deleted as part of the wipe — the entire point of an audit trail
// is to outlive the data it documents.
// describing "an admin is about to wipe data" survives a crash midway or a
// rollback. On success the transaction takes this row with the rest of the
// history — an audit trail is a record of who did what from where, which is
// exactly the personal data an operator clearing a box means to remove — and
// the receipt written after the commit replaces it.
await auditLog("admin.data.clear.start", {
userId: user.id,
ipAddress: getClientIp(request),
details: { confirm: "DELETE ALL" },
});
const result = await prisma.$transaction(async (tx) => {
const measurements = await tx.measurement.deleteMany({});
const intakeEvents = await tx.medicationIntakeEvent.deleteMany({});
const medications = await tx.medication.deleteMany({});
const apiTokens = await tx.apiToken.deleteMany({});
const withingsConnections = await tx.withingsConnection.deleteMany({});
const authChallenges = await tx.authChallenge.deleteMany({});
const counts = await prisma.$transaction(
async (tx) => {
const perModel: Record<string, number> = {};
for (const model of ADMIN_WIPE_MODELS) {
const { count } = await resolveGlobalWipeDelegate(tx, model).deleteMany(
{},
);
if (count > 0) perModel[wipeDelegateKey(model)] = count;
}
// v1.4.6 left these out, so encrypted Telegram bot tokens
// (NotificationChannel.config) and Web Push endpoints survived a
// wipe. Cascading via NotificationPreference is handled by the
// schema's onDelete: Cascade on the channel FK.
const notificationChannels = await tx.notificationChannel.deleteMany({});
const pushSubscriptions = await tx.pushSubscription.deleteMany({});
const telegramScheduledDeletions =
await tx.telegramScheduledDeletion.deleteMany({});
// Every account row survives; the personal data carried on its own
// columns does not. Same classification contract as the model list.
await tx.user.updateMany({ data: USER_RESET });
await tx.user.updateMany({
data: {
heightCm: null,
dateOfBirth: null,
gender: null,
insightsPrivacyMode: "aggregated",
insightsCachedAt: null,
insightsCachedText: null,
telegramBotToken: null,
telegramChatId: null,
telegramEnabled: false,
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
onboardingCompletedAt: null,
},
});
return perModel;
},
{
timeout: WIPE_TRANSACTION_TIMEOUT_MS,
maxWait: WIPE_TRANSACTION_MAX_WAIT_MS,
},
);
return {
measurements: measurements.count,
intakeEvents: intakeEvents.count,
medications: medications.count,
apiTokens: apiTokens.count,
withingsConnections: withingsConnections.count,
authChallenges: authChallenges.count,
notificationChannels: notificationChannels.count,
pushSubscriptions: pushSubscriptions.count,
telegramScheduledDeletions: telegramScheduledDeletions.count,
};
});
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 instance's whole audit history goes; this
// one row stays as the receipt for the erasure the operator asked for.
await auditLog("admin.data.clear", {
userId: user.id,
ipAddress: getClientIp(request),
details: result,
details: { deletedRows, models: counts },
});
annotate({
meta: {
deleted_rows: deletedRows,
models_wiped: ADMIN_WIPE_MODELS.length,
models_with_rows: Object.keys(counts).length,
},
});
// v1.16.9 — the wipe touched every user's rows; clear every cache
// bucket so no per-user payload survives the reset.
invalidateAllCaches();
return apiSuccess({ cleared: true, ...result });
return apiSuccess({ cleared: true, deletedRows, models: counts });
});