Merge branch 'fix/backup-rotation-and-deletion' into release/v1.39.3

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/jobs/apple-health-import-worker.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 21:48:20 +02:00
commit 4409529c89
74 files changed
+4022 -307

No files matched your search

@@ -22,9 +22,15 @@ import {
storedBackupRefusal,
STORED_BACKUP_SELECT,
} from "@/lib/export/stored-backup";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
@@ -72,8 +78,9 @@ export const GET = apiHandler(
let payload;
let measurementCount: number;
let streamed: StreamedBackup;
try {
const streamed = await readStreamedBackup(source);
streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch (err) {
@@ -93,6 +100,22 @@ export const GET = apiHandler(
);
}
// The preview is where the operator decides, so it says what the restore
// would refuse: a key the file's inner ciphertext needs and this host
// does not have. The instance settings are left out here, as the restore
// leaves them out unless asked; the restore itself checks them when they
// are asked for.
const keyVerdict = assessBackupKeys(streamed.keys, {
ignoreSections: new Set(["appSettings"]),
});
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
return apiError(keyProblem, 422, {
errorCode: BACKUP_KEY_MISSING_CODE,
keyIds: [...keyVerdict.missing, ...keyVerdict.unreadable],
});
}
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
@@ -29,12 +29,23 @@ vi.mock("@/lib/db", () => ({
prisma: {
$queryRaw: (...a: unknown[]) => queryRawMock(...a),
user: { findMany: (...a: unknown[]) => findManyMock(...a) },
offhostPurgeRequest: {
count: async () => 2,
findFirst: async () => ({
requestedAt: new Date("2026-09-20T10:00:00.000Z"),
lastFailure: "AccessDenied",
}),
},
},
}));
const configuredMock = vi.fn(() => true);
vi.mock("@/lib/jobs/offhost-backup", () => ({
offhostBackupConfigured: () => configuredMock(),
probeOffhostLifecycle: async () => ({
state: "missing",
expirationDays: null,
}),
}));
import { GET } from "../route";
@@ -139,10 +150,12 @@ describe("GET /api/admin/backups — off-host freshness", () => {
const body = await read();
expect(body.offhost).toEqual({
expect(body.offhost).toMatchObject({
configured: false,
periodHours: 24,
rows: [],
// No bucket to ask on a host that is not configured.
lifecycle: { state: "unknown", expirationDays: null },
});
});
@@ -196,4 +209,20 @@ describe("GET /api/admin/backups — off-host freshness", () => {
]);
expect(JSON.stringify(body.offhost)).not.toMatch(/bucket|endpoint|secret/i);
});
it("carries the bucket's lifecycle verdict and the deletions it still owes", async () => {
configuredMock.mockReturnValue(true);
queryRawMock.mockResolvedValue([]);
findManyMock.mockResolvedValue([]);
const body = await read();
expect(body.offhost.lifecycle).toEqual({
state: "missing",
expirationDays: null,
});
expect(body.offhost.pendingDeletions).toEqual({
count: 2,
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
lastFailure: "AccessDenied",
});
});
});
+21 -1
View File
@@ -27,7 +27,10 @@ import { annotate } from "@/lib/logging/context";
import { DATA_BACKUP_QUEUE } from "@/lib/jobs/data-backup-policy";
import { readLastQueueRun } from "@/lib/jobs/job-failures";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
import {
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
import {
classifyOffhostBackup,
OFFHOST_BACKUP_PERIOD_HOURS,
@@ -164,6 +167,17 @@ export const GET = apiHandler(async () => {
a.username.localeCompare(b.username),
);
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
prisma.offhostPurgeRequest.count(),
prisma.offhostPurgeRequest.findFirst({
orderBy: { requestedAt: "asc" },
select: { requestedAt: true, lastFailure: true },
}),
configured
? probeOffhostLifecycle()
: Promise.resolve({ state: "unknown" as const, expirationDays: null }),
]);
const payload: BackupsList = {
rows: list,
// Matches the retention window the backup-prune job enforces so the
@@ -182,6 +196,12 @@ export const GET = apiHandler(async () => {
// An unconfigured host has nothing to list, and the card says so rather
// than painting an empty table that reads as "no problems".
rows: configured ? offhostRows : [],
pendingDeletions: {
count: purgeCount,
oldestRequestedAt: oldestPurge?.requestedAt.toISOString() ?? null,
lastFailure: oldestPurge?.lastFailure ?? null,
},
lifecycle,
},
};
+42 -38
View File
@@ -29,7 +29,6 @@
* Phase B1 / criterion 2 of the v1.4.15 backup-completeness work.
*/
import { Readable } from "node:stream";
import { createGunzip } from "node:zlib";
import { BodyTooLargeError, readBoundedBody } from "@/lib/labs/ocr-upload";
import { NextRequest } from "next/server";
@@ -44,6 +43,16 @@ import {
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { BackupJsonError, scanBackupJson } from "@/lib/export/backup-json-scan";
import {
BackupUploadDecodeError,
decodeBackupUpload,
} from "@/lib/export/backup-upload-decode";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
BackupKeyIdCollector,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import {
BACKUP_UPLOAD_TOO_LARGE_CODE,
BackupBlobTooLargeError,
@@ -101,50 +110,22 @@ class UploadRefused extends Error {
}
}
/** Bytes → bytes, gunzipped when the file starts with the gzip magic. */
/** The file's bytes, gunzipped and counted on both ends. */
async function* decodedBytes(
source: AsyncIterable<Uint8Array>,
limit: number,
): AsyncGenerator<Uint8Array> {
let seen = 0;
async function* counted() {
for await (const chunk of source) {
seen += chunk.byteLength;
if (seen > limit) {
throw new UploadRefused(
413,
`Upload exceeds ${Math.round(limit / 1024 / 1024)} MB limit`,
{ reason: "file_size_exceeded", size: seen },
);
}
yield chunk;
}
}
const iterator = counted()[Symbol.asyncIterator]();
const first = await iterator.next();
if (first.done) return;
const rest = (async function* () {
yield first.value;
for (;;) {
const next = await iterator.next();
if (next.done) return;
yield next.value;
}
})();
const head = first.value;
if (head.byteLength >= 2 && head[0] === 0x1f && head[1] === 0x8b) {
const gunzip = Readable.from(rest).pipe(createGunzip());
try {
for await (const chunk of gunzip) yield chunk as Buffer;
} catch (err) {
if (err instanceof UploadRefused) throw err;
throw new UploadRefused(422, "Uploaded file is not valid gzip", {
reason: "invalid_gzip",
try {
yield* decodeBackupUpload(source, { compressedLimit: limit });
} catch (err) {
if (err instanceof BackupUploadDecodeError) {
throw new UploadRefused(err.status, err.message, {
reason: err.reason,
...(err.size !== undefined ? { size: err.size } : {}),
});
}
return;
throw err;
}
yield* rest;
}
export const POST = apiHandler(async (request: NextRequest) => {
@@ -259,6 +240,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
let firstWithoutId: number | null = null;
const keys = new BackupKeyIdCollector();
let scanned;
try {
scanned = await scanBackupJson(storedAsRead(), {
@@ -275,6 +257,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (!parsed.data.id && firstWithoutId === null) {
firstWithoutId = index;
}
keys.visit(element, "measurements");
},
});
} catch (err) {
@@ -343,6 +326,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
// A file whose inner ciphertext needs a key this host does not have
// can be stored but never restored: the restore would refuse it, or
// worse, a restore on a host that had lost the key would write rows
// nobody can open. Refused here, where the operator is looking, with
// the key named. The instance settings are left to the restore, which
// checks them only when they are asked for back.
keys.visit(scanned.document);
const keyVerdict = assessBackupKeys(keys, {
ignoreSections: new Set(["appSettings"]),
});
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
const keyIds = [...keyVerdict.missing, ...keyVerdict.unreadable];
throw new UploadRefused(
422,
keyProblem,
{ reason: "key_missing", keyIds },
{ errorCode: BACKUP_KEY_MISSING_CODE, keyIds },
);
}
// Make sure the userId on the file points at a user that exists in
// this DB. Restore would fail later anyway, but failing here gives
// the admin a precise error before any side-effect.
@@ -19,6 +19,10 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getConfiguredKeyIds } from "@/lib/crypto";
import {
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
import {
scanCorpus,
type CorpusClient,
@@ -100,6 +104,100 @@ async function readRotationState(): Promise<RotationState> {
};
}
/**
* Which keys the backups still need.
*
* Rotation re-encrypts every row in the database and re-seals every stored
* backup's envelope, but the content INSIDE a backup keeps the key it was
* written under: a restore writes it back verbatim. So "every row is on the
* active key" is not the same as "the old key can go". This answers the second
* question per key id, from what each copy recorded as it was written: the
* stored copies that need it and the oldest of them, and for the off-host
* bucket the last night an object needing it went in, plus how long the
* bucket's lifecycle rule keeps objects after that.
*/
export interface BackupKeyNeeds {
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
/** Copies written before v1.39.3, which recorded no key ids. */
unrecorded: { copies: number; oldestAt: string | null };
offhost: Array<{
keyId: string;
firstWrittenAt: string;
lastWrittenAt: string;
/** When the last object needing this key expires; null when unknown. */
neededUntil: string | null;
}>;
/** The bucket's expiry, when it has one the credential may read. */
offhostExpirationDays: number | null;
/** Retired key ids some backup still needs (never the active one). */
retiredKeysStillNeeded: string[];
}
async function readBackupKeyNeeds(
activeKeyId: string,
now: Date,
): Promise<BackupKeyNeeds> {
const [stored, unrecorded, offhostRows] = await Promise.all([
prisma.$queryRaw<
Array<{ key_id: string; copies: number; oldest_at: Date }>
>`
SELECT k AS key_id, count(*)::int AS copies, min(created_at) AS oldest_at
FROM data_backups, unnest(inner_key_ids) AS k
WHERE inner_key_ids_recorded
GROUP BY k
ORDER BY k
`,
prisma.dataBackup.aggregate({
where: { innerKeyIdsRecorded: false },
_count: { _all: true },
_min: { createdAt: true },
}),
prisma.offhostBackupKeyUse.findMany({ orderBy: { keyId: "asc" } }),
]);
const lifecycle =
offhostRows.length > 0 && offhostBackupConfigured()
? await probeOffhostLifecycle()
: null;
const expirationDays =
lifecycle?.state === "configured" ? lifecycle.expirationDays : null;
const offhost = offhostRows.map((row) => ({
keyId: row.keyId,
firstWrittenAt: row.firstWrittenAt.toISOString(),
lastWrittenAt: row.lastWrittenAt.toISOString(),
neededUntil:
expirationDays === null
? null
: new Date(
row.lastWrittenAt.getTime() + (expirationDays + 1) * 86_400_000,
).toISOString(),
}));
const needed = new Set<string>();
for (const row of stored) needed.add(row.key_id);
for (const row of offhost) {
if (row.neededUntil === null || Date.parse(row.neededUntil) > +now) {
needed.add(row.keyId);
}
}
needed.delete(activeKeyId);
return {
stored: stored.map((row) => ({
keyId: row.key_id,
copies: row.copies,
oldestAt: row.oldest_at.toISOString(),
})),
unrecorded: {
copies: unrecorded._count._all,
oldestAt: unrecorded._min.createdAt?.toISOString() ?? null,
},
offhost,
offhostExpirationDays: expirationDays,
retiredKeysStillNeeded: [...needed].sort(),
};
}
export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.encryption.status" } });
@@ -114,6 +212,7 @@ export const GET = apiHandler(async () => {
}
const rotation = await readRotationState();
const backups = await readBackupKeyNeeds(scan.activeKeyId, new Date());
annotate({
meta: {
@@ -133,5 +232,14 @@ export const GET = apiHandler(async () => {
staleRows: scan.staleRows,
columns: scan.columns,
rotation,
backups,
// Every row rotated AND no backup still needs a retired key: the one
// state in which dropping an old key loses nothing. Copies that recorded
// no key ids count against it, because they may hold any key that
// existed when they were written.
safeToDropRetiredKeys:
scan.rotationComplete &&
backups.retiredKeysStillNeeded.length === 0 &&
backups.unrecorded.copies === 0,
});
});
@@ -25,6 +25,7 @@ vi.mock("next/headers", () => ({
}));
const mocks = vi.hoisted(() => ({
runningImportFindFirst: vi.fn(async () => null),
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
@@ -60,6 +61,16 @@ vi.mock("@/lib/db", () => ({
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
// The one-running-import check and the new row, in one transaction.
// The check finds nothing running unless a test says otherwise.
$transaction: async (fn: (tx: unknown) => unknown) =>
fn({
$executeRaw: async () => 0,
importJob: {
findFirst: mocks.runningImportFindFirst,
create: mocks.importJobCreate,
},
}),
user: {
findUnique: mocks.userFindUnique,
},
@@ -27,6 +27,11 @@ import {
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import {
createImportJobUnlessBusy,
discardStagedUpload,
IMPORT_BUSY_CODE,
} from "@/lib/import/apple-health-staging";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -96,6 +101,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "missing_user_id" },
});
await discardStagedUpload(uploaded.filePath);
return apiError("Multipart field 'userId' is required", 422);
}
@@ -109,6 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "target_user_missing", targetUserId },
});
await discardStagedUpload(uploaded.filePath);
return apiError(`Target user '${targetUserId}' does not exist`, 422);
}
@@ -149,19 +156,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: targetUser.id,
triggeredByAdminId: admin.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
},
const staged = await createImportJobUnlessBusy(prisma, {
userId: targetUser.id,
triggeredByAdminId: admin.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
});
if ("busy" in staged) {
await discardStagedUpload(uploaded.filePath);
return apiError(
"An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
409,
{ errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
);
}
const importJob = staged.created;
const payload: AppleHealthImportPayload = {
userId: targetUser.id,
@@ -178,6 +193,19 @@ export const POST = apiHandler(async (request: NextRequest) => {
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) {
await prisma.importJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason:
"The import could not be queued. Upload the export again.",
completedAt: new Date(),
},
});
await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "The import could not be queued");
}
await prisma.importJob.update({
where: { id: importJob.id },
@@ -27,6 +27,7 @@ vi.mock("next/headers", () => ({
}));
const mocks = vi.hoisted(() => ({
runningImportFindFirst: vi.fn(async () => null),
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }),
bossSend: vi.fn().mockResolvedValue("boss-job-1"),
getGlobalBoss: vi.fn(),
@@ -35,6 +36,7 @@ const mocks = vi.hoisted(() => ({
importJobUpdate: vi.fn(),
streamToDisk: vi.fn(),
unlink: vi.fn().mockResolvedValue(undefined),
rm: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
@@ -51,7 +53,7 @@ vi.mock("@/lib/multipart/stream-to-disk", () => ({
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return { ...actual, unlink: mocks.unlink };
return { ...actual, unlink: mocks.unlink, rm: mocks.rm };
});
vi.mock("@/lib/db", () => ({
@@ -61,6 +63,16 @@ vi.mock("@/lib/db", () => ({
findFirst: mocks.importJobFindFirst,
update: mocks.importJobUpdate,
},
// The one-running-import check and the new row, in one transaction.
// The check finds nothing running unless a test says otherwise.
$transaction: async (fn: (tx: unknown) => unknown) =>
fn({
$executeRaw: async () => 0,
importJob: {
findFirst: mocks.runningImportFindFirst,
create: mocks.importJobCreate,
},
}),
},
}));
@@ -90,6 +102,7 @@ beforeEach(() => {
textFields: {},
});
mocks.unlink.mockResolvedValue(undefined);
mocks.rm.mockResolvedValue(undefined);
});
function multipartReq(): NextRequest {
@@ -220,3 +233,51 @@ describe("POST /api/import/apple-health-export — dedup by content hash (issue
);
});
});
// The staged upload is the person's export in plain text. Every exit that
// leaves no worker to consume it removes it, and a second import for the same
// account is refused while one is running.
describe("POST /api/import/apple-health-export — the staged upload on every exit", () => {
const removed = () =>
mocks.rm.mock.calls.some(([path]) => path === STAGED_PATH);
it("refuses a second import while one is running, and removes the upload", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.runningImportFindFirst.mockResolvedValue({
id: "ij-running",
status: "parsing",
} as never);
const res = await POST(multipartReq());
expect(res.status).toBe(409);
const body = await res.json();
expect(body.meta).toMatchObject({
errorCode: "import.apple_health.busy",
jobId: "ij-running",
});
expect(mocks.importJobCreate).not.toHaveBeenCalled();
expect(mocks.bossSend).not.toHaveBeenCalled();
expect(removed()).toBe(true);
});
it("removes the upload when no worker is running", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.getGlobalBoss.mockReturnValue(null);
const res = await POST(multipartReq());
expect(res.status).toBe(503);
expect(removed()).toBe(true);
});
it("fails the row and removes the upload when the queue takes no job", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
mocks.bossSend.mockResolvedValue(null);
const res = await POST(multipartReq());
expect(res.status).toBe(503);
expect(mocks.importJobUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "ij-1" },
data: expect.objectContaining({ status: "failed" }),
}),
);
expect(removed()).toBe(true);
});
});
@@ -35,6 +35,11 @@ import {
type AppleHealthImportPayload,
} from "@/lib/jobs/apple-health-import-worker";
import { streamMultipartToDisk } from "@/lib/multipart/stream-to-disk";
import {
createImportJobUnlessBusy,
discardStagedUpload,
IMPORT_BUSY_CODE,
} from "@/lib/import/apple-health-staging";
import { unlink } from "node:fs/promises";
export const dynamic = "force-dynamic";
@@ -147,18 +152,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
ipAddress: getClientIp(request),
details: { reason: "worker_not_running" },
});
// Nothing will ever read the staged export; it must not stay in /tmp.
await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "Background worker is not running");
}
const importJob = await prisma.importJob.create({
data: {
userId: user.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
},
// One running import per account (see `apple-health-staging.ts`).
const staged = await createImportJobUnlessBusy(prisma, {
userId: user.id,
status: "queued",
uploadBytes: uploaded.bytes,
uploadSha256: uploaded.sha256,
parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION,
});
if ("busy" in staged) {
await discardStagedUpload(uploaded.filePath);
return apiError(
"An Apple Health import is already running for this account. Wait for it to finish, then upload again.",
409,
{ errorCode: IMPORT_BUSY_CODE, jobId: staged.busy.id },
);
}
const importJob = staged.created;
const payload: AppleHealthImportPayload = {
userId: user.id,
@@ -174,6 +189,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
payload,
APPLE_HEALTH_IMPORT_SEND_OPTIONS,
);
if (!bossJobId) {
// The queue did not take the job, so no worker will consume the upload
// or move the row on. Fail the row now and remove the export.
await prisma.importJob.update({
where: { id: importJob.id },
data: {
status: "failed",
failureReason:
"The import could not be queued. Upload the export again.",
completedAt: new Date(),
},
});
await discardStagedUpload(uploaded.filePath);
throw new HttpError(503, "The import could not be queued");
}
await prisma.importJob.update({
where: { id: importJob.id },
+10
View File
@@ -8,6 +8,10 @@ import {
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
kickOffhostPurge,
requestOffhostPurge,
} from "@/lib/jobs/offhost-purge";
import {
deleteGuardianAccountWithLifecycle,
LastManagedGuardianError,
@@ -101,6 +105,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// genuinely complete erasure.
await tx.auditLog.deleteMany({ where: { userId } });
// The nightly off-host copies go with the account. The request commits
// with the deletion; the purge job deletes the objects.
await requestOffhostPurge(tx, userId, "account_deleted");
// Delete user — all other related data is removed via onDelete: Cascade.
await tx.user.delete({ where: { id: userId } });
});
@@ -115,6 +123,8 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
throw error;
}
await kickOffhostPurge();
annotate({
action: { name: "settings.account.delete" },
meta: { username },
+10
View File
@@ -1,5 +1,9 @@
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 {
@@ -101,6 +105,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// does not. Same classification contract as the model list.
await tx.user.update({ where: { id: userId }, data: USER_RESET });
// 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;
},
{
@@ -144,6 +153,7 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
// 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 });
});