Files
HealthLog/src/lib/export/account-copy.ts
T
Marc-André Bombeck a69666fa16 feat(account): hold account deletion and data wipe for a grace window
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once
as before) now sets a window:

- Account and managed-profile deletion lock the account (migration 0488:
  deletion_requested_at, deletion_purge_at, deletion_requested_by_id).
  Every session, refresh token, API token, connector, share link and
  step-up elevation is revoked in the same transaction, and the record,
  its stored copies and its off-host objects are left as they are.
- Sign-in is refused with 403 auth.account.deletion_pending and the
  erasure date, after the credential was proved and before a second
  factor is asked for. The session and Bearer resolvers and the acting-
  account resolver refuse a pending account too, so a credential minted
  in the moment before the lock is useless.
- POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged)
  clears the lock. The admin users list reports the pending state.
- The hourly account-deletion-purge job erases accounts whose window has
  ended, exactly as the immediate deletion did, with the off-host purge
  request committed alongside. The Guardian invariant is decided again;
  a refused erasure stays pending and is reported.
- A wipe first stores the whole record as a PRE_WIPE_<ms> copy through
  the writer the weekly copy uses, keeps it out of the wipe, and holds
  its off-host purge back with a new not_before on the request. The
  ordinary restore brings the record back; restoring that copy withdraws
  the held purge. The purge job deletes the copy when its window ends.

Background work skips accounts pending deletion through one shared
predicate (src/lib/account-deletion/active-account.ts): every reminder,
digest, Coach, sync, backfill, rollup, score and backup enumeration
filters with it, and account-deletion-enumeration-guard.test.ts fails on
a query in background work that neither filters nor says why it need
not. Notification delivery and the AI capability loader refuse a pending
account as chokepoints behind it, and the Withings, WHOOP and Telegram
webhooks and the notification quick actions ignore one.

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
2026-10-25 13:50:14 +01:00

51 lines
2.0 KiB
TypeScript

/**
* The one writer of an in-database account copy.
*
* The weekly pass and the copy a data wipe takes first both store the whole
* record through here, so the two cannot drift into different shapes: what an
* admin restores after a wipe is exactly what the weekly copy would have been
* at that moment.
*
* Streamed, compressed, then encrypted (the record contains sensitive health
* information), and stored a piece at a time. Nothing between the database
* rows and the stored copy exists as a whole in this process: the payload
* goes into gzip a page at a time, and gzip's output is sealed and stored in
* pieces of about a megabyte (`storeBackupBlob`). The copy is kept as those
* pieces, so its size does not depend on this process's memory (#1031). The
* restore preview is taken from the writer's own counts and the sections as
* they are written, never by parsing the copy.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { createBackupPreviewCollector } from "@/lib/export/backup-preview";
import type { FullBackupCounts } from "@/lib/export/full-backup-payload";
import { streamFullBackupJson } from "@/lib/export/full-backup-stream";
import { storeBackupBlob } from "@/lib/export/store-backup-blob";
/**
* Store `userId`'s whole record as the `(userId, type)` copy, replacing that
* copy if it exists. Resolves to the row's id, its stored size in bytes and
* the number of pieces.
*/
export async function writeAccountCopy(
prisma: PrismaClient,
userId: string,
type: string,
): Promise<{ id: string; bytes: number; chunks: number }> {
const preview = await createBackupPreviewCollector();
let counts: FullBackupCounts | undefined;
return storeBackupBlob(
prisma,
{
userId,
type,
preview: () => (counts ? preview.finish(counts) : null),
},
async (write) => {
counts = await streamFullBackupJson(prisma, userId, write, {
purpose: "disaster-recovery",
observe: preview.observe,
});
},
);
}