diff --git a/.env.production.example b/.env.production.example index ecf31e293..fa8ca962a 100644 --- a/.env.production.example +++ b/.env.production.example @@ -446,6 +446,16 @@ API_TOKEN_HMAC_KEY="" # 90 rather than repeat the default. # COACH_MESSAGE_RETENTION_DAYS="365" +# How long deleting an account, a managed profile or "all data" stays +# undoable, in days. Default 14, ceiling 30. The account is locked at once +# (signed out everywhere, sign-in refused, nothing runs for it) and an admin +# can undo the deletion from the users list until the window ends; the purge +# job erases it then, together with its stored and off-host copies. A wipe +# keeps a copy of the record an admin can restore for the same time. 0 erases +# immediately, as releases before 1.46 did. The Data & Privacy page states +# this number, so it has to reach the container. +# ACCOUNT_DELETION_GRACE_DAYS="14" + # How long the host CPU/memory samples behind the admin monitoring charts are # kept, in days. Default 7. Raise it for a longer trend; lower it on a host # where disk is the scarce resource. diff --git a/docker-compose.yml b/docker-compose.yml index cdfbfeea6..71302e285 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -168,6 +168,7 @@ x-healthlog-env: &healthlog_env # the block exists: `environment:` is a whitelist, and a value in .env that # is not named here never arrives. AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}" + ACCOUNT_DELETION_GRACE_DAYS: "${ACCOUNT_DELETION_GRACE_DAYS:-}" # How long Coach conversations are kept, in days (default 365, floor 30). # Like the audit window this number is PUBLISHED to the user on the Data & # Privacy page, so an instance running 90 has to be able to say 90. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9255e81e3..ff5fabb48 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2201,11 +2201,18 @@ paths: - $ref: "#/components/schemas/MfaRequiredResponse" "401": *a1 "403": - description: "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in + description: >- + Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account. A sign-in to any other account on that browser is refused before its password is checked, so the answer is the - same whether the password was right or wrong." + same whether the password was right or wrong. + + + Also 403 `auth.account.deletion_pending`, after the password was proved and before a second factor is asked + for: the account is pending deletion and locked. `meta.purgeAt` is when it is erased; an admin can undo the + deletion until then. Every other sign-in surface (passkey, second-factor completion, refresh, the native + token exchanges) refuses with the same code. content: application/json: schema: @@ -14987,11 +14994,19 @@ paths: delete: tags: - Auth - summary: Permanently delete the account and everything in it + summary: Delete the account and everything in it description: >- - Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit - history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There - is no undo and no grace period. + Erasure, after a grace window. With `ACCOUNT_DELETION_GRACE_DAYS` above 0 (14 by default) the account is locked + at once — every session, refresh token, API token, connector and share link is revoked, sign-in is refused with + 403 `auth.account.deletion_pending` and the erasure date in `meta.purgeAt`, and nothing acts for the account any + more — and the purge job erases it when the window ends. Until then an admin can undo the deletion, and the + record is exactly as it was. `scheduledPurgeAt` in the answer is that date. With the window set to 0 the account + is erased in this request, as before, and `scheduledPurgeAt` is null. + + + The erasure itself: the user row goes, every related row cascades with it, the off-host copies are purged, and + the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than + being left orphaned. Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor @@ -15020,7 +15035,8 @@ paths: $ref: "#/components/schemas/DeleteAccountRequest" responses: "200": - description: Account and all associated data deleted. + description: Account locked for deletion (erased at `scheduledPurgeAt`), or erased now when there is no grace window + (`scheduledPurgeAt` null). content: application/json: schema: @@ -15321,6 +15337,12 @@ paths: data. + With a deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 days by default) the whole record is first + stored as an account copy of its own (`PRE_WIPE_`), through the writer the weekly copy uses. The wipe leaves + that copy in place, an admin can restore it until `restorableUntil`, and the off-host purge waits until then + too. A copy that cannot be written stops the wipe before anything is deleted: 503 `data_wipe.copy_failed`. + + Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The @@ -15389,6 +15411,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + "503": + description: The account copy kept before a wipe could not be written (`meta.errorCode` = `data_wipe.copy_failed`), so + nothing was deleted. Only with a deletion grace window. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/settings/module-list-layout: get: tags: @@ -23418,10 +23447,16 @@ paths: tags: - Account sharing summary: Delete a managed record - description: "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is - why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after - it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second - factor." + description: >- + Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is why it + carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after it, + but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second factor. + + + With a deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 days by default) the record is locked rather + than erased: it leaves every Guardian's view at once (reads of it answer 404), nothing acts for it, and the + purge job erases it at `scheduledPurgeAt`. Until then an admin can undo the deletion. Without a window it is + erased now and `scheduledPurgeAt` is null. parameters: - in: path name: id @@ -23430,7 +23465,8 @@ paths: required: true responses: "200": - description: The record is gone. + description: The record is gone from the Guardian's view; it is erased at `scheduledPurgeAt`, or already was when that + is null. content: application/json: schema: @@ -38474,18 +38510,18 @@ components: one the native client names; the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a - credential of its own carries the generic `http.unauthenticated`. activity — - activity.calendar.future_date, activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, - ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, - ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, - ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, + credential of its own carries the generic `http.unauthenticated`. account — + account.deletion.not_pending; activity — activity.calendar.future_date, activity.calendar.too_old; ai — + ai.budget.exceeded, ai.provider.none, ai.record.notPermitted, ai.unavailable, ai.usage.aboveCeiling, + ai.usage.defaultAboveCeiling, ai.usage.invalid, ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider + — ai_provider.invalid, ai_provider.no_fields; aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid, anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict, anamnesis.fact.currentExists, anamnesis.fact.invalidValue, - anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.admin.required, - auth.already_authenticated, auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, - auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, auth.reproof.required, - auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, + anamnesis.lifeConfirm.invalid, anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, + auth.admin.required, auth.already_authenticated, auth.mfa.code_invalid, auth.missing, + auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, auth.reproof.failed, + auth.reproof.required, auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.payload.undecryptable, backup.restore.active, backup.section.missing, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — @@ -38498,23 +38534,23 @@ components: cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, - dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — - day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound, - documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, - documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, - documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — + dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, + dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -73326,8 +73362,15 @@ components: properties: deleted: type: boolean + scheduledPurgeAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - deleted + - scheduledPurgeAt additionalProperties: false error: type: "null" @@ -73517,10 +73560,20 @@ components: maximum: 9007199254740991 description: Per-model row counts, listing only the models that actually had rows. A model absent from this map had nothing to delete — it was not skipped. + restorableUntil: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: "Until when an admin can restore the record from the account copy taken immediately before the wipe; the + off-host copies from before the wipe are kept until then too. Null when the instance has no deletion grace + window (`ACCOUNT_DELETION_GRACE_DAYS=0`): the wipe was final." required: - cleared - deletedRows - models + - restorableUntil additionalProperties: false ModuleListLayoutEnvelope: type: object @@ -73654,10 +73707,19 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: Fixed 90-day window on the push-attempt and mood-dispatch ledgers. + accountDeletionGraceDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Days between asking to delete the account (or a managed profile, or every entry) and the erasure + completing. The account is locked at once; until the window ends an admin can undo the deletion, or + restore the copy a data wipe took first. 0 means the erasure completes immediately. Read from the same + resolver the purge job uses. required: - coachMessagesDays - auditLogDays - deliveryLogDays + - accountDeletionGraceDays additionalProperties: false description: Retention windows as the privacy dashboard states them. Off-host backup retention is operator infrastructure rather than an app setting and is deliberately absent — it is disclosed as prose in the UI, @@ -87524,8 +87586,15 @@ components: deleted: type: boolean const: true + scheduledPurgeAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" required: - deleted + - scheduledPurgeAt additionalProperties: false error: type: "null" diff --git a/prisma/migrations/0488_account_deletion_grace/migration.sql b/prisma/migrations/0488_account_deletion_grace/migration.sql new file mode 100644 index 000000000..6144403ca --- /dev/null +++ b/prisma/migrations/0488_account_deletion_grace/migration.sql @@ -0,0 +1,32 @@ +-- Account deletion, data wipe and managed-profile deletion keep the record +-- for a grace window before anything is erased. +-- +-- `users.deletion_requested_at` marks an account as pending deletion: it is +-- locked at once and skipped by every background pass, and an admin can undo +-- the deletion until `deletion_purge_at`, when the purge job erases it the +-- way the immediate deletion did before. `deletion_requested_by_id` names who +-- asked (the account itself or a Guardian), as a plain id. +-- +-- `offhost_purge_requests.not_before` lets a data wipe write its request +-- with the window's end, so the off-host copies from before the wipe stay +-- until then. +-- +-- Nullable, no default, no backfill: no existing account is pending deletion +-- and every existing request is due now. Reversibility: dropping the columns +-- and the index restores the immediate behaviour's schema. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "users" + ADD COLUMN IF NOT EXISTS "deletion_requested_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "deletion_purge_at" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "deletion_requested_by_id" TEXT; + ALTER TABLE "offhost_purge_requests" + ADD COLUMN IF NOT EXISTS "not_before" TIMESTAMP(3); + CREATE INDEX IF NOT EXISTS "users_deletion_purge_at_idx" + ON "users" ("deletion_purge_at"); +END +$migration$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8397e0cb5..21baac637 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -77,6 +77,25 @@ model User { /// delivery fanout after it defines consent and preference semantics. managedProfileAt DateTime? @map("managed_profile_at") + /// When deletion of this account was asked for. Non-null means the + /// account is pending deletion: it is locked (every session, refresh token + /// and API token was revoked in the same transaction, and sign-in is + /// refused), every background pass skips it (`ACTIVE_ACCOUNT`, + /// `src/lib/account-deletion/active-account.ts`), and its data, stored + /// copies and off-host objects stay untouched until `deletionPurgeAt`. An + /// admin can undo it until then. Null on every account nobody asked to + /// delete. Readers: the session and Bearer resolvers, the sign-in refusal, + /// the admin users list and the undo route. + deletionRequestedAt DateTime? @map("deletion_requested_at") + /// When the account-deletion purge job erases the account for good: + /// `deletionRequestedAt` plus `ACCOUNT_DELETION_GRACE_DAYS`. The sign-in + /// refusal names this date. Reader: `processDueAccountDeletions`. + deletionPurgeAt DateTime? @map("deletion_purge_at") + /// Who asked: the account itself, or the Guardian who deleted a + /// managed profile. A plain id, not a relation: the requester's own + /// deletion must not touch this row. Reader: the admin users list. + deletionRequestedById String? @map("deletion_requested_by_id") + // Profile heightCm Float? @map("height_cm") dateOfBirth DateTime? @map("date_of_birth") @@ -1154,6 +1173,7 @@ model User { // OIDC key (both NULL on accounts that never signed in via SSO; Postgres // unique indexes ignore NULL pairs). @@unique([oidcIssuer, oidcSub]) + @@index([deletionPurgeAt], map: "users_deletion_purge_at_idx") @@map("users") } @@ -5924,6 +5944,12 @@ model OffhostPurgeRequest { lastAttemptAt DateTime? @map("last_attempt_at") /// The bucket's last refusal, trimmed; shown to the operator. lastFailure String? @map("last_failure") + /// The purge leaves this request alone until then. A data wipe + /// writes its request with the end of the deletion grace window here, so + /// the off-host copies from before the wipe stay restorable as long as the + /// account copy taken before it. Null: due at once (every account deletion, + /// which writes its request only when the purge erases the account). + notBefore DateTime? @map("not_before") @@index([requestedAt]) @@map("offhost_purge_requests") diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 890c6327c..1833ce1e1 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -370,6 +370,11 @@ "purpose": "How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.", "example": "365" }, + { + "name": "ACCOUNT_DELETION_GRACE_DAYS", + "purpose": "Days an account deletion, a managed-profile deletion or a data wipe stays undoable before the purge erases it (14 by default, 30 at most, 0 erases immediately). The account is locked at once. Published: the deletion dialogs and the Data & Privacy page state the number.", + "example": "14" + }, { "name": "SESSION_COOKIE_SECURE", "purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.", diff --git a/src/__tests__/account-deletion-enumeration-guard.test.ts b/src/__tests__/account-deletion-enumeration-guard.test.ts new file mode 100644 index 000000000..8294b1478 --- /dev/null +++ b/src/__tests__/account-deletion-enumeration-guard.test.ts @@ -0,0 +1,251 @@ +/** + * Structural guard: background work skips accounts pending deletion. + * + * An account pending deletion is locked for its grace window and frozen as + * it was: no reminder, sync, copy, rollup, score, digest or Coach pass may act + * for it (`src/lib/account-deletion/active-account.ts`). Background work + * reaches accounts by enumerating them, so every query in background work + * that reads across accounts has to filter them out — and a new query is the + * one most likely to forget. + * + * The rule, for every `findMany(` / `groupBy(` and every raw SQL query that + * reads accounts' rows in the background work below: + * + * - it names the shared predicate (`ACTIVE_ACCOUNT`, + * `OWNED_BY_ACTIVE_ACCOUNT` or `activeAccountSql`), or its result goes + * straight into `dropPendingDeletion` (a scan of `measurements`, too + * large to join `users` into per row), or + * - it is scoped to named accounts (a `userId` it was handed, an `id` list + * it already filtered), or + * - a comment directly above it says why it needn't: + * `// account-deletion: ` (a ledger read, the purge's own query, + * …), or the module says so for all of its queries: + * `// account-deletion(file): ` (an encryption backfill, a + * retention sweep over rows rather than accounts). + * + * Two chokepoints back it up behaviourally (the notification dispatcher and + * the AI capability loader refuse a pending account; see their tests), so a + * scope the matcher misreads cannot send a reminder or start model work. + * + * It is a tripwire, not a proof: the scoping regex is shape-based. Its own + * matchers are held to non-zero counts, and the last test plants a query + * that forgets the predicate and requires the rule to catch it. + */ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; + +import { + readSourceText, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** + * Where background work lives: every job module, plus the shared candidate + * builders outside `src/lib/jobs` that jobs call to enumerate accounts. + */ +const EXTRA_FILES = [ + "lib/measurements/consolidation-base.ts", + "lib/integrations/watchdog.ts", +]; + +function backgroundFiles(): string[] { + const jobs = walkSourceFiles(join(SRC, "lib/jobs"), { floor: 150 }) + .filter((rel) => !rel.includes("__tests__")) + .filter((rel) => !rel.endsWith(".test.ts")) + .map((rel) => `lib/jobs/${rel}`); + return [...jobs, ...EXTRA_FILES].sort(); +} + +const PREDICATE = + /\b(?:ACTIVE_ACCOUNT|OWNED_BY_ACTIVE_ACCOUNT|activeAccountSql|activeAccount|ownedByActiveAccount)\b/; + +/** A query scoped to accounts it was handed, not one that finds them. */ +const SCOPED = [ + // `{ userId }`, `userId: x`, `userId: { in: ids }` — but not a select + // (`userId: true`) or a negation that reads every other account. + /\b(?:userId|recordUserId|subjectId|grantorId|profileId)\s*(?:[,}\n]|:(?!\s*(?:true|false)\b|\s*\{\s*(?:not|notIn)\b))/, + /\bid\s*:\s*(?:userId|input\.userId|\{\s*in\b)/, + /"?user_id"?\s*=\s*(?:\$\{|ANY\s*\(\s*\$\{)/, + /\bu\."id"\s*=\s*\$\{/, + /"id"\s*=\s*(?:\$\{|ANY\s*\(\s*\$\{)/, +]; + +const MARKER = /\/\/\s*account-deletion:\s*\S/; + +/** + * The same, for a module whose every query is maintenance of rows rather than + * work for an account (an encryption backfill, a retention sweep, a ledger): + * `// account-deletion(file): ` anywhere in it. + */ +const FILE_MARKER = /\/\/\s*account-deletion\(file\):\s*\S/; + +export interface Query { + file: string; + line: number; + text: string; + /** Whether the query's result goes straight into `dropPendingDeletion`. */ + dropped: boolean; +} + +/** `dropPendingDeletion(db, await ` right before the call. */ +const DROPPED = /dropPendingDeletion\s*\(\s*[\w$]+\s*,\s*await\s+[\w$.]+$/; + +/** The text between the opener at `open` and its matching closer. */ +function balanced(code: string, open: number): string { + let depth = 0; + let quote: string | null = null; + let i = open; + for (; i < code.length; i++) { + const ch = code[i]; + if (quote) { + if (ch === "\\") i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === '"' || ch === "'") quote = ch; + else if (ch === "`") { + // A template: read to its closing backtick, `${…}` included. + const end = templateEnd(code, i); + i = end; + } else if (ch === "(" || ch === "{" || ch === "[") depth++; + else if (ch === ")" || ch === "}" || ch === "]") { + depth--; + if (depth === 0) break; + } + } + return code.slice(open, i + 1); +} + +/** The index of the backtick closing the template opened at `start`. */ +function templateEnd(code: string, start: number): number { + let i = start + 1; + while (i < code.length) { + const ch = code[i]; + if (ch === "\\") { + i += 2; + continue; + } + if (ch === "`") return i; + if (ch === "$" && code[i + 1] === "{") { + const inner = balanced(code, i + 1); + i += 1 + inner.length; + continue; + } + i++; + } + return i; +} + +/** Every `findMany(` / `groupBy(` call and account SQL in `source`. */ +export function queriesIn(file: string, source: string): Query[] { + const code = stripComments(source); + const lineOf = (index: number) => code.slice(0, index).split("\n").length; + const out: Query[] = []; + const dropped = (index: number) => + DROPPED.test(code.slice(Math.max(0, index - 200), index)); + for (const m of code.matchAll(/\.(?:findMany|groupBy)\s*\(/g)) { + const open = m.index + m[0].length - 1; + out.push({ + file, + line: lineOf(m.index), + text: balanced(code, open), + dropped: dropped(m.index), + }); + } + for (const m of code.matchAll(/\.\$(?:queryRaw|queryRawUnsafe)\b/g)) { + const rest = code.slice(m.index + m[0].length); + const tick = rest.search(/[`(]/); + if (tick < 0) continue; + const at = m.index + m[0].length + tick; + const text = + code[at] === "`" + ? code.slice(at, templateEnd(code, at) + 1) + : balanced(code, at); + // SQL that reads no account (the queue's own tables, catalogue sizes) + // enumerates nobody. + if (!/\buser_id\b|"users"|\busers\b/.test(text)) continue; + out.push({ file, line: lineOf(m.index), text, dropped: dropped(m.index) }); + } + return out; +} + +/** Whether a comment within the three lines above `line` gives a reason. */ +function markedAt(source: string, line: number): boolean { + const lines = source.split("\n"); + const from = Math.max(0, line - 4); + return lines.slice(from, line).some((text) => MARKER.test(text)); +} + +export function violations(file: string, source: string): Query[] { + if (FILE_MARKER.test(source)) return []; + return queriesIn(file, source).filter( + (q) => + !q.dropped && + !PREDICATE.test(q.text) && + !SCOPED.some((re) => re.test(q.text)) && + !markedAt(source, q.line), + ); +} + +describe("background work skips accounts pending deletion", () => { + const files = backgroundFiles(); + + it("reads the background work it is about", () => { + expect(files.length).toBeGreaterThan(150); + const all = files.flatMap((file) => + queriesIn(file, readSourceText(join(SRC, file))), + ); + // A matcher that finds nothing proves nothing. + expect(all.length).toBeGreaterThan(100); + expect(all.filter((q) => PREDICATE.test(q.text)).length).toBeGreaterThan( + 15, + ); + }); + + it("every cross-account query filters, is scoped, or says why not", () => { + const found = files.flatMap((file) => + violations(file, readSourceText(join(SRC, file))), + ); + expect( + found.map((q) => `${q.file}:${q.line}`), + "Filter with ACTIVE_ACCOUNT / OWNED_BY_ACTIVE_ACCOUNT / activeAccountSql " + + "(src/lib/account-deletion/active-account.ts), or put " + + "`// account-deletion: ` directly above the query.", + ).toEqual([]); + }); + + it("catches a query that forgets the predicate", () => { + const planted = [ + "export async function sweep() {", + " const users = await prisma.user.findMany({", + " where: { moodReminderEnabled: true },", + " select: { id: true },", + " });", + " const rows = await prisma.$queryRaw`", + ' SELECT "id" FROM "users" WHERE "telegram_enabled"`;', + "}", + ].join("\n"); + expect(violations("planted.ts", planted)).toHaveLength(2); + + const fixed = planted + .replace( + "where: { moodReminderEnabled: true }", + "where: { moodReminderEnabled: true, ...ACTIVE_ACCOUNT }", + ) + .replace( + 'WHERE "telegram_enabled"`', + 'WHERE "telegram_enabled" AND ${activeAccountSql(\'"id"\')}`', + ); + expect(violations("planted.ts", fixed)).toHaveLength(0); + + const select = planted.replace( + "select: { id: true }", + "select: { id: true, userId: true }", + ); + // Selecting `userId` is not a scope. + expect(violations("planted.ts", select)).toHaveLength(2); + }); +}); diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index d57271904..862d02816 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 86, - "total": 96631, + "count": 84, + "total": 94984, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -34,7 +34,6 @@ "lib/analytics/score/reader.ts": 998, "lib/analytics/sleep-night.ts": 1345, "lib/analytics/summaries-slice.ts": 1178, - "lib/auth/session.ts": 811, "lib/cache/server-cache.ts": 848, "lib/cycle/prediction.ts": 883, "lib/daily/digest.ts": 1317, @@ -67,9 +66,8 @@ "lib/jobs/coach-nudge.ts": 1084, "lib/jobs/insight-pregenerate.ts": 1189, "lib/jobs/medication-intake-import.ts": 819, - "lib/jobs/offhost-backup.ts": 1147, + "lib/jobs/offhost-backup.ts": 1127, "lib/jobs/reaction-line.ts": 824, - "lib/jobs/reminder/medication-reminder-check.ts": 816, "lib/managed-profiles/handover.ts": 986, "lib/mcp/record-search.ts": 1467, "lib/mcp/rich-reads.ts": 1467, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index d3aec717a..92da009c2 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 348, - "total": 112810, + "total": 112749, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 286, @@ -9,7 +9,7 @@ "app/api/admin/settings/route.ts :: apiHandler(arg) #2": 211, "app/api/auth/login/route.ts :: apiHandler(arg)": 262, "app/api/auth/me/route.ts :: apiHandler(arg)": 386, - "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 294, + "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 281, "app/api/auth/register/route.ts :: apiHandler(arg)": 268, "app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309, "app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230, @@ -315,8 +315,8 @@ "lib/jobs/medication-low-stock.ts :: runMedicationLowStockTick": 286, "lib/jobs/oura-backfill.ts :: runOuraBackfillForUser": 236, "lib/jobs/reaction-line.ts :: runReactionLine": 337, - "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 616, - "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 611, + "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck": 592, + "lib/jobs/reminder/medication-reminder-check.ts :: handleReminderCheck > withBackgroundEvent(arg)": 587, "lib/jobs/workout-insight-generate.ts :: runWorkoutInsightGenerate": 324, "lib/labs/biomarker-merge.ts :: runMerge": 254, "lib/measurements/consolidate-daily-mean.ts :: consolidateDailyMean": 278, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 81a01b793..ff23102d7 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2897, - "total": 9249, + "total": 9243, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1891,7 +1891,7 @@ "lib/auth/passkey.ts": 4, "lib/auth/profile-update.ts": 5, "lib/auth/security-activity.ts": 1, - "lib/auth/session.ts": 13, + "lib/auth/session.ts": 12, "lib/avatar.ts": 1, "lib/cache/__tests__/invalidate.test.ts": 13, "lib/cache/invalidate.ts": 22, @@ -2324,7 +2324,7 @@ "lib/jobs/google-health-oauth-state-cleanup.ts": 1, "lib/jobs/google-health-sleep-repair.ts": 1, "lib/jobs/insight-pregenerate-shared.ts": 1, - "lib/jobs/insight-pregenerate.ts": 37, + "lib/jobs/insight-pregenerate.ts": 36, "lib/jobs/insight-status-generate-shared.ts": 3, "lib/jobs/insight-status-generate.ts": 3, "lib/jobs/intake-auto-skip.ts": 8, @@ -2357,7 +2357,7 @@ "lib/jobs/reminder/google-health-sync.ts": 1, "lib/jobs/reminder/insights-handlers.ts": 3, "lib/jobs/reminder/medication-maintenance.ts": 3, - "lib/jobs/reminder/medication-reminder-check.ts": 12, + "lib/jobs/reminder/medication-reminder-check.ts": 9, "lib/jobs/reminder/mood-cycle-checks.ts": 3, "lib/jobs/reminder/ops-handlers.ts": 3, "lib/jobs/reminder/oura-sync.ts": 1, @@ -2791,7 +2791,7 @@ "lib/targets/types.ts": 2, "lib/targets/vitals-builder.ts": 2, "lib/telegram-cleanup.ts": 1, - "lib/telegram-webhook-handlers.ts": 18, + "lib/telegram-webhook-handlers.ts": 17, "lib/telegram.ts": 2, "lib/time-window-format.ts": 1, "lib/timeline/__tests__/lane-access.test.ts": 1, diff --git a/src/__tests__/error-level-discipline-guard.test.ts b/src/__tests__/error-level-discipline-guard.test.ts index 98459a233..edbc87410 100644 --- a/src/__tests__/error-level-discipline-guard.test.ts +++ b/src/__tests__/error-level-discipline-guard.test.ts @@ -235,7 +235,7 @@ const ALLOWED: Readonly> = { reason: "Trying each key of the ring in turn; the last error is thrown when no key opens the object.", }, - "lib/jobs/reminder/medication-reminder-check.ts::cleanupScheduledTelegramDeletions": + "lib/jobs/reminder/telegram-deletion-cleanup.ts::cleanupScheduledTelegramDeletions": { count: 1, reason: diff --git a/src/__tests__/guard-floor-guard.test.ts b/src/__tests__/guard-floor-guard.test.ts index 687ec7d31..9a6465295 100644 --- a/src/__tests__/guard-floor-guard.test.ts +++ b/src/__tests__/guard-floor-guard.test.ts @@ -55,8 +55,6 @@ const SRC = join(process.cwd(), "src"); const NO_FLOOR_NEEDED: Readonly> = { // Fixed file lists. Each file is read by name, so a missing one throws, // and each must positively match the shape it is supposed to keep. - "__tests__/backup-readers-stream-guard.test.ts": - "named readers, each must positively reference the streaming reader", "__tests__/proxy-relative-redirect-guard.test.ts": "named routes, each must positively call relativeRedirect(", "__tests__/rotation-walk-keyset-guard.test.ts": diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts index 19698e453..3bd4e7d71 100644 --- a/src/__tests__/notification-delivery-identity.test.ts +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -11,7 +11,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), upsert: vi.fn(), }, - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), count: vi.fn().mockResolvedValue(0) }, accountGrant: { findMany: vi.fn() }, notificationEgressAuthorization: { update: (...args: unknown[]) => authorizationUpdateMock(...args), diff --git a/src/__tests__/openapi-route-coverage-guard.test.ts b/src/__tests__/openapi-route-coverage-guard.test.ts index 6a01790a2..31d5b1fec 100644 --- a/src/__tests__/openapi-route-coverage-guard.test.ts +++ b/src/__tests__/openapi-route-coverage-guard.test.ts @@ -303,6 +303,10 @@ const UNPUBLISHED: Readonly> = { kind: "adminConsole", methods: ["POST"], }, + "/api/admin/users/{id}/undo-deletion": { + kind: "adminConsole", + methods: ["POST"], + }, "/api/fitbit/connect": { kind: "browserHandoff", methods: ["GET"] }, "/api/google-health/connect": { kind: "browserHandoff", methods: ["GET"] }, // `/api/nightscout/connect` sat here as a `browserHandoff` and is published diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index f201b089f..a5b70f375 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -46,6 +46,7 @@ * can be walked. */ import { NextRequest } from "next/server"; +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { apiHandler, @@ -100,7 +101,9 @@ export const GET = apiHandler(async () => { ), listPage(MAX_GRANTS_PER_DIRECTION, (take) => prisma.accountGrant.findMany({ - where: { granteeId: user.id }, + // A record pending deletion is locked for everyone who could + // reach it, so it leaves the list of records shared with them. + where: { granteeId: user.id, grantor: ACTIVE_ACCOUNT }, orderBy: [{ createdAt: "desc" }, { id: "desc" }], take, include: { grantor: { select: GRANT_PARTY_SELECT } }, diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index 495777c14..38ec9db5b 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -36,6 +36,8 @@ import { probeOffhostLifecycle, } from "@/lib/jobs/offhost-backup"; import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue"; +import { offhostPurgeDue } from "@/lib/jobs/offhost-purge-queue"; +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { classifyOffhostBackup, OFFHOST_BACKUP_PERIOD_HOURS, @@ -106,7 +108,10 @@ export const GET = apiHandler(async () => { // listing call from a page render would put the operator's credentials on // the request path of every page view. const now = new Date(); + // An account pending deletion is skipped by the nightly upload on purpose; + // its copy ageing is not a backup failure, so it is not listed. const offhostAccounts = await prisma.user.findMany({ + where: ACTIVE_ACCOUNT, select: { id: true, username: true, @@ -175,12 +180,16 @@ export const GET = apiHandler(async () => { // A wipe whose copies are already gone waits for the next nightly run // before its request is removed (see `offhost-purge.ts`); nothing of it is // left in the bucket, so it is not counted as a pending deletion. + // + // A wipe's request held back to the end of its grace window is not a + // pending deletion either: those copies are kept on purpose until then. const stillInBucket = { NOT: { reason: "data_wiped", lastFailure: null, lastAttemptAt: { not: null }, }, + ...offhostPurgeDue(new Date()), }; const [weeklyRunningSince, offhostRunningSince] = await Promise.all([ readQueueRunningSince(DATA_BACKUP_QUEUE), diff --git a/src/app/api/admin/users/[id]/undo-deletion/route.ts b/src/app/api/admin/users/[id]/undo-deletion/route.ts new file mode 100644 index 000000000..fa122c415 --- /dev/null +++ b/src/app/api/admin/users/[id]/undo-deletion/route.ts @@ -0,0 +1,53 @@ +/** + * POST /api/admin/users/[id]/undo-deletion — give back an account pending + * deletion. + * + * Account deletion, and a Guardian's deletion of a managed profile, lock the + * account for a grace window before the purge job erases it + * (`ACCOUNT_DELETION_GRACE_DAYS`). Until then the record, its stored copies + * and its off-host objects are untouched, so undoing it clears the pending + * stamp and nothing else: everything the account held is there as it was. + * Every credential stays revoked; the person signs in again (a managed + * profile is reached through its Guardians, as before). + * + * Cookie-only (`requireAdmin`), audit-logged for the admin and on the account + * itself. Serialised with the purge job on the account row, so an undo either + * lands before the erasure or answers 404 after it. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAdmin } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { cancelAccountDeletion } from "@/lib/account-deletion/lifecycle"; + +export const dynamic = "force-dynamic"; + +export const POST = apiHandler( + async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, + ) => { + const { user: admin } = await requireAdmin(); + const { id } = await params; + annotate({ + action: { + name: "admin.users.undo-deletion", + entity_type: "user", + entity_id: id, + }, + }); + + const outcome = await cancelAccountDeletion({ + userId: id, + adminId: admin.id, + ipAddress: getClientIp(request), + }); + if (outcome === "not_found") return apiError("User not found", 404); + if (outcome === "not_pending") { + return apiError("This account is not pending deletion", 409, { + errorCode: "account.deletion.not_pending", + }); + } + return apiSuccess({ restored: true }); + }, +); diff --git a/src/app/api/admin/users/__tests__/route.test.ts b/src/app/api/admin/users/__tests__/route.test.ts index 4b1038de2..c9ddc4bfd 100644 --- a/src/app/api/admin/users/__tests__/route.test.ts +++ b/src/app/api/admin/users/__tests__/route.test.ts @@ -100,6 +100,8 @@ describe("GET /api/admin/users", () => { // BigInt in Prisma, plain number on the wire. documentQuotaBytes: 2_147_483_648, passkeyCount: 2, + // Not pending deletion. + deletion: null, }); expect(body.data[1].passkeyCount).toBe(0); expect(body.data[1].email).toBeNull(); @@ -123,6 +125,9 @@ describe("GET /api/admin/users", () => { createdAt: true, mfaEnforced: true, documentQuotaBytes: true, + deletionRequestedAt: true, + deletionPurgeAt: true, + deletionRequestedById: true, _count: { select: { passkeys: true } }, }); expect(args.select).not.toHaveProperty("passwordHash"); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 37baaf290..a46dfd455 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -18,6 +18,9 @@ export const GET = apiHandler(async () => { createdAt: true, mfaEnforced: true, documentQuotaBytes: true, + deletionRequestedAt: true, + deletionPurgeAt: true, + deletionRequestedById: true, _count: { select: { passkeys: true } }, }, orderBy: { createdAt: "asc" }, @@ -39,6 +42,20 @@ export const GET = apiHandler(async () => { documentQuotaBytes: u.documentQuotaBytes === null ? null : Number(u.documentQuotaBytes), passkeyCount: u._count.passkeys, + // An account pending deletion: when it was asked for, when the + // purge job erases it, and whether the account asked itself or a + // Guardian deleted the managed profile. Null on every other account. + // The list offers "Undo deletion" on these rows until `purgeAt`. + deletion: u.deletionRequestedAt + ? { + requestedAt: u.deletionRequestedAt, + purgeAt: u.deletionPurgeAt, + requestedBy: + u.deletionRequestedById === u.id + ? ("self" as const) + : ("guardian" as const), + } + : null, })), ); }); diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 6bec4ebf3..9ee262970 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -112,6 +112,8 @@ export const POST = apiHandler(async (request: NextRequest) => { username: true, email: true, onboardingCompletedAt: true, + deletionRequestedAt: true, + deletionPurgeAt: true, totpConfirmedAt: true, totpSecretEncrypted: true, totpLastStep: true, diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 6c46bf27b..d548c9c08 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -102,7 +102,13 @@ export const POST = apiHandler(async (request: NextRequest) => { const user = await prisma.user.findUnique({ where: { id: challenge.userId }, - select: { id: true, username: true, onboardingCompletedAt: true }, + select: { + id: true, + username: true, + onboardingCompletedAt: true, + deletionRequestedAt: true, + deletionPurgeAt: true, + }, }); if (!user) { await recordChallengeFailure(challenge.id); diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts index bc14efe31..606cfba38 100644 --- a/src/app/api/auth/native/token/route.ts +++ b/src/app/api/auth/native/token/route.ts @@ -114,7 +114,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // (5) Load the user (may have been deleted mid-flight). const user = await prisma.user.findUnique({ where: { id: result.userId }, - select: { id: true, username: true, onboardingCompletedAt: true }, + select: { + id: true, + username: true, + onboardingCompletedAt: true, + deletionRequestedAt: true, + deletionPurgeAt: true, + }, }); if (!user) { annotate({ action: { name: "auth.native.token.user_missing" } }); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index fc7a1f6d3..4b7b83ac8 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -47,6 +47,7 @@ import { completeSsoClaim, SSO_CLAIM_LANDING, } from "@/lib/managed-profiles/claim-sso"; +import { pendingDeletionOf } from "@/lib/account-deletion/pending"; import { isHandoverUsable } from "@/lib/managed-profiles/handover"; const LOGIN_ERROR_URL = "/auth/login"; @@ -59,6 +60,51 @@ function errorRedirect(reason: string): NextResponse { return response; } +/** + * The web sign-in page, with the single-use second-factor ticket parked in a + * short-lived cookie its script reads (`OIDC_MFA_COOKIE`). + */ +function secondFactorRedirect( + next: string, + secondFactor: { ticket: string; methods: readonly string[] }, +): NextResponse { + const loginUrl = oidcAppUrl(LOGIN_ERROR_URL); + if (next !== "/") { + loginUrl.searchParams.set("next", next); + } + const response = NextResponse.redirect(loginUrl); + deleteStateCookie(response); + response.cookies.set( + OIDC_MFA_COOKIE, + JSON.stringify({ + ticket: secondFactor.ticket, + methods: secondFactor.methods, + }), + { + // Not httpOnly by design — see the constant's doc comment. + httpOnly: false, + secure: shouldEmitSecureCookie(), + sameSite: "lax", + maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000), + path: OIDC_MFA_COOKIE_PATH, + }, + ); + return response; +} + +/** + * The web sign-in page for an account pending deletion, with the date it is + * erased, so the page can say until when an admin can undo it. + */ +function deletionPendingRedirect(purgeAt: Date | null): NextResponse { + const url = oidcAppUrl(LOGIN_ERROR_URL); + url.searchParams.set("error", "account_deletion_pending"); + if (purgeAt) url.searchParams.set("purgeAt", purgeAt.toISOString()); + const response = NextResponse.redirect(url); + deleteStateCookie(response); + return response; +} + /** * The state cookie is set with `path: "/api/auth/oidc"`; the delete must * repeat that path (RFC 6265 keys cookies by name+domain+path — a bare @@ -305,6 +351,14 @@ export const GET = apiHandler(async (req: NextRequest) => { } const user = account.user; + // An account pending deletion is locked. The identity provider proved + // the caller is this account, so the sign-in page may name the date. + const pendingDeletion = await pendingDeletionOf(user.id); + if (pendingDeletion) { + if (isNative || isClaim) return failRedirect("account_deletion_pending"); + return deletionPendingRedirect(pendingDeletion.purgeAt); + } + const secondFactor = await startOidcSecondFactor(user, ip, isNative); if (secondFactor) { // Native: hand the app the SAME single-use ticket via the custom scheme. @@ -325,28 +379,7 @@ export const GET = apiHandler(async (req: NextRequest) => { return response; } - const loginUrl = oidcAppUrl(LOGIN_ERROR_URL); - if (stored.next !== "/") { - loginUrl.searchParams.set("next", stored.next); - } - const response = NextResponse.redirect(loginUrl); - deleteStateCookie(response); - response.cookies.set( - OIDC_MFA_COOKIE, - JSON.stringify({ - ticket: secondFactor.ticket, - methods: secondFactor.methods, - }), - { - // Not httpOnly by design — see the constant's doc comment. - httpOnly: false, - secure: shouldEmitSecureCookie(), - sameSite: "lax", - maxAge: Math.floor(OIDC_MFA_TTL_MS / 1000), - path: OIDC_MFA_COOKIE_PATH, - }, - ); - return response; + return secondFactorRedirect(stored.next, secondFactor); } const ua = req.headers.get("user-agent"); diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index 623f3235f..b3d480082 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -122,7 +122,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // (9) Load the user (may have been deleted mid-flight). const user = await prisma.user.findUnique({ where: { id: result.userId }, - select: { id: true, username: true, onboardingCompletedAt: true }, + select: { + id: true, + username: true, + onboardingCompletedAt: true, + deletionRequestedAt: true, + deletionPurgeAt: true, + }, }); if (!user) { annotate({ action: { name: "auth.oidc.native.token.user_missing" } }); diff --git a/src/app/api/auth/passkey/login-verify/route.ts b/src/app/api/auth/passkey/login-verify/route.ts index 07f0c6628..76c6487c4 100644 --- a/src/app/api/auth/passkey/login-verify/route.ts +++ b/src/app/api/auth/passkey/login-verify/route.ts @@ -14,6 +14,7 @@ import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { refuseIfPendingDeletion } from "@/lib/account-deletion/pending"; import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token"; import { resolveTokenPolicy, @@ -139,6 +140,10 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("User not found", 404); } + // An account pending deletion is locked; the passkey proved the caller + // could sign in, so the refusal may say why and until when. + refuseIfPendingDeletion(user); + // v1.4.22 — `createSession` anchors the // `hl_onboarding` cookie itself; pass the user's onboarding state // through so the proxy short-circuits the redirect before diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts index 0ce6501e8..ed342be8a 100644 --- a/src/app/api/managed-profiles/[id]/route.ts +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -162,8 +162,12 @@ export const DELETE = apiHandler( const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); const { id } = await params; + let scheduledPurgeAt: Date | null; try { - await deleteManagedProfile({ profileId: id, guardianId: user.id }); + ({ scheduledPurgeAt } = await deleteManagedProfile({ + profileId: id, + guardianId: user.id, + })); } catch (error) { if (error instanceof ManagedProfileLifecycleError) return notFound(); throw error; @@ -171,8 +175,13 @@ export const DELETE = apiHandler( annotate({ action: { name: "managed_profile.delete" }, - meta: { profile_id: id }, + meta: { profile_id: id, deletion_scheduled: scheduledPurgeAt !== null }, + }); + // When the profile is erased; null when it already was. Until then it is + // locked and an admin can undo the deletion. + return apiSuccess({ + deleted: true, + scheduledPurgeAt: scheduledPurgeAt?.toISOString() ?? null, }); - return apiSuccess({ deleted: true }); }, ); diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts index d74b5fc1f..813cf81cd 100644 --- a/src/app/api/notifications/action/[token]/route.ts +++ b/src/app/api/notifications/action/[token]/route.ts @@ -100,12 +100,14 @@ export const POST = apiHandler( const user = await prisma.user.findUnique({ where: { id: verdict.userId }, - select: { id: true, timezone: true }, + select: { id: true, timezone: true, deletionRequestedAt: true }, }); - if (!user) { + // An account pending deletion is locked: a quick answer from a + // notification sent before the deletion records nothing. + if (!user || user.deletionRequestedAt != null) { annotate({ action: { name: "notification.action.refused" }, - meta: { reason: "unknown_user" }, + meta: { reason: user ? "account_deletion_pending" : "unknown_user" }, }); return apiError("Invalid action", 401, { errorCode: "notification.action.invalid", diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 60e06374a..1c1a1f290 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -8,20 +8,33 @@ 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 { kickOffhostPurge } from "@/lib/jobs/offhost-purge"; import { deleteGuardianAccountWithLifecycle, LastManagedGuardianError, } from "@/lib/managed-profiles/lifecycle"; +import { + accountDeletionGraceDays, + accountDeletionPurgeAt, +} from "@/lib/account-deletion/grace"; +import { + eraseAccount, + lockAccountForDeletion, +} from "@/lib/account-deletion/lifecycle"; +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; export const dynamic = "force-dynamic"; /** - * Permanently delete the user account and ALL associated data. - * Cascading deletes in the schema handle all related records. + * Delete the user account and ALL associated data. + * + * With a grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 by default) + * the account is locked rather than erased: every credential is revoked and + * sign-in is refused at once, nothing acts for the account any more, and the + * purge job erases it when the window ends. Until then an admin can undo it, + * and the record, its stored copies and its off-host objects are exactly as + * they were. With the window set to 0 the account is erased here, as before: + * cascading deletes in the schema handle all related records. * * v1.23 — for an account with a second factor active this requires a fresh * step-up (within MFA_STEP_UP_MAX_AGE_SECONDS) in addition to the typed @@ -63,7 +76,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // Prevent last admin from deleting themselves if (user.role === "ADMIN") { - const adminCount = await prisma.user.count({ where: { role: "ADMIN" } }); + // An admin already pending deletion is on their way out and cannot sign + // in: they do not count as the one who stays. + const adminCount = await prisma.user.count({ + where: { role: "ADMIN", ...ACTIVE_ACCOUNT }, + }); if (adminCount <= 1) { return apiError("The last admin account cannot be deleted", 400); } @@ -83,35 +100,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // the elevation would stay redeemable while the erasure was already running. await auth.commitElevation(); + const requestedAt = new Date(); + const purgeAt = accountDeletionPurgeAt( + requestedAt, + accountDeletionGraceDays(), + ); + try { - await deleteGuardianAccountWithLifecycle(userId, async (tx) => { - // The invariant has now passed, so this success event cannot survive a - // refusal. It is purged with the rest of the account's audit history. - await auditLog("user.account.delete", { - userId, - ipAddress: getClientIp(request), - details: { username }, - client: tx, - }); + await deleteGuardianAccountWithLifecycle( + userId, + async (tx) => { + // The invariant has now passed, so this success event cannot survive + // a refusal. On an immediate erasure it is purged with the rest of the + // account's audit history; during a grace window it is the account's + // own record of the request. + await auditLog("user.account.delete", { + userId, + ipAddress: getClientIp(request), + details: { + username, + scheduledPurgeAt: purgeAt?.toISOString() ?? null, + }, + client: tx, + }); - // Destroy all sessions before the user row goes, inside the same record - // lifecycle transaction so a refusal leaves every account state intact. - await tx.session.deleteMany({ where: { userId } }); + if (purgeAt) { + // Locked, not erased: credentials revoked in this transaction, + // the record untouched until the purge job reaches `purgeAt`. + await lockAccountForDeletion(tx, { + userId, + requestedById: userId, + requestedAt, + purgeAt, + }); + return; + } - // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have - // `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login - // city geo) attached to the record after the user is deleted. We explicitly - // purge them inside the same logical operation so account deletion is - // 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 } }); - }); + // No window: erase now, inside the same record lifecycle transaction + // so a refusal leaves every account state intact (sessions, the + // account's audit history — GDPR Art. 17 — the off-host purge + // request, then the row with everything cascading from it). + await eraseAccount(tx, userId, "account_deleted"); + }, + { erasing: purgeAt === null }, + ); } catch (error) { if (error instanceof LastManagedGuardianError) { return apiError( @@ -123,12 +155,18 @@ export const DELETE = apiHandler(async (request: NextRequest) => { throw error; } - await kickOffhostPurge(); + if (!purgeAt) await kickOffhostPurge(); annotate({ action: { name: "settings.account.delete" }, - meta: { username }, + meta: { username, deletion_scheduled: purgeAt !== null }, }); - return apiSuccess({ deleted: true }); + // `deleted` stays true either way: from the person's side the account is + // gone now. `scheduledPurgeAt` says when it is erased, null when it already + // was. + return apiSuccess({ + deleted: true, + scheduledPurgeAt: purgeAt?.toISOString() ?? null, + }); }); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index d18710799..3a4d9f11d 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -25,6 +25,12 @@ import { WIPE_MODELS, } from "@/lib/data-wipe/wipe-plan"; import { stampSyncReset } from "@/lib/sync/reset"; +import { + accountDeletionGraceDays, + accountDeletionPurgeAt, +} from "@/lib/account-deletion/grace"; +import { takePreWipeCopy } from "@/lib/account-deletion/pre-wipe-copy"; +import { logCaught } from "@/lib/logging/signal"; export const dynamic = "force-dynamic"; @@ -85,6 +91,29 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // (a no-op on the cookie path). await auth.commitElevation(); + // With a deletion grace window, the whole record is stored first, + // through the writer the weekly copy uses, as a copy of its own + // (`PRE_WIPE_`). The wipe below leaves it in place, an admin can + // restore it until the window ends, and the off-host copies from before + // the wipe wait for the same date. A copy that cannot be written stops the + // wipe before anything is deleted: an undoable wipe the person was told + // about must not quietly become a final one. + const wipedAt = new Date(); + const purgeAt = accountDeletionPurgeAt(wipedAt, accountDeletionGraceDays()); + let preWipeCopy: { id: string; type: string } | null = null; + if (purgeAt) { + try { + preWipeCopy = await takePreWipeCopy(prisma, userId, wipedAt); + } catch (error) { + logCaught("settings.data.clear.pre_wipe_copy_failed", error); + return apiError( + "The copy kept before deleting could not be written, so nothing was deleted. Try again later or ask your admin.", + 503, + { errorCode: "data_wipe.copy_failed" }, + ); + } + } + let counts: Record; try { counts = await prisma.$transaction( @@ -116,7 +145,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // 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"); + // Held back to the end of the grace window when there is one, so the + // off-host copies from before the wipe stay as long as the copy above. + await requestOffhostPurge(tx, userId, "data_wiped", { + notBefore: purgeAt, + }); // A guardian's wipe ends their grants; a provider app a profile // borrowed from them goes with those grants. @@ -133,6 +166,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => { }, ); } catch (error) { + // Nothing was wiped, so the copy taken for undoing it has no purpose. + if (preWipeCopy) { + await prisma.dataBackup + .delete({ where: { id: preWipeCopy.id } }) + .catch((err) => logCaught("settings.data.clear.copy_cleanup", err)); + } if (error instanceof LastManagedGuardianError) { return apiError( "Add another Guardian or delete the managed profile first", @@ -153,7 +192,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => { await auditLog("user.data.clear", { userId, ipAddress: getClientIp(request), - details: { deletedRows, models: counts }, + details: { + deletedRows, + models: counts, + preWipeCopyId: preWipeCopy?.id ?? null, + restorableUntil: purgeAt?.toISOString() ?? null, + }, }); annotate({ @@ -170,5 +214,12 @@ export const DELETE = apiHandler(async (request: NextRequest) => { invalidateUserData(userId); await kickOffhostPurge(); - return apiSuccess({ cleared: true, deletedRows, models: counts }); + // `restorableUntil`: until when an admin can bring the record back from + // the copy taken first; null when there is no grace window. + return apiSuccess({ + cleared: true, + deletedRows, + models: counts, + restorableUntil: purgeAt?.toISOString() ?? null, + }); }); diff --git a/src/app/api/settings/privacy-summary/route.ts b/src/app/api/settings/privacy-summary/route.ts index f7e8fae74..c9c43f5e0 100644 --- a/src/app/api/settings/privacy-summary/route.ts +++ b/src/app/api/settings/privacy-summary/route.ts @@ -11,6 +11,7 @@ import { apiSuccess } from "@/lib/api-response"; import { getAuditLogRetentionDays } from "@/lib/jobs/audit-log-cleanup"; import { annotate } from "@/lib/logging/context"; import { ENCRYPTED_COLUMNS } from "@/lib/crypto/encrypted-columns"; +import { accountDeletionGraceDays } from "@/lib/account-deletion/grace"; export const dynamic = "force-dynamic"; @@ -39,6 +40,11 @@ function resolveRetention() { auditLogDays: getAuditLogRetentionDays(), // The push-attempt + mood-dispatch ledgers are a fixed 90-day window. deliveryLogDays: 90, + // Account deletion, a data wipe and a managed-profile deletion complete + // after this many days (0: at once). The same resolver the purge job and + // the deletion routes read, so the dialogs and this page state the + // window that is enforced. + accountDeletionGraceDays: accountDeletionGraceDays(), }; } diff --git a/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts b/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts index 93f212a60..70e7e382b 100644 --- a/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts +++ b/src/app/api/whoop/webhook/[token]/__tests__/route.test.ts @@ -166,7 +166,7 @@ describe("POST /api/whoop/webhook/[token]", () => { expect(res.status).toBe(200); expect(prisma.whoopConnection.findUnique).toHaveBeenCalledWith({ where: { whoopUserId: "42" }, - select: { userId: true }, + select: { userId: true, user: { select: { deletionRequestedAt: true } } }, }); // v1.16.16 (iOS #17) — the resource id rides through so the worker can do // a targeted fetch-by-id refresh instead of re-walking the collection. diff --git a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts index d29f7207f..201b429a8 100644 --- a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts @@ -109,7 +109,9 @@ describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { expect(body.status).toBe("ok"); expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { withingsUserId: "wu-1" } }), + expect.objectContaining({ + where: { withingsUserId: "wu-1", user: { deletionRequestedAt: null } }, + }), ); expect(syncUserMeasurements).toHaveBeenCalledTimes(1); expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts index f64a7b267..fe26196ca 100644 --- a/src/app/api/withings/webhook/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -164,7 +164,9 @@ describe("POST /api/withings/webhook", () => { expect(body.status).toBe("ok"); expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { withingsUserId: "wu-1" } }), + expect.objectContaining({ + where: { withingsUserId: "wu-1", user: { deletionRequestedAt: null } }, + }), ); expect(syncUserMeasurements).toHaveBeenCalledTimes(1); expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); diff --git a/src/lib/account-deletion/__tests__/active-account.test.ts b/src/lib/account-deletion/__tests__/active-account.test.ts new file mode 100644 index 000000000..5f693bba3 --- /dev/null +++ b/src/lib/account-deletion/__tests__/active-account.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + activeAccount, + activeAccountSql, + dropPendingDeletion, + ownedByActiveAccount, +} from "../active-account"; + +describe("the shared pending-deletion predicate", () => { + it("narrows a User where without dropping its own conditions", () => { + expect(activeAccount({ moodReminderEnabled: true })).toEqual({ + moodReminderEnabled: true, + deletionRequestedAt: null, + }); + }); + + it("narrows an owned model's user filter instead of replacing it", () => { + expect(ownedByActiveAccount({ enabled: true })).toEqual({ + enabled: true, + user: { deletionRequestedAt: null }, + }); + expect( + ownedByActiveAccount({ user: { cycleProfile: { is: null } } }), + ).toEqual({ + user: { cycleProfile: { is: null }, deletionRequestedAt: null }, + }); + }); + + it("splices only a column reference into SQL", () => { + expect(activeAccountSql('m."user_id"').sql).toContain( + '"pending_deletion"."id" = m."user_id"', + ); + expect(() => activeAccountSql("1; DROP TABLE users")).toThrow(); + }); + + it("drops the pending accounts from a scan's result", async () => { + const findMany = vi.fn(async () => [{ id: "pending" }]); + const db = { user: { findMany } }; + const rows = [{ userId: "pending" }, { userId: "active" }]; + expect(await dropPendingDeletion(db, rows, (r) => r.userId)).toEqual([ + { userId: "active" }, + ]); + // An empty scan asks nothing. + const none: Array<{ userId: string }> = []; + expect(await dropPendingDeletion(db, none, (r) => r.userId)).toEqual([]); + expect(findMany).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/account-deletion/__tests__/grace.test.ts b/src/lib/account-deletion/__tests__/grace.test.ts new file mode 100644 index 000000000..328a1f1df --- /dev/null +++ b/src/lib/account-deletion/__tests__/grace.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + accountDeletionGraceDays, + accountDeletionPurgeAt, + preWipeCopyCutoff, +} from "../grace"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("accountDeletionGraceDays", () => { + it("is 14 days when unset or blank", () => { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", ""); + expect(accountDeletionGraceDays()).toBe(14); + }); + + it("reads a whole number of days, 0 included", () => { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "7"); + expect(accountDeletionGraceDays()).toBe(7); + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", " 0 "); + expect(accountDeletionGraceDays()).toBe(0); + }); + + it("clamps past 30 days rather than obeying", () => { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "365"); + expect(accountDeletionGraceDays()).toBe(30); + }); + + it("falls back to the default on anything that is not a whole number", () => { + for (const raw of ["-1", "1.5", "two", "14d"]) { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", raw); + expect(accountDeletionGraceDays()).toBe(14); + } + }); +}); + +describe("the dates the window gives", () => { + const at = new Date("2026-10-25T10:00:00.000Z"); + + it("erases the window's length after the request, or now", () => { + expect(accountDeletionPurgeAt(at, 14)?.getTime()).toBe( + at.getTime() + 14 * DAY_MS, + ); + expect(accountDeletionPurgeAt(at, 0)).toBeNull(); + }); + + it("keeps a pre-wipe copy for the window and no longer", () => { + expect(preWipeCopyCutoff(at, 14).getTime()).toBe( + at.getTime() - 14 * DAY_MS, + ); + expect(preWipeCopyCutoff(at, 0).getTime()).toBe(at.getTime()); + }); +}); diff --git a/src/lib/account-deletion/active-account.ts b/src/lib/account-deletion/active-account.ts new file mode 100644 index 000000000..b50e724bb --- /dev/null +++ b/src/lib/account-deletion/active-account.ts @@ -0,0 +1,91 @@ +/** + * The one definition of "an account background work may act for". + * + * An account pending deletion (`User.deletionRequestedAt` set) is locked: its + * owner cannot sign in, and nothing may act on their behalf while the grace + * window runs. No reminder, no digest, no Coach nudge, no provider sync, no + * weekly or off-host copy, no rollup or derived score — the record is frozen + * exactly as it was when deletion was asked for, so an undo gives back the + * record as it was and the purge erases nothing that was produced after the + * person said goodbye. + * + * Every cross-account enumeration in background work filters with one of the + * forms below (the constants, or the `activeAccount` / `ownedByActiveAccount` + * wrappers around an existing `where`) (or, for a scan of a table too large to join per row, drops the + * pending accounts from its result with `dropPendingDeletion`), and `src/__tests__/account-deletion-enumeration-guard.test.ts` + * fails on an enumeration that does not. Two chokepoints catch what a missed + * enumeration would still reach: the notification dispatcher refuses delivery + * for a pending account, and the AI capability loader refuses model work for + * one. + */ +import { Prisma } from "@/generated/prisma/client"; + +/** `where` on `User`: the account is not pending deletion. */ +export const ACTIVE_ACCOUNT = { + deletionRequestedAt: null, +} as const satisfies Prisma.UserWhereInput; + +/** + * `where` on a model that belongs to an account through a `user` relation: + * that account is not pending deletion. + */ +export const OWNED_BY_ACTIVE_ACCOUNT = { + user: ACTIVE_ACCOUNT, +} as const; + +/** `where` on `User`, narrowed to accounts not pending deletion. */ +export function activeAccount(where: T) { + return { ...where, ...ACTIVE_ACCOUNT }; +} + +/** + * `where` on a model owned through a `user` relation, narrowed to owners not + * pending deletion. A `user` filter the caller already has is kept and + * narrowed, not replaced. + */ +export function ownedByActiveAccount( + where: T & { user?: object }, +) { + return { ...where, user: { ...where.user, ...ACTIVE_ACCOUNT } }; +} + +/** + * Raw SQL: the account behind `column` (a `users.id` value, such as + * `"user_id"` or `u."id"`) is not pending deletion. `column` is a fixed + * identifier written at the call site, never input. + */ +export function activeAccountSql(column: string): Prisma.Sql { + if (!/^(?:[a-z_]+\.)?"?[a-z_]+"?$/i.test(column)) { + throw new Error(`activeAccountSql: not a column reference: ${column}`); + } + return Prisma.raw( + `NOT EXISTS (SELECT 1 FROM "users" "pending_deletion" WHERE "pending_deletion"."id" = ${column} AND "pending_deletion"."deletion_requested_at" IS NOT NULL)`, + ); +} + +/** + * Drop the accounts pending deletion from the result of a scan over a table + * too large to join `users` into row by row (`measurements`): the scan keeps + * its plan, and the few pending accounts are read once and taken out. + */ +export async function dropPendingDeletion( + db: { + user: { + findMany(args: { + where: { deletionRequestedAt: { not: null } }; + select: { id: true }; + }): Promise>; + }; + }, + rows: readonly T[], + accountOf: (row: T) => string, +): Promise { + if (rows.length === 0) return []; + const pending = await db.user.findMany({ + where: { deletionRequestedAt: { not: null } }, + select: { id: true }, + }); + if (pending.length === 0) return [...rows]; + const drop = new Set(pending.map((row) => row.id)); + return rows.filter((row) => !drop.has(accountOf(row))); +} diff --git a/src/lib/account-deletion/grace.ts b/src/lib/account-deletion/grace.ts new file mode 100644 index 000000000..324524823 --- /dev/null +++ b/src/lib/account-deletion/grace.ts @@ -0,0 +1,56 @@ +/** + * How long a deleted account, a wiped record and a deleted managed profile + * stay recoverable before anything is erased. + * + * Personal health data deleted by a mis-tap was, until v1.46, gone the moment + * the confirmation was pressed: the account row cascaded into every table, + * every stored copy went with it, and the off-host objects were purged in the + * same breath. Nothing short of rolling the whole instance back could bring + * one account back. So the three erasures now wait out a grace window: + * + * - Account deletion (self or a Guardian deleting a managed profile) locks + * the account at once — every credential is revoked and sign-in is + * refused — and the purge job erases it when the window ends. An admin + * can undo it until then. + * - A data wipe takes an account copy first (`PRE_WIPE_`), keeps it for + * the window, and holds the off-host purge back until the window ends. + * + * `ACCOUNT_DELETION_GRACE_DAYS` sets the window. `0` keeps the immediate + * erasure every release before v1.46 performed. The ceiling is 30 days: the + * window exists to recover a mistake, and an erasure the person asked for + * should complete without undue delay, so a larger value is clamped rather + * than obeyed. Anything that is not a whole number falls back to the default. + * + * The privacy summary publishes the resolved value + * (`GET /api/settings/privacy-summary`), so every sentence that names the + * window states the number the purge job acts on. + */ + +const DEFAULT_ACCOUNT_DELETION_GRACE_DAYS = 14; +const MAX_ACCOUNT_DELETION_GRACE_DAYS = 30; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The configured window in whole days; `0` means erase immediately. */ +export function accountDeletionGraceDays(): number { + const raw = process.env.ACCOUNT_DELETION_GRACE_DAYS?.trim(); + if (!raw || !/^\d+$/.test(raw)) return DEFAULT_ACCOUNT_DELETION_GRACE_DAYS; + return Math.min(Number.parseInt(raw, 10), MAX_ACCOUNT_DELETION_GRACE_DAYS); +} + +/** When something asked for at `requestedAt` is erased, or null for now. */ +export function accountDeletionPurgeAt( + requestedAt: Date, + graceDays: number = accountDeletionGraceDays(), +): Date | null { + if (graceDays <= 0) return null; + return new Date(requestedAt.getTime() + graceDays * DAY_MS); +} + +/** The oldest creation time a `PRE_WIPE_` copy may have and still be kept. */ +export function preWipeCopyCutoff( + now: Date, + graceDays: number = accountDeletionGraceDays(), +): Date { + return new Date(now.getTime() - Math.max(graceDays, 0) * DAY_MS); +} diff --git a/src/lib/account-deletion/lifecycle.ts b/src/lib/account-deletion/lifecycle.ts new file mode 100644 index 000000000..713116171 --- /dev/null +++ b/src/lib/account-deletion/lifecycle.ts @@ -0,0 +1,156 @@ +/** + * The three steps of an account deletion with a grace window: lock, undo, + * erase. + * + * - `lockAccountForDeletion` runs inside the transaction that asked for the + * deletion. It stamps the window on the account and revokes every + * credential the account holds, so nothing signs in, refreshes or reads + * through a token from the moment the deletion commits. The record itself, + * its stored copies and its off-host objects are left exactly as they are. + * - `cancelAccountDeletion` is the admin's undo. It clears the stamp and + * nothing else: the record was never touched, so there is nothing to put + * back. Credentials stay revoked; the person signs in again. + * - `eraseAccount` is what the deletion did immediately before v1.46, now run + * by the purge job when the window ends (and still run at once when + * `ACCOUNT_DELETION_GRACE_DAYS=0`). The off-host purge request commits with + * the erasure, as it always did. + */ +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { credentialRevocations } from "@/lib/auth/credential-revocations"; +import { + requestOffhostPurge, + type OffhostPurgeReason, +} from "@/lib/jobs/offhost-purge"; + +type Tx = Prisma.TransactionClient; + +type AccountErasureReason = Extract< + OffhostPurgeReason, + "account_deleted" | "managed_profile_deleted" +>; + +/** + * Lock `userId` for deletion: stamp when it was asked for, when it is + * erased and by whom, and revoke every credential. Call it with the + * transaction that decided the deletion may happen. + */ +export async function lockAccountForDeletion( + tx: Tx, + input: { + userId: string; + requestedById: string; + requestedAt: Date; + purgeAt: Date; + }, +): Promise { + await tx.user.update({ + where: { id: input.userId }, + data: { + deletionRequestedAt: input.requestedAt, + deletionPurgeAt: input.purgeAt, + deletionRequestedById: input.requestedById, + }, + select: { id: true }, + }); + for (const revoke of credentialRevocations(tx, input.userId)) { + await revoke; + } +} + +/** + * Erase `userId` for good: what an account deletion did before the grace + * window, unchanged. The account's own audit history goes with it when the + * account deleted itself (AuditLog rows only `SetNull` on the cascade and + * would keep IP addresses and sign-in places attached to nobody); a managed + * profile's history is kept as it always was, attributed to the Guardians + * who acted on it. + */ +export async function eraseAccount( + tx: Tx, + userId: string, + reason: AccountErasureReason, +): Promise { + if (reason === "account_deleted") { + await tx.session.deleteMany({ where: { userId } }); + 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, reason); + // Every other row the account owns goes through `onDelete: Cascade`. + await tx.user.delete({ where: { id: userId } }); +} + +/** + * Hold the account row for the rest of the transaction and answer whether it + * is still pending deletion and due by `now`. The undo takes the same row + * lock, so an undo and a purge of one account are ordered, never interleaved. + */ +export async function lockPendingAccount( + tx: Tx, + userId: string, +): Promise<{ + pending: boolean; + purgeAt: Date | null; + managedProfile: boolean; +} | null> { + const rows = await tx.$queryRaw< + Array<{ + deletion_requested_at: Date | null; + deletion_purge_at: Date | null; + managed_profile_at: Date | null; + }> + >`SELECT "deletion_requested_at", "deletion_purge_at", "managed_profile_at" + FROM "users" WHERE "id" = ${userId} FOR UPDATE`; + const row = rows[0]; + if (!row) return null; + return { + pending: row.deletion_requested_at !== null, + purgeAt: row.deletion_purge_at, + managedProfile: row.managed_profile_at !== null, + }; +} + +type CancelDeletionOutcome = "cancelled" | "not_found" | "not_pending"; + +/** + * Undo a pending deletion. Audit-logged twice: once for the admin who undid + * it, once on the account itself so its own activity shows what happened. + */ +export async function cancelAccountDeletion(input: { + userId: string; + adminId: string; + ipAddress?: string | null; +}): Promise { + return prisma.$transaction(async (tx) => { + const state = await lockPendingAccount(tx, input.userId); + if (!state) return "not_found"; + if (!state.pending) return "not_pending"; + await tx.user.update({ + where: { id: input.userId }, + data: { + deletionRequestedAt: null, + deletionPurgeAt: null, + deletionRequestedById: null, + }, + select: { id: true }, + }); + await auditLog("admin.user.deletion.undo", { + userId: input.adminId, + ipAddress: input.ipAddress ?? null, + details: { + targetUserId: input.userId, + purgeAt: state.purgeAt?.toISOString() ?? null, + }, + client: tx, + }); + await auditLog("user.account.deletion_cancelled", { + userId: input.userId, + actorUserId: input.adminId, + client: tx, + }); + return "cancelled"; + }); +} diff --git a/src/lib/account-deletion/pending.ts b/src/lib/account-deletion/pending.ts new file mode 100644 index 000000000..bf3ae6528 --- /dev/null +++ b/src/lib/account-deletion/pending.ts @@ -0,0 +1,59 @@ +/** + * Whether an account is pending deletion, asked where it has to be decided: + * at every place that hands out a credential or acts for the account. + */ +import { prisma } from "@/lib/db"; +import { AccountDeletionPendingError } from "@/lib/api-errors"; +import type { Prisma } from "@/generated/prisma/client"; + +type UserReader = Pick; + +interface PendingDeletion { + requestedAt: Date; + /** When the purge job erases the account. */ + purgeAt: Date | null; +} + +/** The pending deletion of `userId`, or null when the account is not pending. */ +export async function pendingDeletionOf( + userId: string, + db: UserReader = prisma, +): Promise { + const row = await db.user.findUnique({ + where: { id: userId }, + select: { deletionRequestedAt: true, deletionPurgeAt: true }, + }); + if (!row?.deletionRequestedAt) return null; + return { requestedAt: row.deletionRequestedAt, purgeAt: row.deletionPurgeAt }; +} + +/** + * Refuse a sign-in to an account pending deletion. + * + * Call it only after the credential was proved, never before: the refusal + * says the account exists and is pending, which is not something to tell a + * caller who has not shown they could sign in to it. Every primitive that + * mints a sign-in (`createSession`, `issueApiToken`) calls it too, so a + * surface that forgets is still refused; the explicit calls in the sign-in + * routes are there to refuse before a second factor is asked for. + */ +export async function assertAccountNotPendingDeletion( + userId: string, + db: UserReader = prisma, +): Promise { + const pending = await pendingDeletionOf(userId, db); + if (pending) throw new AccountDeletionPendingError(pending.purgeAt); +} + +/** + * The same refusal, from an account row the caller already holds, so a + * sign-in route that loaded the whole row asks nothing twice. + */ +export function refuseIfPendingDeletion(account: { + deletionRequestedAt?: Date | null; + deletionPurgeAt?: Date | null; +}): void { + if (account.deletionRequestedAt != null) { + throw new AccountDeletionPendingError(account.deletionPurgeAt ?? null); + } +} diff --git a/src/lib/account-deletion/pre-wipe-copy.ts b/src/lib/account-deletion/pre-wipe-copy.ts new file mode 100644 index 000000000..9de2356eb --- /dev/null +++ b/src/lib/account-deletion/pre-wipe-copy.ts @@ -0,0 +1,36 @@ +/** + * The account copy a data wipe takes before it deletes anything. + * + * "Delete all my data" keeps the account and empties the record. Until v1.46 + * it also deleted every stored copy of the record in the same transaction and + * purged the off-host objects at once, so a wipe pressed by mistake was + * final. Now, with a grace window, the wipe first stores the whole record + * through the same writer the weekly copy uses (`writeAccountCopy`), as a + * copy of its own type, `PRE_WIPE_`: + * + * - its own type, because `DataBackup` is unique on `(userId, type)` and the + * weekly pass overwrites `WEEKLY_AUTO` in place every Sunday; + * - a timestamped type, so a second wipe inside the window (after the + * person recorded a few days of new data) cannot overwrite the copy of + * the record the first wipe emptied. + * + * The wipe leaves these copies alone (`WIPE_KEPT_ROWS`), the admin backups + * page lists them beside the weekly copy, and the ordinary restore brings the + * record back, because the owner still exists. The account-deletion purge job + * deletes each one when its window ends. The off-host purge the wipe asks for + * waits for the same moment (`OffhostPurgeRequest.notBefore`). + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { writeAccountCopy } from "@/lib/export/account-copy"; +import { PRE_WIPE_BACKUP_TYPE_PREFIX } from "./pre-wipe-type"; + +/** Store `userId`'s whole record as a new `PRE_WIPE_` copy. */ +export async function takePreWipeCopy( + prisma: PrismaClient, + userId: string, + takenAt: Date = new Date(), +): Promise<{ id: string; type: string; bytes: number }> { + const type = `${PRE_WIPE_BACKUP_TYPE_PREFIX}${takenAt.getTime()}`; + const { id, bytes } = await writeAccountCopy(prisma, userId, type); + return { id, type, bytes }; +} diff --git a/src/lib/account-deletion/pre-wipe-type.ts b/src/lib/account-deletion/pre-wipe-type.ts new file mode 100644 index 000000000..007c41403 --- /dev/null +++ b/src/lib/account-deletion/pre-wipe-type.ts @@ -0,0 +1,12 @@ +/** + * The `DataBackup.type` of the account copy a data wipe takes first + * (`PRE_WIPE_`, see `pre-wipe-copy.ts`). A leaf module, so the wipe + * plan, the purge job and the admin backups list can name it without loading + * the copy writer. + */ +export const PRE_WIPE_BACKUP_TYPE_PREFIX = "PRE_WIPE_"; + +/** Whether a `DataBackup.type` names a copy taken before a data wipe. */ +export function isPreWipeBackupType(type: string): boolean { + return type.startsWith(PRE_WIPE_BACKUP_TYPE_PREFIX); +} diff --git a/src/lib/ai/capabilities/load.ts b/src/lib/ai/capabilities/load.ts index 5dac29a9e..d619c1c5a 100644 --- a/src/lib/ai/capabilities/load.ts +++ b/src/lib/ai/capabilities/load.ts @@ -81,6 +81,21 @@ function loadActiveConsents( ); } +/** + * Whether the record is pending deletion. No model work is admitted for one: + * it is locked, and nothing acts for it until it is erased or an admin undoes + * the deletion. This is the chokepoint every AI job and route passes, so a + * job that enumerates without the shared filter still starts nothing + * (`src/lib/account-deletion/active-account.ts`). + */ +async function loadRecordDeletionPending(recordId: string): Promise { + const row = await prisma.user.findUnique({ + where: { id: recordId }, + select: { deletionRequestedAt: true }, + }); + return row?.deletionRequestedAt != null; +} + function scopeKey(scope: AiCapabilityScope): string { return [ scope.recordId, @@ -108,6 +123,7 @@ export function loadAiCapabilityInputs( provider, consents, usageLevel, + deletionPending, ] = await Promise.all([ loadAssistantSwitches(), resolveModuleMap(scope.recordId), @@ -115,6 +131,7 @@ export function loadAiCapabilityInputs( probeProviderChain(scope.recordId, scope.authority), loadActiveConsents(scope.recordId), loadRecordUsageLevel(scope.recordId), + loadRecordDeletionPending(scope.recordId), ]); if (switches === null) return null; const { moduleAccess } = buildModuleDisclosure( @@ -125,7 +142,8 @@ export function loadAiCapabilityInputs( return { switches, moduleAccess, - providerWorkAdmitted: mayEnqueueProviderWork(scope.authority), + providerWorkAdmitted: + mayEnqueueProviderWork(scope.authority) && !deletionPending, provider, activeConsentKinds: consents.kinds, fullConsentSince: consents.fullSince, diff --git a/src/lib/api-errors.ts b/src/lib/api-errors.ts index b4145c935..f5bc61906 100644 --- a/src/lib/api-errors.ts +++ b/src/lib/api-errors.ts @@ -20,6 +20,8 @@ import { RECORD_FENCE_ERROR_CODE } from "./sharing/record-session-fence-contract * HttpError without one serialises exactly as before. */ export class HttpError extends Error { + /** Machine fields for the envelope's `meta`, beside `errorCode`. */ + extraMeta?: Record; constructor( public statusCode: number, message: string, @@ -68,8 +70,36 @@ export const AUTH_ERROR_CODES = { * drops a perfectly good session because the database blinked. Retry. */ sessionUnavailable: "auth.session.unavailable", + /** + * The credentials are right, but the account is pending deletion: it is + * locked until the grace window ends and then erased. 403, and the envelope + * carries `meta.purgeAt` so the sign-in screen can name the date. Only an + * admin can undo it, so a client stops retrying and says so. + */ + deletionPending: "auth.account.deletion_pending", } as const; +/** + * Sign-in refused because the account is pending deletion. + * + * Raised only after the credential itself was proved (the password checked, + * the passkey assertion verified, the identity provider answered), so it + * tells nobody anything they could not already sign in to learn. `purgeAt` is + * when the purge job erases the account; null only on a row whose window was + * never stamped, which the purge job treats as due. + */ +export class AccountDeletionPendingError extends HttpError { + constructor(public readonly purgeAt: Date | null) { + super( + 403, + "This account is scheduled for deletion and is locked. An admin can undo the deletion until it completes.", + AUTH_ERROR_CODES.deletionPending, + ); + this.name = "AccountDeletionPendingError"; + this.extraMeta = { purgeAt: purgeAt ? purgeAt.toISOString() : null }; + } +} + /** * The session lookup failed, as opposed to finding nothing. * diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index 060a86062..b7775f259 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -39,6 +39,7 @@ export { // the api-handler ↔ record-session-fence import cycle) and are re-exported // here so the hundreds of existing importers read exactly as before. import { + AccountDeletionPendingError, AUTH_ERROR_CODES, HttpError, SharingAuthError, @@ -49,6 +50,7 @@ import { } from "./api-errors"; export { + AccountDeletionPendingError, AUTH_ERROR_CODES, HttpError, SharingAuthError, @@ -279,7 +281,7 @@ export function apiHandler Promise>( data: null, error: error.message, ...(error.errorCode - ? { meta: { errorCode: error.errorCode } } + ? { meta: { ...error.extraMeta, errorCode: error.errorCode } } : {}), }, { status: error.statusCode }, diff --git a/src/lib/auth/bearer.ts b/src/lib/auth/bearer.ts index 3af3317fe..92383f03e 100644 --- a/src/lib/auth/bearer.ts +++ b/src/lib/auth/bearer.ts @@ -25,7 +25,8 @@ export type BearerAuthReason = | "expired" | "insufficient_permissions" | "undeclared_scope" - | "user_missing"; + | "user_missing" + | "account_deletion_pending"; /** * What a caller demands of a token's scope set. Required on every @@ -159,6 +160,17 @@ export async function resolveBearerToken( ); } + // Deleting an account revokes its tokens in the same transaction; this + // refuses one minted in the moment before that commit. + if (user.deletionRequestedAt != null) { + throw new BearerAuthError( + 401, + "account_deletion_pending", + apiToken.userId, + apiToken.id, + ); + } + // Fire-and-forget: refresh lastUsedAt without blocking the caller. prisma.apiToken .update({ diff --git a/src/lib/auth/credential-revocations.ts b/src/lib/auth/credential-revocations.ts new file mode 100644 index 000000000..459936dd4 --- /dev/null +++ b/src/lib/auth/credential-revocations.ts @@ -0,0 +1,61 @@ +/** + * The statements that end every credential an account holds. + * + * A leaf module, so the account-deletion lock can run the same list inside + * its own transaction without importing the session module (and with it + * `next/headers`). `destroyAllSessions` runs it as a batch. + */ +import type { Prisma } from "@/generated/prisma/client"; + +type CredentialStore = Pick< + Prisma.TransactionClient, + | "session" + | "apiToken" + | "refreshToken" + | "trustedDevice" + | "mcpOAuthConnection" + | "clinicianShareLink" + | "stepUpElevation" +>; + +/** + * The statements that end every credential `userId` holds, unexecuted, so + * the same list runs as `destroyAllSessions`' batch and inside a caller's own + * transaction (account deletion locks the account with them). Each is a lazy + * Prisma promise: nothing runs until it is awaited or batched. + */ +export function credentialRevocations( + db: CredentialStore, + userId: string, +): Prisma.PrismaPromise[] { + const now = new Date(); + return [ + db.session.deleteMany({ where: { userId } }), + db.apiToken.updateMany({ + where: { userId, revoked: false }, + data: { revoked: true }, + }), + db.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: now }, + }), + // A credential rotation also kills every "remember this device" + // trusted-device token: a stolen device cookie must not survive a + // password change or account-credential remediation. + db.trustedDevice.deleteMany({ where: { userId } }), + // A connected AI assistant holds a refresh chain of its own: revoking its + // access tokens above left the connection able to mint the next one. + db.mcpOAuthConnection.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: now }, + }), + // A clinician share link opens the record without signing in. Whoever + // held the old password could have made one, and it would outlive the + // change; the owner makes a fresh link for the people who should keep it. + db.clinicianShareLink.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: now }, + }), + db.stepUpElevation.deleteMany({ where: { userId } }), + ]; +} diff --git a/src/lib/auth/guards/record.ts b/src/lib/auth/guards/record.ts index 44010e554..3e0de60dc 100644 --- a/src/lib/auth/guards/record.ts +++ b/src/lib/auth/guards/record.ts @@ -351,6 +351,11 @@ async function resolveSwitchedRecord( // is deleted with it. Fail closed anyway: an authorization we cannot resolve // to a person is not one to act on. if (!owner) throw denied("owner_missing"); + // A record pending deletion is locked for everyone, its Guardians and + // delegates included, until an admin undoes the deletion or it is erased. + if (owner.deletionRequestedAt != null) { + throw denied("owner_deletion_pending"); + } const refusal = admit(grant, owner); if (refusal !== null) throw denied(refusal); diff --git a/src/lib/auth/login-response.ts b/src/lib/auth/login-response.ts index 6a0c71ff4..960a9cd44 100644 --- a/src/lib/auth/login-response.ts +++ b/src/lib/auth/login-response.ts @@ -31,9 +31,17 @@ import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie"; import { annotate } from "@/lib/logging/context"; import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link"; import type { User } from "@/generated/prisma/client"; +import { refuseIfPendingDeletion } from "@/lib/account-deletion/pending"; export interface FinishLoginParams { - user: Pick; + user: Pick< + User, + | "id" + | "username" + | "onboardingCompletedAt" + | "deletionRequestedAt" + | "deletionPurgeAt" + >; request: NextRequest; ip: string; userAgent: string | null; @@ -58,6 +66,12 @@ export async function finishLogin( ): Promise { const { user, request, ip, userAgent, source, mfaVerified } = params; + // Every sign-in completes here (password, second factor, the native and + // single sign-on token exchanges), so no credential leaves for an account + // pending deletion. A credential minted in the moment before a deletion + // commits is refused on its first use by the session and Bearer resolvers. + refuseIfPendingDeletion(user); + // v1.23 — new-device / new-location alert. Fire-and-forget: never let the // device ledger or the dispatcher add latency to — or fail — a sign-in. Runs // for every transport (web cookie + native bundle) since the factor checks diff --git a/src/lib/auth/mfa/__tests__/challenge.test.ts b/src/lib/auth/mfa/__tests__/challenge.test.ts index 141a1607e..47041d747 100644 --- a/src/lib/auth/mfa/__tests__/challenge.test.ts +++ b/src/lib/auth/mfa/__tests__/challenge.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; +/** The account row the pending-deletion check reads. */ +const accountRow = vi.hoisted(() => ({ + current: { + deletionRequestedAt: null as Date | null, + deletionPurgeAt: null as Date | null, + }, +})); + vi.mock("@/lib/db", () => ({ prisma: { mfaChallenge: { @@ -8,6 +16,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), updateMany: vi.fn(), }, + user: { findUnique: vi.fn(async () => accountRow.current) }, }, })); @@ -29,6 +38,7 @@ import { prisma } from "@/lib/db"; beforeEach(() => { vi.clearAllMocks(); + accountRow.current = { deletionRequestedAt: null, deletionPurgeAt: null }; }); function activeRow(overrides: Record = {}) { @@ -55,6 +65,20 @@ describe("mfa challenge", () => { expect(JSON.stringify(arg)).not.toContain(ticket); }); + it("asks no second factor of an account pending deletion", async () => { + const purgeAt = new Date("2026-11-08T10:00:00.000Z"); + accountRow.current = { + deletionRequestedAt: new Date("2026-10-25T10:00:00.000Z"), + deletionPurgeAt: purgeAt, + }; + await expect(createMfaChallenge("user-1", "login")).rejects.toMatchObject({ + statusCode: 403, + errorCode: "auth.account.deletion_pending", + extraMeta: { purgeAt: purgeAt.toISOString() }, + }); + expect(prisma.mfaChallenge.create).not.toHaveBeenCalled(); + }); + it("loads an active challenge by hash", async () => { vi.mocked(prisma.mfaChallenge.findUnique).mockResolvedValue( activeRow() as never, diff --git a/src/lib/auth/mfa/challenge.ts b/src/lib/auth/mfa/challenge.ts index a816bc4c0..e020ab3a6 100644 --- a/src/lib/auth/mfa/challenge.ts +++ b/src/lib/auth/mfa/challenge.ts @@ -26,6 +26,7 @@ import { randomBytes } from "node:crypto"; import { hashToken } from "@/lib/auth/hmac"; import { prisma } from "@/lib/db"; +import { assertAccountNotPendingDeletion } from "@/lib/account-deletion/pending"; /** Login step-up: the only `kind` Phase M exercises. */ export type MfaChallengeKind = "login"; @@ -46,6 +47,10 @@ export async function createMfaChallenge( userId: string, kind: MfaChallengeKind, ): Promise { + // No second factor is asked of an account pending deletion: the caller has + // proved the first one, so the refusal may name the date (403 + // `auth.account.deletion_pending`) instead of costing them a code. + await assertAccountNotPendingDeletion(userId); const ticket = randomBytes(32).toString("base64url"); const expiresAt = new Date(Date.now() + MFA_CHALLENGE_TTL_MS); await prisma.mfaChallenge.create({ diff --git a/src/lib/auth/session-user.ts b/src/lib/auth/session-user.ts index b665542c7..5622285a1 100644 --- a/src/lib/auth/session-user.ts +++ b/src/lib/auth/session-user.ts @@ -38,6 +38,9 @@ export const SESSION_USER_SELECT = { createdAt: true, updatedAt: true, managedProfileAt: true, + // Pending deletion: the session and Bearer resolvers refuse the account, + // and the acting-account resolver refuses it as a record. + deletionRequestedAt: true, heightCm: true, dateOfBirth: true, gender: true, diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 6fdfea5c3..7224dee51 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -8,6 +8,7 @@ import { getEvent } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { isInputRefusal, isP2025 } from "@/lib/prisma-errors"; import { SessionStoreUnavailableError } from "@/lib/api-errors"; +import { credentialRevocations } from "@/lib/auth/credential-revocations"; import { locales, type Locale } from "@/lib/i18n/config"; import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie"; @@ -132,6 +133,22 @@ async function findSessionByCookie(cookieValue: string) { } } +/** + * A session row that may answer for its user: it exists, has not expired, and + * its account is not pending deletion. Deleting an account revokes every + * session in the same transaction; this refuses one minted in the moment + * between a sign-in's own check and that commit, so the lock holds without + * relying on the order of two transactions. + */ +function isUsableSession(session: { + expiresAt: Date; + user: { deletionRequestedAt: Date | null }; +}): boolean { + return ( + session.expiresAt >= new Date() && session.user.deletionRequestedAt == null + ); +} + /** * Validate a session straight from a raw cookie value — no `next/headers`, * no sliding-expiry write, no cookie mutation. Runs the same hardened lookup @@ -154,7 +171,7 @@ export async function validateSessionFromCookieValue( } | null> { if (!cookieValue) return null; const session = await findSessionByCookie(cookieValue); - if (!session || session.expiresAt < new Date()) return null; + if (!session || !isUsableSession(session)) return null; return { session: { id: session.id, expiresAt: session.expiresAt }, user: session.user, @@ -186,7 +203,7 @@ export async function validateSessionWithCreatedAt( } | null> { if (!cookieValue) return null; const session = await findSessionByCookie(cookieValue); - if (!session || session.expiresAt < new Date()) return null; + if (!session || !isUsableSession(session)) return null; return { session: { id: session.id, @@ -317,7 +334,7 @@ export async function getSession(): Promise<{ const session = await findSessionByCookie(cookieValue); - if (!session || session.expiresAt < new Date()) { + if (!session || !isUsableSession(session)) { if (session) { await prisma.session .deleteMany({ where: { id: session.id } }) @@ -491,7 +508,7 @@ export async function getSessionUserLocale(): Promise { // layout where the cookie store cannot be written, so there is nothing to // refresh or clear here. const session = await findSessionByCookie(cookieValue); - if (!session || session.expiresAt < new Date()) return null; + if (!session || !isUsableSession(session)) return null; return session.user.locale; } @@ -550,35 +567,7 @@ export async function destroySession(): Promise { * survives — the rows linger as evidence of the rotation event. */ export async function destroyAllSessions(userId: string): Promise { - await prisma.$transaction([ - prisma.session.deleteMany({ where: { userId } }), - prisma.apiToken.updateMany({ - where: { userId, revoked: false }, - data: { revoked: true }, - }), - prisma.refreshToken.updateMany({ - where: { userId, revokedAt: null }, - data: { revokedAt: new Date() }, - }), - // v1.23 — a credential rotation also kills every "remember this device" - // trusted-device token: a stolen device cookie must not survive a - // password change or account-credential remediation. - prisma.trustedDevice.deleteMany({ where: { userId } }), - // A connected AI assistant holds a refresh chain of its own: revoking its - // access tokens above left the connection able to mint the next one. - prisma.mcpOAuthConnection.updateMany({ - where: { userId, revokedAt: null }, - data: { revokedAt: new Date() }, - }), - // A clinician share link opens the record without signing in. Whoever - // held the old password could have made one, and it would outlive the - // change; the owner makes a fresh link for the people who should keep it. - prisma.clinicianShareLink.updateMany({ - where: { userId, revokedAt: null }, - data: { revokedAt: new Date() }, - }), - prisma.stepUpElevation.deleteMany({ where: { userId } }), - ]); + await prisma.$transaction(credentialRevocations(prisma, userId)); } /** diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 92cead915..b84dc061f 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -35,6 +35,7 @@ * test asserts that property rather than trusting the author. */ import { Prisma } from "@/generated/prisma/client"; +import { PRE_WIPE_BACKUP_TYPE_PREFIX } from "@/lib/account-deletion/pre-wipe-type"; /** * Every user-scoped model this action deletes, children before parents. @@ -540,6 +541,12 @@ export const USER_KEPT_FIELDS: Readonly> = { "a per-account storage quota set by the operator, not by the person", syncResetAt: "sync bookkeeping, not record content: the wipe sets it to its own time rather than clearing it, so a paired client's delta cursor from before the wipe is refused and the client starts over", + deletionRequestedAt: + "the account's deletion lifecycle, not record content; an account pending deletion is locked and cannot ask for a wipe, so this is null whenever one runs", + deletionPurgeAt: + "the account's deletion lifecycle, not record content; null whenever a wipe runs, for the same reason", + deletionRequestedById: + "the account's deletion lifecycle, not record content; null whenever a wipe runs, for the same reason", }; /** @@ -623,10 +630,31 @@ export function wipeDelegateKey(model: string): string { */ export function wipeWhere(model: string, userId: string): object { const fields = WIPE_OWNER_FIELDS[model]; - if (!fields) return { userId }; - return { OR: fields.map((field) => ({ [field]: userId })) }; + const owned = fields + ? { OR: fields.map((field) => ({ [field]: userId })) } + : { userId }; + const kept = WIPE_KEPT_ROWS[model]; + return kept ? { AND: [owned, kept.keep] } : owned; } +/** + * Rows of a wiped model the per-account wipe leaves in place, each with the + * reason. Unlike {@link WIPE_EXEMPT}, which spares a whole model, this spares + * named rows of a model whose every other row goes. + * + * The admin wipe of every account ignores it: the operator clearing the host + * asked for nothing to stay. + */ +export const WIPE_KEPT_ROWS: Readonly< + Record +> = { + DataBackup: { + keep: { NOT: { type: { startsWith: PRE_WIPE_BACKUP_TYPE_PREFIX } } }, + reason: + "the account copies taken before a wipe (`PRE_WIPE_`), the current one included: it is written immediately before this transaction so the wipe can be undone during the deletion grace window, and the account-deletion purge job deletes each one when its window ends", + }, +}; + /** The narrow slice of a Prisma delegate the wipe loop uses. */ export interface UserScopedDeleteDelegate { deleteMany(args: { where: { userId: string } }): Promise<{ count: number }>; @@ -674,7 +702,7 @@ export function resolveWipeDelegate( model: string, ): UserScopedDeleteDelegate { const delegate = rawDelegate(client, model); - if (!WIPE_OWNER_FIELDS[model]) { + if (!WIPE_OWNER_FIELDS[model] && !WIPE_KEPT_ROWS[model]) { return delegate as UserScopedDeleteDelegate; } return { diff --git a/src/lib/export/account-copy.ts b/src/lib/export/account-copy.ts new file mode 100644 index 000000000..a1db30e5f --- /dev/null +++ b/src/lib/export/account-copy.ts @@ -0,0 +1,50 @@ +/** + * 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, + }); + }, + ); +} diff --git a/src/lib/export/account-settings-classes.ts b/src/lib/export/account-settings-classes.ts index 0e3064b4e..436ed21b4 100644 --- a/src/lib/export/account-settings-classes.ts +++ b/src/lib/export/account-settings-classes.ts @@ -62,6 +62,15 @@ export const USER_COLUMNS_OUTSIDE_SETTINGS = { mfaEnforced: "IDENTITY", documentQuotaBytes: "IDENTITY", + // The account's deletion lifecycle on this host: when deletion was asked + // for, when the purge job erases the account, and who asked. A copy must + // never carry it: restoring a file taken from a pending account would lock + // the account it lands in, and one taken before would undo nothing an admin + // did not mean to undo. + deletionRequestedAt: "OPERATIONAL", + deletionPurgeAt: "OPERATIONAL", + deletionRequestedById: "OPERATIONAL", + // ── Credentials ─────────────────────────────────────────────────────────── passwordHash: "CREDENTIAL", oidcIssuer: "CREDENTIAL", diff --git a/src/lib/integrations/watchdog.ts b/src/lib/integrations/watchdog.ts index 22b161c9b..a5d890469 100644 --- a/src/lib/integrations/watchdog.ts +++ b/src/lib/integrations/watchdog.ts @@ -20,6 +20,10 @@ * own alerts do), a reason and an instant. Never a token, a value or an error * message from the provider. */ +import { + ACTIVE_ACCOUNT, + OWNED_BY_ACTIVE_ACCOUNT, +} from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import type { MeasurementSource } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; @@ -114,15 +118,27 @@ async function countDeliveredDays( async function listWatchedUserIds(): Promise { const [withings, whoop, fitbit, google, userRows, healthKit, watches] = await Promise.all([ - prisma.withingsConnection.findMany({ select: { userId: true } }), + // An account pending deletion is frozen: its connections are not + // synced, so their silence is not an incident. + prisma.withingsConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, + select: { userId: true }, + }), prisma.whoopConnection.findMany({ - where: { whoopUserId: { not: null } }, + where: { whoopUserId: { not: null }, ...OWNED_BY_ACTIVE_ACCOUNT }, + select: { userId: true }, + }), + prisma.fitbitConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, + select: { userId: true }, + }), + prisma.googleHealthConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }), - prisma.fitbitConnection.findMany({ select: { userId: true } }), - prisma.googleHealthConnection.findMany({ select: { userId: true } }), prisma.user.findMany({ where: { + ...ACTIVE_ACCOUNT, OR: [ { polarAccessTokenEncrypted: { not: null } }, { ouraAccessTokenEncrypted: { not: null } }, @@ -135,6 +151,7 @@ async function listWatchedUserIds(): Promise { select: { id: true }, }), prisma.healthKitTypeSync.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, distinct: ["userId"], select: { userId: true }, }), @@ -144,6 +161,7 @@ async function listWatchedUserIds(): Promise { { silentSince: { not: null } }, { incidentReason: { not: null } }, ], + ...OWNED_BY_ACTIVE_ACCOUNT, }, select: { userId: true }, }), diff --git a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts index f4d56ca51..4b298d6b9 100644 --- a/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts +++ b/src/lib/jobs/__tests__/google-health-sleep-repair.test.ts @@ -104,7 +104,9 @@ describe("enqueueBootTimeGoogleHealthSleepRepair — discovery", () => { const result = await enqueueBootTimeGoogleHealthSleepRepair(); expect(prismaMock.googleHealthConnection.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { sleepRepairedAt: null } }), + expect.objectContaining({ + where: { sleepRepairedAt: null, user: { deletionRequestedAt: null } }, + }), ); expect(result.enqueued).toBe(2); expect(bossSend).toHaveBeenCalledWith( diff --git a/src/lib/jobs/__tests__/medication-reminder-check.test.ts b/src/lib/jobs/__tests__/medication-reminder-check.test.ts index f4a9fa453..5126e9765 100644 --- a/src/lib/jobs/__tests__/medication-reminder-check.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-check.test.ts @@ -189,10 +189,12 @@ describe("handleReminderCheck — as-needed skip (v1.16.11, #316)", () => { where: Record; }; // v1.39.1 (#1033) — nor a medication with intake tracking off. + // Nor one whose account is pending deletion. expect(args.where).toEqual({ active: true, asNeeded: false, trackIntake: true, + user: { deletionRequestedAt: null }, }); }); diff --git a/src/lib/jobs/__tests__/mood-reminder.test.ts b/src/lib/jobs/__tests__/mood-reminder.test.ts index f80785e23..7e21e627c 100644 --- a/src/lib/jobs/__tests__/mood-reminder.test.ts +++ b/src/lib/jobs/__tests__/mood-reminder.test.ts @@ -387,7 +387,9 @@ describe("runMoodReminderTick", () => { }); expect(prisma.user.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { moodReminderEnabled: true } }), + expect.objectContaining({ + where: { moodReminderEnabled: true, deletionRequestedAt: null }, + }), ); }); diff --git a/src/lib/jobs/__tests__/period-narrative-warm.test.ts b/src/lib/jobs/__tests__/period-narrative-warm.test.ts index 8bdf1bb09..91a8eaf93 100644 --- a/src/lib/jobs/__tests__/period-narrative-warm.test.ts +++ b/src/lib/jobs/__tests__/period-narrative-warm.test.ts @@ -112,7 +112,10 @@ describe("findNarrativeCandidates", () => { const result = await findNarrativeCandidates(prisma as any, 50); expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); const arg = findMany.mock.calls[0][0]; - expect(arg.where).toEqual({ id: { notIn: ["opted-out"] } }); + expect(arg.where).toEqual({ + deletionRequestedAt: null, + id: { notIn: ["opted-out"] }, + }); expect(arg.where.disableCoach).toBeUndefined(); expect(result).toEqual([{ id: "u1", locale: "de" }]); // Below the cap the ordering aggregate never runs. diff --git a/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts b/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts index bc6294402..f24f3a06a 100644 --- a/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts +++ b/src/lib/jobs/__tests__/sleep-timeline-backfill.test.ts @@ -71,10 +71,20 @@ describe("enqueueBootTimeSleepTimelineBackfill — discovery", () => { const result = await enqueueBootTimeSleepTimelineBackfill(); expect(prismaMock.whoopConnection.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }), + expect.objectContaining({ + where: { + sleepTimelineBackfillAt: null, + user: { deletionRequestedAt: null }, + }, + }), ); expect(prismaMock.withingsConnection.findMany).toHaveBeenCalledWith( - expect.objectContaining({ where: { sleepTimelineBackfillAt: null } }), + expect.objectContaining({ + where: { + sleepTimelineBackfillAt: null, + user: { deletionRequestedAt: null }, + }, + }), ); expect(result.enqueued).toBe(2); expect(bossSend).toHaveBeenCalledWith( diff --git a/src/lib/jobs/__tests__/status-cron-candidates.test.ts b/src/lib/jobs/__tests__/status-cron-candidates.test.ts index b912a4c27..a8d6cc328 100644 --- a/src/lib/jobs/__tests__/status-cron-candidates.test.ts +++ b/src/lib/jobs/__tests__/status-cron-candidates.test.ts @@ -100,7 +100,10 @@ describe("findStatusCronCandidates — gates", () => { const { prisma, findMany } = makePrisma([userRow()]); // eslint-disable-next-line @typescript-eslint/no-explicit-any await findStatusCronCandidates(prisma as any, NOW); - expect(findMany.mock.calls[0][0].where).toEqual({}); + // Only the accounts pending deletion are left out. + expect(findMany.mock.calls[0][0].where).toEqual({ + deletionRequestedAt: null, + }); expect(JSON.stringify(findMany.mock.calls[0][0])).not.toContain( "disableCoach", ); @@ -117,6 +120,7 @@ describe("findStatusCronCandidates — gates", () => { // The module key rides as a bound parameter, never spliced. expect(queryRaw.mock.calls[0].slice(1)).toContain("insights"); expect(findMany.mock.calls[0][0].where).toEqual({ + deletionRequestedAt: null, id: { notIn: ["opted-out-a", "opted-out-b"] }, }); expect(result).toEqual([{ id: "kept", locale: "de" }]); diff --git a/src/lib/jobs/__tests__/whoop-backfill.test.ts b/src/lib/jobs/__tests__/whoop-backfill.test.ts index 7cb6404e4..7c7b7f7ff 100644 --- a/src/lib/jobs/__tests__/whoop-backfill.test.ts +++ b/src/lib/jobs/__tests__/whoop-backfill.test.ts @@ -62,7 +62,10 @@ describe("enqueueBootTimeWhoopBackfill — discovery", () => { expect(prismaMock.whoopConnection.findMany).toHaveBeenCalledWith( expect.objectContaining({ - where: { backfillCompletedAt: null }, + where: { + backfillCompletedAt: null, + user: { deletionRequestedAt: null }, + }, }), ); expect(result.enqueued).toBe(2); diff --git a/src/lib/jobs/__tests__/withings-subscription-retry.test.ts b/src/lib/jobs/__tests__/withings-subscription-retry.test.ts index cd0d0eb04..9abf2ac39 100644 --- a/src/lib/jobs/__tests__/withings-subscription-retry.test.ts +++ b/src/lib/jobs/__tests__/withings-subscription-retry.test.ts @@ -137,7 +137,10 @@ describe("handleWithingsFallbackSync subscription repair", () => { }, }); - expect(findMany).toHaveBeenCalledWith({ select: { userId: true } }); + expect(findMany).toHaveBeenCalledWith({ + where: { user: { deletionRequestedAt: null } }, + select: { userId: true }, + }); expect(syncUserMeasurements).toHaveBeenNthCalledWith( 1, "repair-failed-user", diff --git a/src/lib/jobs/account-deletion-purge-queue.ts b/src/lib/jobs/account-deletion-purge-queue.ts new file mode 100644 index 000000000..d15c27994 --- /dev/null +++ b/src/lib/jobs/account-deletion-purge-queue.ts @@ -0,0 +1,12 @@ +/** + * The queue facts of `account-deletion-purge.ts`, apart from the job so the + * worker registry can read them without loading it. + */ +export const ACCOUNT_DELETION_PURGE_QUEUE = "account-deletion-purge"; + +/** + * Hourly at :23, so an account is erased within the hour its grace window + * ends, and a copy taken before a data wipe leaves within the hour its + * window does. + */ +export const ACCOUNT_DELETION_PURGE_CRON = "23 * * * *"; diff --git a/src/lib/jobs/account-deletion-purge.ts b/src/lib/jobs/account-deletion-purge.ts new file mode 100644 index 000000000..9bb6377e1 --- /dev/null +++ b/src/lib/jobs/account-deletion-purge.ts @@ -0,0 +1,203 @@ +/** + * The end of the deletion grace window. + * + * Hourly, this erases every account whose window has ended + * (`User.deletionPurgeAt`) exactly as the immediate deletion did before v1.46 + * (`eraseAccount`): the row goes, everything it owns cascades, and the + * off-host purge request commits with it and is kicked at once. It also + * deletes the copies data wipes took (`PRE_WIPE_`) whose window has + * ended. The off-host purge a wipe asked for needs nothing from here: its + * request carries the same date (`notBefore`) and the off-host purge job + * picks it up once that has passed. + * + * Each account is decided again inside its own transaction, under the row + * lock the admin's undo takes, so an undo that lands while this runs either + * wins (the account is no longer pending and is skipped) or loses (the + * account is already gone and the undo answers 404). The Guardian invariant + * is decided again too: a Guardian whose last co-Guardian left during the + * window would leave a managed profile nobody can reach, so that erasure is + * refused, the account stays locked and pending, and the run reports it for + * the operator to settle. + */ +import type { Job } from "pg-boss"; + +import { prisma } from "@/lib/db"; +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { kickOffhostPurge } from "@/lib/jobs/offhost-purge"; +import { reportWorkerError } from "@/lib/jobs/report-worker-error"; +import { withBackgroundEvent } from "@/lib/logging/background"; +import { + ACCOUNT_DELETION_TRANSACTION_OPTIONS, + deleteGuardianAccountWithLifecycle, + LastManagedGuardianError, + withManagedProfileLock, +} from "@/lib/managed-profiles/lifecycle"; +import { + eraseAccount, + lockPendingAccount, +} from "@/lib/account-deletion/lifecycle"; +import { preWipeCopyCutoff } from "@/lib/account-deletion/grace"; +import { PRE_WIPE_BACKUP_TYPE_PREFIX } from "@/lib/account-deletion/pre-wipe-type"; +import { ACCOUNT_DELETION_PURGE_QUEUE } from "./account-deletion-purge-queue"; + +/** Accounts one run erases; the next run takes the rest. */ +const ACCOUNTS_PER_RUN = 50; + +interface AccountDeletionPurgeReport { + due: number; + purged: number; + /** Erasures refused by the Guardian invariant; the accounts stay pending. */ + refused: number; + preWipeCopiesDeleted: number; +} + +/** Thrown inside an erasure transaction to roll it back without erasing. */ +class NoLongerDue extends Error { + constructor() { + super("account is no longer pending deletion or not yet due"); + this.name = "NoLongerDue"; + } +} + +function isDue( + state: { pending: boolean; purgeAt: Date | null } | null, + now: Date, +): boolean { + return ( + state !== null && + state.pending && + (state.purgeAt === null || state.purgeAt <= now) + ); +} + +/** Erase one account whose window ended. False when it no longer was due. */ +async function purgeAccount( + userId: string, + managedProfile: boolean, + now: Date, +): Promise { + try { + if (managedProfile) { + // A managed profile is erased on its own lock, as its deletion was + // decided; it guards no Guardian invariant of its own. + await prisma.$transaction( + (tx) => + withManagedProfileLock(tx, userId, async () => { + if (!isDue(await lockPendingAccount(tx, userId), now)) { + throw new NoLongerDue(); + } + await eraseAccount(tx, userId, "managed_profile_deleted"); + }), + ACCOUNT_DELETION_TRANSACTION_OPTIONS, + ); + } else { + await deleteGuardianAccountWithLifecycle( + userId, + async (tx) => { + if (!isDue(await lockPendingAccount(tx, userId), now)) { + throw new NoLongerDue(); + } + await eraseAccount(tx, userId, "account_deleted"); + }, + { erasing: true }, + ); + } + return true; + } catch (error) { + if (error instanceof NoLongerDue) return false; + throw error; + } +} + +/** + * Delete the `PRE_WIPE_` copies whose window has ended, except one an + * unfinished restore is reading: that restore is the window being used. + */ +async function deleteExpiredPreWipeCopies(now: Date): Promise { + // account-deletion: restore jobs, not accounts. + const active = await prisma.backupRestoreJob.findMany({ + where: { status: { in: ["queued", "running"] } }, + select: { backupId: true }, + }); + const { count } = await prisma.dataBackup.deleteMany({ + where: { + type: { startsWith: PRE_WIPE_BACKUP_TYPE_PREFIX }, + createdAt: { lt: preWipeCopyCutoff(now) }, + id: { notIn: active.map((job) => job.backupId) }, + }, + }); + return count; +} + +export async function processDueAccountDeletions( + now: Date = new Date(), +): Promise { + // account-deletion: the purge's own query: exactly the accounts pending deletion. + const due = await prisma.user.findMany({ + where: { + deletionRequestedAt: { not: null }, + OR: [{ deletionPurgeAt: null }, { deletionPurgeAt: { lte: now } }], + }, + select: { id: true, managedProfileAt: true }, + orderBy: { deletionPurgeAt: "asc" }, + take: ACCOUNTS_PER_RUN, + }); + + const report: AccountDeletionPurgeReport = { + due: due.length, + purged: 0, + refused: 0, + preWipeCopiesDeleted: 0, + }; + for (const account of due) { + try { + if ( + await purgeAccount(account.id, account.managedProfileAt !== null, now) + ) { + report.purged++; + } + } catch (error) { + if (!(error instanceof LastManagedGuardianError)) throw error; + report.refused++; + } + } + report.preWipeCopiesDeleted = await deleteExpiredPreWipeCopies(now); + + // The off-host copies of every erased account, now rather than at the + // nightly slot. The requests committed with the erasures. + if (report.purged > 0) await kickOffhostPurge(); + return report; +} + +export async function handleAccountDeletionPurge( + jobs: Job[], +): Promise { + void jobs; + return withBackgroundEvent("job.account_deletion_purge", async (evt) => { + try { + const report = await processDueAccountDeletions(); + evt.addMeta("account_deletions_due", report.due); + evt.addMeta("account_deletions_purged", report.purged); + evt.addMeta("account_deletions_refused", report.refused); + evt.addMeta("pre_wipe_copies_deleted", report.preWipeCopiesDeleted); + if (report.refused > 0) { + await reportWorkerError( + ACCOUNT_DELETION_PURGE_QUEUE, + new Error( + `${report.refused} account deletion(s) could not complete: the account is the last active Guardian of a managed profile. The account stays locked and pending; add another Guardian to the profile or delete the profile, or undo the deletion in the admin users list.`, + ), + ); + } + return jobDone({ + account_deletions_due: report.due, + account_deletions_purged: report.purged, + account_deletions_refused: report.refused, + pre_wipe_copies_deleted: report.preWipeCopiesDeleted, + }); + } catch (err) { + evt.addWarning(`account-deletion-purge failed: ${err}`); + await reportWorkerError(ACCOUNT_DELETION_PURGE_QUEUE, err); + return jobFailed("account deletion purge failed", err); + } + }); +} diff --git a/src/lib/jobs/achievement-unlock-sweep.ts b/src/lib/jobs/achievement-unlock-sweep.ts index 190a17189..b40cd17b4 100644 --- a/src/lib/jobs/achievement-unlock-sweep.ts +++ b/src/lib/jobs/achievement-unlock-sweep.ts @@ -23,6 +23,7 @@ * * Runs at 04:25 Europe/Berlin, after the cycle-prediction refresh (04:20). */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { Job } from "pg-boss"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -60,7 +61,10 @@ export async function runAchievementUnlockSweep( const buildResult = options.buildResult ?? buildAchievementsResult; const prisma = getWorkerPrisma(); - const users = await prisma.user.findMany({ orderBy: { id: "asc" } }); + const users = await prisma.user.findMany({ + where: ACTIVE_ACCOUNT, + orderBy: { id: "asc" }, + }); const summary: AchievementUnlockSweepSummary = { scanned: users.length, diff --git a/src/lib/jobs/ai-job-candidates.ts b/src/lib/jobs/ai-job-candidates.ts index b11a21756..6cd22218e 100644 --- a/src/lib/jobs/ai-job-candidates.ts +++ b/src/lib/jobs/ai-job-candidates.ts @@ -27,6 +27,7 @@ export async function userIdsWithModuleOff( prisma: Pick, module: ModuleKey, ): Promise { + // account-deletion: the accounts to leave out; the candidate queries using it filter. const rows = await prisma.$queryRaw>` SELECT "id" FROM "users" WHERE "module_preferences_json" -> ${module}::text = 'false'::jsonb`; diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 01a3e3649..15435665c 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -651,6 +651,7 @@ const LIVE_PG_BOSS_STATES = new Set(["active", "created", "retry"]); */ export async function stagedImportFilesInUse(): Promise { const prisma = getWorkerPrisma(); + // account-deletion: staged files of running imports, not accounts. const rows = await prisma.importJob.findMany({ where: { status: { in: [...ACTIVE_IMPORT_STATUSES] } }, select: { status: true, pgBossJobId: true, kind: true }, @@ -712,6 +713,7 @@ export async function stagedImportFilesInUse(): Promise { */ export async function reconcileOrphanImportJobs(): Promise { const prisma = getWorkerPrisma(); + // account-deletion: import-job bookkeeping after a restart, not work for an account. const candidates = await prisma.importJob.findMany({ where: { status: { in: ["unpacking", "parsing", "upserting"] }, diff --git a/src/lib/jobs/audit-log-cleanup.ts b/src/lib/jobs/audit-log-cleanup.ts index b79ba31b3..6049d4c0c 100644 --- a/src/lib/jobs/audit-log-cleanup.ts +++ b/src/lib/jobs/audit-log-cleanup.ts @@ -10,6 +10,7 @@ * env). Rows older than the cutoff are deleted in a single bulk * `deleteMany`; runs daily via pg-boss. */ +// account-deletion(file): the audit-trail retention sweep over rows, by age. import type { PrismaClient } from "@/generated/prisma/client"; import { purgeInBatches, type PurgeOutcome } from "@/lib/jobs/purge-batch"; diff --git a/src/lib/jobs/backup-pass-attempts.ts b/src/lib/jobs/backup-pass-attempts.ts index 115acd04e..4c7db51a6 100644 --- a/src/lib/jobs/backup-pass-attempts.ts +++ b/src/lib/jobs/backup-pass-attempts.ts @@ -27,6 +27,7 @@ * accounts at most every second run, and the account itself is attempted at * least every second run. */ +// account-deletion(file): reads the backup passes' own ledger; which accounts a pass walks is decided by the pass. import type { PrismaClient } from "@/generated/prisma/client"; import { logCaught } from "@/lib/logging/signal"; diff --git a/src/lib/jobs/backup-restore-view.ts b/src/lib/jobs/backup-restore-view.ts index f237bf57d..e283b9e5c 100644 --- a/src/lib/jobs/backup-restore-view.ts +++ b/src/lib/jobs/backup-restore-view.ts @@ -6,6 +6,7 @@ * says what it waits for (`backup-restore-wait.ts`). The worker and the * admission check live in `backup-restore.ts`. */ +// account-deletion(file): reads restore jobs for the admin view; restores are started by an admin, not for an account by the schedule. import { z } from "zod/v4"; import { prisma } from "@/lib/db"; diff --git a/src/lib/jobs/backup-restore.ts b/src/lib/jobs/backup-restore.ts index 7ad611afb..6c4e8e6ab 100644 --- a/src/lib/jobs/backup-restore.ts +++ b/src/lib/jobs/backup-restore.ts @@ -48,9 +48,11 @@ * `DataBackup` row, which stays where it is whatever happens, so no retry * ever needs the file again. */ +// account-deletion(file): restore-job bookkeeping; a restore is started by an admin, and the pending-deletion lock does not stop an admin. import { createHash } from "node:crypto"; import type { Job, PgBoss } from "pg-boss"; +import { isPreWipeBackupType } from "@/lib/account-deletion/pre-wipe-type"; import { invalidateUserData } from "@/lib/cache/invalidate"; import { prisma, toJson } from "@/lib/db"; @@ -124,6 +126,31 @@ export const BACKUP_RESTORE_MAX_ATTEMPTS = 2; /** Finished jobs older than this are deleted when the account restores again. */ export const BACKUP_RESTORE_RETENTION_DAYS = 30; +/** + * A restore of the copy a data wipe took first undoes that wipe, so the + * off-host purge the wipe asked for — held back to the end of the grace + * window — no longer has anything to do: the copies from before the wipe hold + * the record the account has again. It is withdrawn; a request already due + * (no window, or one that has ended) is left to run. + */ +async function withdrawHeldWipePurge( + backupId: string, + userId: string, +): Promise { + const backup = await prisma.dataBackup.findUnique({ + where: { id: backupId }, + select: { type: true }, + }); + if (!backup || !isPreWipeBackupType(backup.type)) return; + await prisma.offhostPurgeRequest.deleteMany({ + where: { + subjectId: userId, + reason: "data_wiped", + notBefore: { gt: new Date() }, + }, + }); +} + export type BackupRestorePayload = { restoreJobId: string } | { sweep: true }; export type BackupRestoreStatus = "queued" | "running" | "succeeded" | "failed"; @@ -476,6 +503,7 @@ export async function runBackupRestoreJob( if (outcome.ok) { const { summary, skipped, cleared } = outcome.response; + await withdrawHeldWipePurge(row.backupId, row.userId); await finish("succeeded", { result: { summary: { ...summary }, diff --git a/src/lib/jobs/coach-nudge.ts b/src/lib/jobs/coach-nudge.ts index b092ce85e..c74f1c06e 100644 --- a/src/lib/jobs/coach-nudge.ts +++ b/src/lib/jobs/coach-nudge.ts @@ -81,10 +81,11 @@ * by construction (no trigger → no nudge), and a user who engaged the Coach * in the last ~24 h is skipped. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; import { aiCapabilityForJob } from "@/lib/ai/capabilities/gate"; -import { annotate } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { getEffectiveRange, type ThresholdOverridesJson, @@ -101,7 +102,6 @@ import { type ComposeNudgeWithAI, type NudgeAiTickBudget, } from "@/lib/jobs/coach-nudge-ai"; -import { getEvent } from "@/lib/logging/context"; import { resolveCanonicalRecovery } from "@/lib/insights/derived/recovery-resolve"; import { resolveRestMode } from "@/lib/illness/rest-mode"; import { reconstructSleepNights } from "@/lib/analytics/sleep-night"; @@ -832,7 +832,7 @@ export async function runCoachNudgeTick( // Gate 2 — per-user Coach opt-out. const users = await prisma.user.findMany({ - where: { disableCoach: false }, + where: { disableCoach: false, ...ACTIVE_ACCOUNT }, select: { id: true, locale: true, diff --git a/src/lib/jobs/coach-plan-review.ts b/src/lib/jobs/coach-plan-review.ts index 46f749a86..55afa71cd 100644 --- a/src/lib/jobs/coach-plan-review.ts +++ b/src/lib/jobs/coach-plan-review.ts @@ -22,6 +22,7 @@ * behavioral (the plan extractor never records a clinical/dose trial); the * prompt + B0 cases enforce the refuse-clinical-experiment boundary. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { MeasurementType, PrismaClient } from "@/generated/prisma/client"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { isCumulativeDaySumType } from "@/lib/measurements/cumulative-day-sum"; @@ -264,6 +265,7 @@ export async function runCoachPlanReviewTick( source: "extractor", relatedPlanId: { not: null }, status: { in: ["due", "surfaced"] }, + ...OWNED_BY_ACTIVE_ACCOUNT, }, take: REVIEW_BATCH, select: { relatedPlanId: true }, diff --git a/src/lib/jobs/coach-reminder-sweep.ts b/src/lib/jobs/coach-reminder-sweep.ts index fb47ce4b5..2bc4bcc30 100644 --- a/src/lib/jobs/coach-reminder-sweep.ts +++ b/src/lib/jobs/coach-reminder-sweep.ts @@ -57,6 +57,10 @@ * cron-behind-event pattern the Vorsorge engine uses. Every trigger * converges on the ONE surfacing transaction in `ai/coach/reminder-surface.ts`. */ +import { + OWNED_BY_ACTIVE_ACCOUNT, + ownedByActiveAccount, +} from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec"; @@ -178,11 +182,11 @@ export async function runCoachReminderSweep( // Minted `active` with `dueAt: now` so the surfacing pass below picks them // up in this same tick. One path reaches the conversation, not two. const plans = await prisma.coachPlan.findMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, status: "active", reviewDate: { not: null, lte: now }, - }, + }), take: PLAN_REVIEW_BATCH, select: { id: true, @@ -243,12 +247,12 @@ export async function runCoachReminderSweep( // ones showing a badge with nothing behind it. Picking them up here converts // a stuck indicator into the message it always promised. const overdue = await prisma.coachReminder.findMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, status: { in: ["active", "due"] }, triggerKind: "date", dueAt: { not: null, lte: now }, - }, + }), take: SURFACE_BATCH, orderBy: { dueAt: "asc" }, select: { id: true, userId: true, noteEncrypted: true }, @@ -280,12 +284,12 @@ export async function runCoachReminderSweep( // within a day. NEXT_APP_OPEN is deliberately absent: only a real // app-open signal may satisfy it. const contextRows = await prisma.coachReminder.findMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, status: { in: ["active", "due"] }, triggerKind: "context", contextCue: { not: "NEXT_APP_OPEN" }, - }, + }), take: CONTEXT_BACKSTOP_BATCH, orderBy: { createdAt: "asc" }, select: { userId: true }, @@ -316,7 +320,7 @@ export async function runCoachReminderSweep( // the badge was dark and there was nothing to open. Those users' rows are // neither charged nor dismissed. const surfacedOwners = await prisma.coachReminder.findMany({ - where: { deletedAt: null, status: "surfaced" }, + where: { deletedAt: null, status: "surfaced", ...OWNED_BY_ACTIVE_ACCOUNT }, distinct: ["userId"], select: { userId: true }, }); diff --git a/src/lib/jobs/compaction-tombstone-purge.ts b/src/lib/jobs/compaction-tombstone-purge.ts index a43c080da..9b0868e19 100644 --- a/src/lib/jobs/compaction-tombstone-purge.ts +++ b/src/lib/jobs/compaction-tombstone-purge.ts @@ -48,6 +48,7 @@ * means as stored, and each account's delete takes the fold lock so it never * lands between a re-fold's reads. */ +// account-deletion(file): a retention sweep over rows already deleted, with its own retention clock; it does no work for an account. import { createHash } from "node:crypto"; import type { Job } from "pg-boss"; diff --git a/src/lib/jobs/cycle-prediction-refresh.ts b/src/lib/jobs/cycle-prediction-refresh.ts index 9e47d94f6..62b8bef4f 100644 --- a/src/lib/jobs/cycle-prediction-refresh.ts +++ b/src/lib/jobs/cycle-prediction-refresh.ts @@ -19,6 +19,7 @@ * moves on a day scale and the reminder windows are days wide, so a fixed * server-side hour is close enough for every timezone the cohort spans. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { Job } from "pg-boss"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -62,7 +63,7 @@ export async function runCyclePredictionRefresh( const prisma = getWorkerPrisma(); const profiles = await prisma.cycleProfile.findMany({ - where: { NOT: { predictionEnabled: false } }, + where: { NOT: { predictionEnabled: false }, ...OWNED_BY_ACTIVE_ACCOUNT }, select: { userId: true }, }); diff --git a/src/lib/jobs/cycle-reminder.ts b/src/lib/jobs/cycle-reminder.ts index dadb0ff05..477ef38e2 100644 --- a/src/lib/jobs/cycle-reminder.ts +++ b/src/lib/jobs/cycle-reminder.ts @@ -36,6 +36,7 @@ * single-flight (`localConcurrency: 1`) so two ticks never race the * fire-and-forget ledger write. */ +import { activeAccount } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { coerceLocale } from "@/lib/i18n/config"; @@ -333,12 +334,12 @@ export async function runCycleReminderTick( { nextPeriodStart: { gte: windowFloor, lte: windowCeil } }, { fertileWindowStart: { gte: fertileFloor, lte: fertileCeil } }, ], - user: { + user: activeAccount({ cycleProfile: { cycleTrackingEnabled: true, NOT: { predictionEnabled: false }, }, - }, + }), }, select: { userId: true, diff --git a/src/lib/jobs/daily-briefing.ts b/src/lib/jobs/daily-briefing.ts index f8a50b9d1..e605e8507 100644 --- a/src/lib/jobs/daily-briefing.ts +++ b/src/lib/jobs/daily-briefing.ts @@ -18,6 +18,7 @@ * loads a digest for the handful of users at their fallback minute-window this * tick. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; @@ -79,7 +80,11 @@ export async function runDailyBriefingTick( // preference row. Deduped by userId (a user may have opted several channels // in) and carrying the timezone so the slot gate needs no extra read. const prefs = await prisma.notificationPreference.findMany({ - where: { eventType: "DAILY_BRIEFING", enabled: true }, + where: { + eventType: "DAILY_BRIEFING", + enabled: true, + channel: OWNED_BY_ACTIVE_ACCOUNT, + }, select: { channel: { select: { userId: true, user: { select: { timezone: true } } }, diff --git a/src/lib/jobs/dawarich-sync.ts b/src/lib/jobs/dawarich-sync.ts index ebd4bac30..ad78649bb 100644 --- a/src/lib/jobs/dawarich-sync.ts +++ b/src/lib/jobs/dawarich-sync.ts @@ -11,6 +11,7 @@ * Every run is its own background event (`job.dawarich_sync`), so its lines * and its failure carry the action they belong to. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { Job, PgBoss } from "pg-boss"; import { loadDawarichConnection } from "@/lib/dawarich/connection"; @@ -67,7 +68,7 @@ export async function enqueueDawarichSync(payload: { async function discover(boss: PgBoss): Promise { const [candidates, operatorAvailability] = await Promise.all([ prisma.user.findMany({ - where: { dawarichUrlEncrypted: { not: null } }, + where: { dawarichUrlEncrypted: { not: null }, ...ACTIVE_ACCOUNT }, select: { id: true, modulePreferencesJson: true }, }), getOperatorModuleAvailability(), diff --git a/src/lib/jobs/dense-intraday-hourly-rebuild.ts b/src/lib/jobs/dense-intraday-hourly-rebuild.ts index 14ead6307..e5a8c0f8a 100644 --- a/src/lib/jobs/dense-intraday-hourly-rebuild.ts +++ b/src/lib/jobs/dense-intraday-hourly-rebuild.ts @@ -24,6 +24,7 @@ * `src/lib/jobs/reminder/register-rollup.ts` so pg-boss provisions it at * boot; an unregistered queue silently never drains. */ +import { dropPendingDeletion } from "@/lib/account-deletion/active-account"; import { Prisma, type PrismaClient } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -127,7 +128,9 @@ export async function findHourlyRebuildCandidateUserIds( const types = Array.from(DENSE_INTRADAY_RETENTION_TYPES).map(String); if (types.length === 0) return []; - const users = await prismaClient.$queryRaw>` + const users = await dropPendingDeletion( + prismaClient, + await prismaClient.$queryRaw>` SELECT DISTINCT m."user_id" FROM "measurements" m WHERE m."source" = 'APPLE_HEALTH' @@ -146,7 +149,9 @@ export async function findHourlyRebuildCandidateUserIds( AND t."measured_at" >= m."measured_at" - interval '14 hours' AND t."measured_at" < m."measured_at" + interval '14 hours' ) - `; + `, + (u) => u.user_id, + ); return users.map((u) => u.user_id); } diff --git a/src/lib/jobs/dense-intraday-retention.ts b/src/lib/jobs/dense-intraday-retention.ts index c807020a9..27b7322ae 100644 --- a/src/lib/jobs/dense-intraday-retention.ts +++ b/src/lib/jobs/dense-intraday-retention.ts @@ -18,6 +18,7 @@ * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; an * unregistered queue silently never drains. */ +import { dropPendingDeletion } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -176,16 +177,20 @@ export async function enqueueBootTimeDenseIntradayRetention(): Promise<{ // `distinct` dedups in the client AFTER pulling every matching row, // an instance-wide scan of every out-of-window dense-tier row just // to list user ids. - const users = await prisma.measurement.groupBy({ - by: ["userId"], - where: { - source: { in: [...DENSE_INTRADAY_FOLD_SOURCES] }, - type: { in: types }, - deletedAt: null, - NOT: { externalId: { startsWith: "stats:" } }, - measuredAt: { lt: windowStart }, - }, - }); + const users = await dropPendingDeletion( + prisma, + await prisma.measurement.groupBy({ + by: ["userId"], + where: { + source: { in: [...DENSE_INTRADAY_FOLD_SOURCES] }, + type: { in: types }, + deletedAt: null, + NOT: { externalId: { startsWith: "stats:" } }, + measuredAt: { lt: windowStart }, + }, + }), + (u) => u.userId, + ); if (users.length === 0) { return { enqueued: 0, skipped: 0, error: null }; diff --git a/src/lib/jobs/document-index-retry.ts b/src/lib/jobs/document-index-retry.ts index a6809953f..149373d07 100644 --- a/src/lib/jobs/document-index-retry.ts +++ b/src/lib/jobs/document-index-retry.ts @@ -18,6 +18,7 @@ * module is off keeps its marks until the module is on again; a boot with * nothing marked costs one query. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { enqueueDocumentIndex } from "@/lib/jobs/document-index"; import { annotate } from "@/lib/logging/context"; @@ -52,6 +53,7 @@ export async function enqueueBootTimeIndexRetry(): Promise<{ lastIndexAttemptAt: null, lastIndexOutcome: { not: null }, contentIndex: { is: null }, + ...OWNED_BY_ACTIVE_ACCOUNT, ...(moduleOff.length > 0 ? { userId: { notIn: moduleOff } } : {}), // Past the last row read. Not Prisma's `cursor` + `skip`: the // cursor row may belong to an account the filter now leaves out, diff --git a/src/lib/jobs/document-thumbnail-backfill.ts b/src/lib/jobs/document-thumbnail-backfill.ts index bdd373642..1ef0b6740 100644 --- a/src/lib/jobs/document-thumbnail-backfill.ts +++ b/src/lib/jobs/document-thumbnail-backfill.ts @@ -23,6 +23,7 @@ * The queue MUST be registered in the maintenance registrar * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. */ +import { activeAccountSql } from "@/lib/account-deletion/active-account"; import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { nativeCanvasSupported } from "@/lib/documents/native-canvas-support"; @@ -188,7 +189,8 @@ export async function enqueueBootTimeThumbnailBackfill(): Promise<{ f.reason NOT IN (${Prisma.join([...FINAL_THUMBNAIL_FAILURES])}) AND f.attempts < ${MAX_THUMBNAIL_ATTEMPTS} ) - )`; + ) + AND ${activeAccountSql("d.user_id")}`; let enqueued = 0; for (const { user_id: userId } of rows) { diff --git a/src/lib/jobs/environment-air-quality-history.ts b/src/lib/jobs/environment-air-quality-history.ts index 3c03090d5..18e018e17 100644 --- a/src/lib/jobs/environment-air-quality-history.ts +++ b/src/lib/jobs/environment-air-quality-history.ts @@ -31,6 +31,7 @@ * `src/lib/jobs/reminder/register-maintenance.ts`, or the sends silently * no-op. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PgBoss } from "pg-boss"; import { prisma } from "@/lib/db"; @@ -130,6 +131,7 @@ export async function discoverAirQualityHistory( prisma.user.findMany({ where: { environmentAirQualityEnabled: true, + ...ACTIVE_ACCOUNT, OR: [ { homeLocationEncrypted: { not: null } }, { environmentTravelLocations: { some: {} } }, diff --git a/src/lib/jobs/environment-fetch.ts b/src/lib/jobs/environment-fetch.ts index eef50e48d..dfb68f398 100644 --- a/src/lib/jobs/environment-fetch.ts +++ b/src/lib/jobs/environment-fetch.ts @@ -25,6 +25,7 @@ * is found by its sealed copy as well as by the readable columns the * encryption backfill has not cleared yet. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import type { PgBoss } from "pg-boss"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -150,7 +151,7 @@ export async function enqueueEnvironmentFetchDiscovery( // consults the per-user preference map + the operator availability. const [candidates, operatorAvailability] = await Promise.all([ prisma.user.findMany({ - where: { homeLocationEncrypted: { not: null } }, + where: { homeLocationEncrypted: { not: null }, ...ACTIVE_ACCOUNT }, select: { id: true, modulePreferencesJson: true }, }), getOperatorModuleAvailability(), diff --git a/src/lib/jobs/feedback-aggregator.ts b/src/lib/jobs/feedback-aggregator.ts index f2ae6569f..7934360b9 100644 --- a/src/lib/jobs/feedback-aggregator.ts +++ b/src/lib/jobs/feedback-aggregator.ts @@ -21,6 +21,7 @@ * No prompt mutation in v1.4.16 — the aggregator only writes the * summary blob; the prompt-tuning step is deferred to v1.4.17. */ +// account-deletion(file): instance-wide counts of feedback rows; nothing is done for any account. import type { PrismaClient } from "@/generated/prisma/client"; export const DEFAULT_FEEDBACK_AGGREGATION_WINDOW_DAYS = 30; diff --git a/src/lib/jobs/free-text-encryption-backfill.ts b/src/lib/jobs/free-text-encryption-backfill.ts index f7e7317d3..ace46ade3 100644 --- a/src/lib/jobs/free-text-encryption-backfill.ts +++ b/src/lib/jobs/free-text-encryption-backfill.ts @@ -64,6 +64,7 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at * boot; an unregistered queue silently never drains. */ +// account-deletion(file): encrypts existing rows at rest; it changes how a row is stored, not what the record holds, and a pending account's rows are erased or kept as they are either way. import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; diff --git a/src/lib/jobs/geo-backfill.ts b/src/lib/jobs/geo-backfill.ts index 919cb8a01..bf79452e1 100644 --- a/src/lib/jobs/geo-backfill.ts +++ b/src/lib/jobs/geo-backfill.ts @@ -49,6 +49,7 @@ * counts off the returned summary and decide whether to keep * scheduling. */ +// account-deletion(file): fills the place of audit rows by their IP address; bookkeeping on rows, not work for an account. import type { PrismaClient } from "@/generated/prisma/client"; import { lookupIpGeo } from "@/lib/geo"; diff --git a/src/lib/jobs/google-health-backfill.ts b/src/lib/jobs/google-health-backfill.ts index 127347574..20cca26d9 100644 --- a/src/lib/jobs/google-health-backfill.ts +++ b/src/lib/jobs/google-health-backfill.ts @@ -11,6 +11,7 @@ * provisions it and the boot enqueue silently never drains (the v1.4.37 * dead-queue class). */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -486,7 +487,7 @@ export async function enqueueBootTimeGoogleHealthBackfill( try { const connections = await prisma.googleHealthConnection.findMany({ - where: { backfillCompletedAt: null }, + where: { backfillCompletedAt: null, ...OWNED_BY_ACTIVE_ACCOUNT }, select: { userId: true }, }); diff --git a/src/lib/jobs/google-health-sleep-repair.ts b/src/lib/jobs/google-health-sleep-repair.ts index e4fe7dedf..15f513af3 100644 --- a/src/lib/jobs/google-health-sleep-repair.ts +++ b/src/lib/jobs/google-health-sleep-repair.ts @@ -23,6 +23,7 @@ * provisions it and the boot enqueue silently never drains (the v1.4.37 * dead-queue class). */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -150,7 +151,7 @@ export async function enqueueBootTimeGoogleHealthSleepRepair( try { const connections = await prisma.googleHealthConnection.findMany({ - where: { sleepRepairedAt: null }, + where: { sleepRepairedAt: null, ...OWNED_BY_ACTIVE_ACCOUNT }, select: { userId: true }, }); diff --git a/src/lib/jobs/insight-pregenerate.ts b/src/lib/jobs/insight-pregenerate.ts index f67c63230..a56101dca 100644 --- a/src/lib/jobs/insight-pregenerate.ts +++ b/src/lib/jobs/insight-pregenerate.ts @@ -48,6 +48,7 @@ * Recurring pg-boss task — never runs inside an HTTP request and never * shells out to `tsx` (CLAUDE.md DO-NOTs). */ +import { activeAccount } from "@/lib/account-deletion/active-account"; import { createHash, randomUUID } from "node:crypto"; import pLimit from "p-limit"; @@ -456,19 +457,18 @@ export async function findPregenerateCandidates( const staleBefore = new Date(now.getTime() - PREGENERATE_STALE_MS); const optedOut = await userIdsWithModuleOff(prisma, "insights"); return prisma.user.findMany({ - where: { + where: activeAccount({ ...(optedOut.length > 0 ? { id: { notIn: optedOut } } : {}), OR: [ { insightsCachedAt: null }, { insightsCachedAt: { lt: staleBefore } }, ], - }, + }), // nulls-first is the default ascending order in Postgres, so // never-generated users sort ahead of merely-stale ones. orderBy: { insightsCachedAt: "asc" }, take: cap, - // v1.25.3 — carry the per-user response-timeout so the warm outer abort - // scales with the operator's setting instead of a fixed cap. + // The per-user response timeout, so the warm outer abort scales with it. select: { id: true, locale: true, aiResponseTimeoutSeconds: true }, }); } diff --git a/src/lib/jobs/intake-auto-skip.ts b/src/lib/jobs/intake-auto-skip.ts index db63b19c0..4cb6cdbfd 100644 --- a/src/lib/jobs/intake-auto-skip.ts +++ b/src/lib/jobs/intake-auto-skip.ts @@ -61,6 +61,7 @@ * cached compliance / intake / summary payloads were built on the * pre-flip denominator and would otherwise serve it for up to their TTL. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { invalidateUserMedications } from "@/lib/cache/invalidate"; @@ -251,7 +252,11 @@ export async function runIntakeAutoSkipPass( // users' server caches below. const candidates = await prisma.medicationIntakeEvent.groupBy({ by: ["medicationId", "userId"], - where: { ...pendingWhere, scheduledFor: { lt: baseCutoff } }, + where: { + ...pendingWhere, + scheduledFor: { lt: baseCutoff }, + ...OWNED_BY_ACTIVE_ACCOUNT, + }, }); if (candidates.length === 0) { return { skippedCount: 0, cutoff: baseCutoff, invalidatedUserIds: [] }; diff --git a/src/lib/jobs/intake-auto-take.ts b/src/lib/jobs/intake-auto-take.ts index 1e6998558..1e265a750 100644 --- a/src/lib/jobs/intake-auto-take.ts +++ b/src/lib/jobs/intake-auto-take.ts @@ -52,6 +52,7 @@ * The hourly auto-miss pass (`intake-auto-skip.ts`) leaves the slots this job * owns alone, so the two never write the same slot. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { MEDICATION_WINDOW_SELECT } from "@/lib/medications/course-window"; @@ -261,7 +262,11 @@ export async function runIntakeAutoTakePass( const medications = (await prisma.medication.findMany({ // Opted in, active, not paused, not as-needed, intake tracked (a // record-only medication expects no dose, so none is recorded). - where: { ...autoTakeEligibleWhere(now), ...TRACKED_INTAKE_WHERE }, + where: { + ...autoTakeEligibleWhere(now), + ...TRACKED_INTAKE_WHERE, + ...OWNED_BY_ACTIVE_ACCOUNT, + }, select: { id: true, userId: true, diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 250838b7d..5a2366d9c 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -43,6 +43,12 @@ export type JobFacts = Readonly>; */ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "access_tokens_deleted", + // The account-deletion purge: accounts whose grace window ended and + // were erased, ones whose erasure was refused and stays pending, and the + // copies taken before a data wipe that aged out with their window. + "account_deletions_due", + "account_deletions_purged", + "account_deletions_refused", "accounts_completed", // v1.40 — the document AI run reaper's three outcomes. "ai_runs_deleted", @@ -201,6 +207,7 @@ export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ "persisted", "plan_reviews_minted", "practitioner_contacts_migrated", + "pre_wipe_copies_deleted", "pr_detection_inserted", "pr_detection_jobs", "pr_detection_ties", diff --git a/src/lib/jobs/lab-biomarker-backfill.ts b/src/lib/jobs/lab-biomarker-backfill.ts index 246d2addc..52550da51 100644 --- a/src/lib/jobs/lab-biomarker-backfill.ts +++ b/src/lib/jobs/lab-biomarker-backfill.ts @@ -24,6 +24,7 @@ * or pg-boss never provisions it and the boot enqueue silently never drains * (the v1.4.37 dead-queue class). */ +import { activeAccountSql } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -213,7 +214,8 @@ export async function enqueueBootTimeLabBiomarkerBackfill( // to empty as rows are linked. const rows = await prisma.$queryRaw<{ user_id: string }[]>` SELECT DISTINCT user_id FROM lab_results - WHERE deleted_at IS NULL AND biomarker_id IS NULL`; + WHERE deleted_at IS NULL AND biomarker_id IS NULL + AND ${activeAccountSql("user_id")}`; if (rows.length === 0) { return { enqueued: 0, skipped: 0, error: null }; diff --git a/src/lib/jobs/mean-consolidation.ts b/src/lib/jobs/mean-consolidation.ts index 6e9cef9bf..b8902b43b 100644 --- a/src/lib/jobs/mean-consolidation.ts +++ b/src/lib/jobs/mean-consolidation.ts @@ -13,6 +13,7 @@ * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; * an unregistered queue silently never drains. */ +import { dropPendingDeletion } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -120,15 +121,19 @@ export async function enqueueBootTimeMeanConsolidation( // an instance-wide unbounded scan just to list user ids. No date // bound on purpose: a fresh import of OLD per-sample rows must still // surface its user here, however old the rows are. - const users = await prisma.measurement.groupBy({ - by: ["userId"], - where: { - source: "APPLE_HEALTH", - type: { in: types }, - deletedAt: null, - NOT: { externalId: { startsWith: "stats:" } }, - }, - }); + const users = await dropPendingDeletion( + prisma, + await prisma.measurement.groupBy({ + by: ["userId"], + where: { + source: "APPLE_HEALTH", + type: { in: types }, + deletedAt: null, + NOT: { externalId: { startsWith: "stats:" } }, + }, + }), + (u) => u.userId, + ); if (users.length === 0) { return { enqueued: 0, skipped: 0, error: null }; diff --git a/src/lib/jobs/measurement-fold-repair.ts b/src/lib/jobs/measurement-fold-repair.ts index ffab94285..c9c42e3b0 100644 --- a/src/lib/jobs/measurement-fold-repair.ts +++ b/src/lib/jobs/measurement-fold-repair.ts @@ -74,6 +74,7 @@ * `src/lib/jobs/reminder/register-maintenance.ts`, or the sends silently * no-op. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { createHash } from "node:crypto"; import type { Job } from "pg-boss"; @@ -755,7 +756,7 @@ export async function enqueueBootTimeMeasurementFoldRepair( if (!boss) return { enqueued: 0, skipped: 0, error: null }; try { const users = await prisma.user.findMany({ - where: { measurementFoldRepair: null }, + where: { measurementFoldRepair: null, ...ACTIVE_ACCOUNT }, select: { id: true }, }); let enqueued = 0; diff --git a/src/lib/jobs/measurement-reminder.ts b/src/lib/jobs/measurement-reminder.ts index 87dd5cd22..e81f56bfa 100644 --- a/src/lib/jobs/measurement-reminder.ts +++ b/src/lib/jobs/measurement-reminder.ts @@ -35,6 +35,7 @@ * server-side APNs send only, and it does so in the dispatcher — the * tick dispatches for every user and lets the cascade decide. */ +import { ownedByActiveAccount } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import type { MeasurementType } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -313,11 +314,11 @@ export async function runMeasurementReminderTick( // cross due, before any nudge fires, so dropping future rows is safe. const dueFloor = new Date(now.getTime() + DUE_QUERY_SLACK_MS); const reminders = await prisma.measurementReminder.findMany({ - where: { + where: ownedByActiveAccount({ deletedAt: null, enabled: true, nextDueAt: { not: null, lte: dueFloor }, - }, + }), include: { user: { select: { diff --git a/src/lib/jobs/measurement-tombstone-cleanup.ts b/src/lib/jobs/measurement-tombstone-cleanup.ts index e52072316..c838879af 100644 --- a/src/lib/jobs/measurement-tombstone-cleanup.ts +++ b/src/lib/jobs/measurement-tombstone-cleanup.ts @@ -18,6 +18,7 @@ * (`NATIVE_REFRESH_TOKEN_DAYS` + margin) so it moves automatically if the * refresh-token lifetime changes — the two never drift. */ +// account-deletion(file): a retention sweep over rows already deleted, with its own retention clock; it does no work for an account. import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { diff --git a/src/lib/jobs/med-notes-encryption-backfill.ts b/src/lib/jobs/med-notes-encryption-backfill.ts index ea620c284..99fffb237 100644 --- a/src/lib/jobs/med-notes-encryption-backfill.ts +++ b/src/lib/jobs/med-notes-encryption-backfill.ts @@ -38,6 +38,7 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at * boot; an unregistered queue silently never drains. */ +// account-deletion(file): encrypts existing rows at rest; it changes how a row is stored, not what the record holds. import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; diff --git a/src/lib/jobs/medication-low-stock.ts b/src/lib/jobs/medication-low-stock.ts index 42b2db001..99b3fa8bf 100644 --- a/src/lib/jobs/medication-low-stock.ts +++ b/src/lib/jobs/medication-low-stock.ts @@ -60,6 +60,7 @@ * an absolute floor instead: `AS_NEEDED_LOW_STOCK_DOSES` doses or fewer * left, once per crossing, with the same stamp and re-arm rule. */ +import { activeAccount } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { classifyLowStockState, @@ -391,7 +392,7 @@ export async function runMedicationLowStockTick( // Only users who own at least one ACTIVE, unpaused medication with // tracked inventory (any container ever registered) are candidates. const users = await prisma.user.findMany({ - where: { + where: activeAccount({ medications: { some: { active: true, @@ -402,7 +403,7 @@ export async function runMedicationLowStockTick( inventoryItems: { some: {} }, }, }, - }, + }), select: { id: true, locale: true, notificationPrefs: true }, }); diff --git a/src/lib/jobs/mood-prognosis-refresh.ts b/src/lib/jobs/mood-prognosis-refresh.ts index e63ff411a..b9caccc8a 100644 --- a/src/lib/jobs/mood-prognosis-refresh.ts +++ b/src/lib/jobs/mood-prognosis-refresh.ts @@ -32,6 +32,7 @@ * scores. 04:35 is free, and it sits after the inputs this job reads have * settled. (04:40 is not free: the geo backfill runs at `40 * * * *`.) */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { Job } from "pg-boss"; import { @@ -150,10 +151,12 @@ export async function runMoodPrognosisRefresh( deletedAt: null, moodA1: { not: null }, date: { gte: since }, + ...OWNED_BY_ACTIVE_ACCOUNT, }, _count: { _all: true }, }), prisma.moodPrediction.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, distinct: ["userId"], select: { userId: true }, }), diff --git a/src/lib/jobs/mood-reminder.ts b/src/lib/jobs/mood-reminder.ts index a5ad24492..86abb666b 100644 --- a/src/lib/jobs/mood-reminder.ts +++ b/src/lib/jobs/mood-reminder.ts @@ -20,6 +20,7 @@ * keeps the test surface focused and lets the unit tests run without * booting pg-boss. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getServerTranslator } from "@/lib/i18n/server-translator"; import { coerceLocale } from "@/lib/i18n/config"; @@ -163,7 +164,7 @@ export async function runMoodReminderTick( }; const candidates = await prisma.user.findMany({ - where: { moodReminderEnabled: true }, + where: { moodReminderEnabled: true, ...ACTIVE_ACCOUNT }, select: { id: true, timezone: true, locale: true, notificationPrefs: true }, }); diff --git a/src/lib/jobs/note-encryption-backfill.ts b/src/lib/jobs/note-encryption-backfill.ts index c504dd438..7f4ec0104 100644 --- a/src/lib/jobs/note-encryption-backfill.ts +++ b/src/lib/jobs/note-encryption-backfill.ts @@ -31,6 +31,7 @@ * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it at * boot; an unregistered queue silently never drains. */ +// account-deletion(file): encrypts existing rows at rest; it changes how a row is stored, not what the record holds. import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; diff --git a/src/lib/jobs/offhost-backup-cohort.ts b/src/lib/jobs/offhost-backup-cohort.ts new file mode 100644 index 000000000..1d5bcbf50 --- /dev/null +++ b/src/lib/jobs/offhost-backup-cohort.ts @@ -0,0 +1,46 @@ +/** + * The accounts one off-host pass walks, and the ones a resumed run already + * uploaded. + */ +import type { PrismaClient } from "@/generated/prisma/client"; +import { + ACTIVE_ACCOUNT, + OWNED_BY_ACTIVE_ACCOUNT, +} from "@/lib/account-deletion/active-account"; +import { + orderInterruptedLast, + readAccountsToTakeLast, +} from "@/lib/jobs/backup-pass-attempts"; +import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue"; + +export async function readOffhostCohort( + prisma: PrismaClient, + runStartedAt: Date | undefined, +): Promise<{ users: Array<{ id: string }>; doneThisRun: Set }> { + // By id, with an account whose last walk never came back behind the rest: + // a record that killed the process once would otherwise lead every retry + // and keep every account after it out of the bucket. Behind them for one + // run only, so a pass that stops on its budget still reaches it the run + // after (`backup-pass-attempts.ts`). An account pending deletion is not + // walked: its copies stay as they were until the purge erases them. + const users = orderInterruptedLast( + await prisma.user.findMany({ + where: ACTIVE_ACCOUNT, + select: { id: true }, + orderBy: { id: "asc" }, + }), + await readAccountsToTakeLast(prisma, OFFHOST_BACKUP_QUEUE), + ); + const doneThisRun = new Set(); + if (runStartedAt) { + const done = await prisma.offhostBackupState.findMany({ + where: { + lastSuccessAt: { gte: runStartedAt }, + ...OWNED_BY_ACTIVE_ACCOUNT, + }, + select: { userId: true }, + }); + for (const row of done) doneThisRun.add(row.userId); + } + return { users, doneThisRun }; +} diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index cf6fbf1ba..c00f03be6 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -38,9 +38,8 @@ import { streamFullBackupJson } from "@/lib/export/full-backup-stream"; import { markBackupAttemptFinished, markBackupAttemptStarted, - orderInterruptedLast, - readAccountsToTakeLast, } from "@/lib/jobs/backup-pass-attempts"; +import { readOffhostCohort } from "./offhost-backup-cohort"; import { annotate, getEvent } from "@/lib/logging/context"; import { envValue } from "@/lib/env"; import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue"; @@ -908,26 +907,7 @@ export async function runOffhostBackup( // eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: off-host object key date, the name the restore drill and the purge look up const dateKey = (runStartedAt ?? now).toISOString().slice(0, 10); - // By id, with an account whose last walk never came back behind the rest: - // a record that killed the process once would otherwise lead every retry - // and keep every account after it out of the bucket. Behind them for one - // run only, so a pass that stops on its budget still reaches it the run - // after (`backup-pass-attempts.ts`). - const users = orderInterruptedLast( - await prisma.user.findMany({ - select: { id: true }, - orderBy: { id: "asc" }, - }), - await readAccountsToTakeLast(prisma, OFFHOST_BACKUP_QUEUE), - ); - const doneThisRun = new Set(); - if (runStartedAt) { - const done = await prisma.offhostBackupState.findMany({ - where: { lastSuccessAt: { gte: runStartedAt } }, - select: { userId: true }, - }); - for (const row of done) doneThisRun.add(row.userId); - } + const { users, doneThisRun } = await readOffhostCohort(prisma, runStartedAt); let alreadyUploaded = 0; let stoppedEarly = false; let uploaded = 0; diff --git a/src/lib/jobs/offhost-purge-queue.ts b/src/lib/jobs/offhost-purge-queue.ts index 4630131a5..fe225948e 100644 --- a/src/lib/jobs/offhost-purge-queue.ts +++ b/src/lib/jobs/offhost-purge-queue.ts @@ -7,3 +7,11 @@ export const OFFHOST_PURGE_QUEUE = "offhost-backup-purge"; /** 03:40, after the nightly upload (02:30) has had its hour. */ export const OFFHOST_PURGE_CRON = "40 3 * * *"; + +/** + * `where` on `OffhostPurgeRequest`: due at `now`. A data wipe holds its + * request back to the end of the deletion grace window (`notBefore`). + */ +export function offhostPurgeDue(now: Date) { + return { OR: [{ notBefore: null }, { notBefore: { lte: now } }] }; +} diff --git a/src/lib/jobs/offhost-purge.ts b/src/lib/jobs/offhost-purge.ts index 2cdf06cd8..857c84f40 100644 --- a/src/lib/jobs/offhost-purge.ts +++ b/src/lib/jobs/offhost-purge.ts @@ -46,7 +46,7 @@ import { reportWorkerError } from "@/lib/jobs/report-worker-error"; import { getWorkerPrisma } from "@/lib/jobs/reminder/shared"; import { withBackgroundEvent } from "@/lib/logging/background"; import { logCaught } from "@/lib/logging/signal"; -import { OFFHOST_PURGE_QUEUE } from "./offhost-purge-queue"; +import { OFFHOST_PURGE_QUEUE, offhostPurgeDue } from "./offhost-purge-queue"; export type OffhostPurgeReason = "account_deleted" | "data_wiped" | "managed_profile_deleted"; @@ -72,15 +72,20 @@ type PurgeDb = Pick< * transaction that deletes the account or wipes its data, so the request and * the deletion commit together. A host without off-host backup configured has * nothing in any bucket this could reach, and records nothing. + * + * `notBefore` holds the purge back until then: a data wipe passes the end of + * the deletion grace window, so the copies from before the wipe stay as long + * as the account copy the wipe took first (v1.46). */ export async function requestOffhostPurge( db: Db, subjectId: string, reason: OffhostPurgeReason, + options: { notBefore?: Date | null } = {}, ): Promise { if (!offhostBackupConfigured()) return false; await db.offhostPurgeRequest.create({ - data: { subjectId, reason }, + data: { subjectId, reason, notBefore: options.notBefore ?? null }, select: { id: true }, }); return true; @@ -130,7 +135,10 @@ export async function processOffhostPurges( prisma: PurgeDb, s3Override?: S3Like, ): Promise { + // A request held back to the end of a grace window is not this run's. + // account-deletion: purge requests: the account is gone, or the request waits for its window. const requests = await prisma.offhostPurgeRequest.findMany({ + where: offhostPurgeDue(new Date()), orderBy: { requestedAt: "asc" }, take: REQUESTS_PER_RUN, }); diff --git a/src/lib/jobs/oura-backfill.ts b/src/lib/jobs/oura-backfill.ts index 413267412..40a6e5b37 100644 --- a/src/lib/jobs/oura-backfill.ts +++ b/src/lib/jobs/oura-backfill.ts @@ -28,6 +28,7 @@ * that grants the missing scope reads only those collections: the first slice * of every chain asks again for the refused ones. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -476,6 +477,7 @@ export async function enqueueBootTimeOuraBackfill( const users = await prisma.user.findMany({ where: { ouraBackfillCompletedAt: null, + ...ACTIVE_ACCOUNT, OR: [ { ouraAccessTokenEncrypted: { not: null } }, { measurements: { some: OURA_EQUIVALENT_DISTANCE_WHERE } }, diff --git a/src/lib/jobs/period-narrative-warm.ts b/src/lib/jobs/period-narrative-warm.ts index 51aa05dd9..f01162781 100644 --- a/src/lib/jobs/period-narrative-warm.ts +++ b/src/lib/jobs/period-narrative-warm.ts @@ -19,6 +19,7 @@ * Recurring pg-boss task — never runs inside an HTTP request, never shells out * to `tsx` (CLAUDE.md DO-NOTs). */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { checkRateLimit } from "@/lib/rate-limit"; import { getAssistantFlags } from "@/lib/feature-flags"; @@ -123,11 +124,15 @@ export async function findNarrativeCandidates( ): Promise { const optedOut = await userIdsWithModuleOff(prisma, "insights"); const users: NarrativeCandidate[] = await prisma.user.findMany({ - where: optedOut.length > 0 ? { id: { notIn: optedOut } } : {}, + where: { + ...ACTIVE_ACCOUNT, + ...(optedOut.length > 0 ? { id: { notIn: optedOut } } : {}), + }, select: { id: true, locale: true }, }); if (users.length <= cap) return users; + // account-deletion: when each account's narrative was last written, to order the filtered list above. const latest = await prisma.insightNarrative.groupBy({ by: ["userId"], _max: { updatedAt: true }, diff --git a/src/lib/jobs/pr-detection.ts b/src/lib/jobs/pr-detection.ts index 5117f0069..189a1ad28 100644 --- a/src/lib/jobs/pr-detection.ts +++ b/src/lib/jobs/pr-detection.ts @@ -11,6 +11,7 @@ * 30-minute fallback cron in `reminder-worker.ts` is the safety net * for any ingest path that forgets to enqueue. */ +import { activeAccountSql } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -71,16 +72,19 @@ export async function listPrDetectionFallbackUserIds( const rows = await prisma.$queryRaw>` SELECT u."id" FROM "users" u - WHERE EXISTS ( - SELECT 1 FROM "measurements" m - WHERE m."user_id" = u."id" - AND m."updated_at" > ${since} - AND m."deleted_at" IS NULL - ) - OR EXISTS ( - SELECT 1 FROM "workouts" w - WHERE w."user_id" = u."id" AND w."updated_at" > ${since} + WHERE ( + EXISTS ( + SELECT 1 FROM "measurements" m + WHERE m."user_id" = u."id" + AND m."updated_at" > ${since} + AND m."deleted_at" IS NULL + ) + OR EXISTS ( + SELECT 1 FROM "workouts" w + WHERE w."user_id" = u."id" AND w."updated_at" > ${since} + ) ) + AND ${activeAccountSql(`u."id"`)} ORDER BY u."id" `; return rows.map((row) => row.id); diff --git a/src/lib/jobs/pulse-day-mean.ts b/src/lib/jobs/pulse-day-mean.ts index c5f9bc758..8b2073b23 100644 --- a/src/lib/jobs/pulse-day-mean.ts +++ b/src/lib/jobs/pulse-day-mean.ts @@ -18,6 +18,7 @@ * `REBUILD_WINDOW_DAYS`), so the process holds a window's aggregate rows and * never an account's readings: the heap stays flat however long the history. */ +import { activeAccountSql } from "@/lib/account-deletion/active-account"; import type { Job } from "pg-boss"; import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql"; @@ -198,6 +199,7 @@ async function pulseAccounts(): Promise { WHERE m."user_id" = u."id" AND m."type" = 'PULSE' AND m."deleted_at" IS NULL) AS last FROM users u + WHERE ${activeAccountSql(`u."id"`)} ORDER BY u."created_at", u."id" `; return rows diff --git a/src/lib/jobs/queue-runtime.ts b/src/lib/jobs/queue-runtime.ts index 28ed26810..c8201c4a2 100644 --- a/src/lib/jobs/queue-runtime.ts +++ b/src/lib/jobs/queue-runtime.ts @@ -352,6 +352,9 @@ export const QUEUE_RUNTIME: Readonly> = { why: "Fetches and stream-parses every account's newest off-host object.", }, "offhost-backup-purge": short("At most 200 delete requests per run."), + "account-deletion-purge": short( + "At most 50 account erasures per run, plus one DELETE of expired pre-wipe copies.", + ), "backup-restore": { runtime: "long", expireInSeconds: BACKUP_RESTORE_EXPIRE_SECONDS, diff --git a/src/lib/jobs/registry/maintenance.ts b/src/lib/jobs/registry/maintenance.ts index 70429c156..f8ff18899 100644 --- a/src/lib/jobs/registry/maintenance.ts +++ b/src/lib/jobs/registry/maintenance.ts @@ -160,6 +160,10 @@ import { OFFHOST_PURGE_CRON, OFFHOST_PURGE_QUEUE, } from "@/lib/jobs/offhost-purge-queue"; +import { + ACCOUNT_DELETION_PURGE_CRON, + ACCOUNT_DELETION_PURGE_QUEUE, +} from "@/lib/jobs/account-deletion-purge-queue"; import { PR_DETECTION_CONCURRENCY, PR_DETECTION_EXPIRE_SECONDS, @@ -349,6 +353,15 @@ const jobs: readonly JobDefinition[] = [ OFFHOST_PURGE_CRON, async () => (await import("@/lib/jobs/offhost-purge")).handleOffhostPurge, ), + // Erases the accounts whose deletion grace window ended, and the copies + // data wipes took whose window ended with them. + prune( + ACCOUNT_DELETION_PURGE_QUEUE, + ACCOUNT_DELETION_PURGE_CRON, + async () => + (await import("@/lib/jobs/account-deletion-purge")) + .handleAccountDeletionPurge, + ), // One restore at a time: each holds a transaction over a whole account. defineJob({ queue: BACKUP_RESTORE_QUEUE, diff --git a/src/lib/jobs/reminder/backup-handlers.ts b/src/lib/jobs/reminder/backup-handlers.ts index c8bc8fa83..2b59e685f 100644 --- a/src/lib/jobs/reminder/backup-handlers.ts +++ b/src/lib/jobs/reminder/backup-handlers.ts @@ -4,6 +4,7 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { type Job, type JobWithMetadata } from "pg-boss"; import { jobBudget } from "@/lib/jobs/job-budget"; import { recordError } from "@/lib/jobs/worker-status"; @@ -11,10 +12,7 @@ import { BackupBlobTooLargeError, BackupBusyError, } from "@/lib/export/backup-blob"; -import { storeBackupBlob } from "@/lib/export/store-backup-blob"; -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 { writeAccountCopy } from "@/lib/export/account-copy"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; import { @@ -149,7 +147,11 @@ export async function handleDataBackup( const prisma = getWorkerPrisma(); try { const [accounts, copies, interrupted] = await Promise.all([ - prisma.user.findMany({ select: { id: true, username: true } }), + prisma.user.findMany({ + where: ACTIVE_ACCOUNT, + select: { id: true, username: true }, + }), + // account-deletion: copy ages, read against the filtered accounts beside it. prisma.dataBackup.findMany({ where: { type: "WEEKLY_AUTO" }, select: { userId: true, createdAt: true }, @@ -198,30 +200,14 @@ export async function handleDataBackup( }); await markBackupAttemptStarted(prisma, DATA_BACKUP_QUEUE, user.id); try { - // 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. - const preview = await createBackupPreviewCollector(); - let counts: FullBackupCounts | undefined; - const { bytes: storedBytes } = await storeBackupBlob( + // The shared account-copy writer (`writeAccountCopy`): streamed, + // compressed, encrypted and stored a piece at a time, so nothing of + // the record exists as a whole in this process (#1031). The copy a + // data wipe takes first goes through the same writer. + const { bytes: storedBytes } = await writeAccountCopy( prisma, - { - userId: user.id, - type: "WEEKLY_AUTO", - preview: () => (counts ? preview.finish(counts) : null), - }, - async (write) => { - counts = await streamFullBackupJson(prisma, user.id, write, { - purpose: "disaster-recovery", - observe: preview.observe, - }); - }, + user.id, + "WEEKLY_AUTO", ); largestBlobBytes = Math.max(largestBlobBytes, storedBytes); backed++; diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index 44e59eadf..cb67bfab0 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -4,6 +4,7 @@ * Extracted alongside the Fitbit handlers; register-integration-sync.ts owns the * queue names, cron schedules, and boss.work registrations. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { type Job } from "pg-boss"; import pLimit from "p-limit"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; @@ -48,6 +49,7 @@ export async function handleGoogleHealthSync( } if (targets.length === 0) { const connections = await prisma.googleHealthConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }); targets.push(...connections); diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index eb9596aae..2dd0529a1 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -4,11 +4,11 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { type Job } from "pg-boss"; import type { Locale } from "@/lib/i18n/config"; import { resolveJobLocale } from "@/lib/i18n/job-locale"; import { recordError, recordReminderCheck } from "@/lib/jobs/worker-status"; -import { decrypt } from "@/lib/crypto"; import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; import { dispatchNotification } from "@/lib/notifications/dispatcher"; @@ -23,7 +23,6 @@ import { resolveSlotPhaseWindow, shouldMintMissedDoseRow, } from "@/lib/medications/scheduling/worker-helpers"; -import { deleteMessage } from "@/lib/telegram"; import { DEFAULT_PHASE_CONFIG, resolvePhaseThresholds, @@ -39,6 +38,7 @@ import { } from "@/lib/notifications/reminder-dedup"; import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day"; import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; +import { cleanupScheduledTelegramDeletions } from "./telegram-deletion-cleanup"; import { TRACKED_INTAKE_WHERE } from "@/lib/medications/intake-tracking"; import { sendsMedicationReminders } from "@/lib/medications/auto-mark-taken"; import { @@ -55,93 +55,40 @@ export interface ReminderCheckPayload { const getUserTodayBounds = getUserTodayBoundsUtil; /** - * Process expired TelegramScheduledDeletion records. - * Deletes messages from Telegram and removes the DB records. - * Called at the start of every reminder check (every 15 minutes). + * Every medication the tick may remind about, with its schedules, phase + * config, live-era revisions and the owner's timezone and locale. + * + * Active only. As-needed (PRN) medications never remind: they carry zero + * schedules anyway (the per-schedule loop would be a no-op), but the explicit + * predicate keeps them out of the tick's per-medication intake reads + * entirely. A medication with intake tracking off keeps its schedule rows as + * a record, so it is excluded explicitly: no reminder on any channel and no + * missed-dose placeholder (#1033). And none whose account is pending + * deletion: that record is frozen until it is erased or given back. */ -export async function cleanupScheduledTelegramDeletions(): Promise { - const prisma = getWorkerPrisma(); - try { - let totalDeleted = 0; - - // Process in batches until all expired records are handled - while (true) { - const expired = await prisma.telegramScheduledDeletion.findMany({ - where: { deleteAfter: { lte: new Date() } }, - take: 100, - }); - - if (expired.length === 0) break; - - // Group by userId to fetch bot token once per user - const byUser = new Map< - string, - { chatId: string; messageId: number; id: string }[] - >(); - for (const record of expired) { - const list = byUser.get(record.userId) ?? []; - list.push({ - chatId: record.chatId, - messageId: record.messageId, - id: record.id, - }); - byUser.set(record.userId, list); - } - - const deletedIds: string[] = []; - for (const [userId, messages] of byUser) { - const user = await prisma.user.findFirst({ - where: { id: userId, telegramBotToken: { not: null } }, - select: { telegramBotToken: true }, - }); - if (!user?.telegramBotToken) { - // No bot token — just clean up the records - deletedIds.push(...messages.map((m) => m.id)); - continue; - } - const botToken = decrypt(user.telegramBotToken); - for (const msg of messages) { - try { - await deleteMessage(botToken, msg.chatId, msg.messageId); - } catch { - // Best-effort: message may already be deleted - } - deletedIds.push(msg.id); - } - } - - if (deletedIds.length > 0) { - await prisma.telegramScheduledDeletion.deleteMany({ - where: { id: { in: deletedIds } }, - }); - totalDeleted += deletedIds.length; - } - } - - if (totalDeleted > 0) { - const { getEvent } = await import("@/lib/logging/context"); - getEvent()?.addMeta("telegram_scheduled_cleanup", totalDeleted); - } - - // v1.19.0 — prune expired force-reply correlation rows in the same - // tick (no new sweep). An unanswered mood-note prompt leaves a - // TelegramPromptContext row; once past `expiresAt` it can never bind a - // reply, so drop it. The prompt MESSAGE itself self-cleans through the - // scheduled-deletion rows above. - const prunedContexts = await prisma.telegramPromptContext.deleteMany({ - where: { expiresAt: { lte: new Date() } }, - }); - if (prunedContexts.count > 0) { - const { getEvent } = await import("@/lib/logging/context"); - getEvent()?.addMeta( - "telegram_prompt_context_cleanup", - prunedContexts.count, - ); - } - } catch (err) { - const { getEvent } = await import("@/lib/logging/context"); - getEvent()?.addWarning(`telegram-scheduled-cleanup failed: ${err}`); - } +function loadRemindableMedications(prisma: ReturnType) { + return prisma.medication.findMany({ + where: { + active: true, + asNeeded: false, + ...TRACKED_INTAKE_WHERE, + ...OWNED_BY_ACTIVE_ACCOUNT, + }, + include: { + schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, + phaseConfig: true, + scheduleRevisions: LIVE_ERA_REVISION_ARGS, + user: { + select: { + id: true, + timezone: true, + // Used to localise the reminder title / message / keyboard + // labels per user. Null falls back to the app default. + locale: true, + }, + }, + }, + }); } /** @@ -217,31 +164,7 @@ export async function handleReminderCheck( data: { snoozedUntil: null }, }); - // Get all active medications with schedules and phase config. - // v1.16.11 — as-needed (PRN) medications never remind: they carry - // zero schedules anyway (the per-schedule loop below would be a - // no-op), but the explicit predicate keeps them out of the tick's - // per-medication intake reads entirely. - // v1.39.1 (#1033) — a medication with intake tracking off keeps its - // schedule rows as a record, so it must be excluded explicitly: no - // reminder on any channel and no missed-dose placeholder. - const medications = await prisma.medication.findMany({ - where: { active: true, asNeeded: false, ...TRACKED_INTAKE_WHERE }, - include: { - schedules: { orderBy: MEDICATION_SCHEDULE_ORDER }, - phaseConfig: true, - scheduleRevisions: LIVE_ERA_REVISION_ARGS, - user: { - select: { - id: true, - timezone: true, - // Used to localise the reminder title / message / keyboard - // labels per user. Null falls back to the app default. - locale: true, - }, - }, - }, - }); + const medications = await loadRemindableMedications(prisma); // The per-slot facts the tick already emits as wide-event meta, counted // so the run's outcome carries them as scalars. diff --git a/src/lib/jobs/reminder/nightscout-sync.ts b/src/lib/jobs/reminder/nightscout-sync.ts index 01bb0b25c..06c12ecb9 100644 --- a/src/lib/jobs/reminder/nightscout-sync.ts +++ b/src/lib/jobs/reminder/nightscout-sync.ts @@ -9,6 +9,7 @@ * `./poll-cohort`; reminder-worker.ts owns the queue name, cron schedule, and * boss.work registration. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { recordNightscoutSyncFailure, syncUserNightscout, @@ -22,7 +23,7 @@ export const handleNightscoutSync = makePollCohortHandler({ findCohort: async (prisma) => { // The URL column is the connect marker (a public instance stores no token). const users = await prisma.user.findMany({ - where: { nightscoutUrlEncrypted: { not: null } }, + where: { nightscoutUrlEncrypted: { not: null }, ...ACTIVE_ACCOUNT }, select: { id: true }, }); return users.map((u) => u.id); diff --git a/src/lib/jobs/reminder/oura-sync.ts b/src/lib/jobs/reminder/oura-sync.ts index 1909c682c..59bbbfc3d 100644 --- a/src/lib/jobs/reminder/oura-sync.ts +++ b/src/lib/jobs/reminder/oura-sync.ts @@ -7,6 +7,7 @@ * lives in `./poll-cohort`; the queue name, cron schedule, and boss.work * registration live in reminder-worker.ts. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { recordOuraSyncFailure, syncUserOura } from "@/lib/oura/sync"; import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort"; @@ -16,7 +17,7 @@ export const handleOuraSync = makePollCohortHandler({ taskName: "job.oura_sync", findCohort: async (prisma) => { const users = await prisma.user.findMany({ - where: { ouraAccessTokenEncrypted: { not: null } }, + where: { ouraAccessTokenEncrypted: { not: null }, ...ACTIVE_ACCOUNT }, select: { id: true }, }); return users.map((u) => u.id); diff --git a/src/lib/jobs/reminder/polar-sync.ts b/src/lib/jobs/reminder/polar-sync.ts index b8c34c2f5..269148516 100644 --- a/src/lib/jobs/reminder/polar-sync.ts +++ b/src/lib/jobs/reminder/polar-sync.ts @@ -13,6 +13,7 @@ * reminder-worker.ts — no new queue: the workout leg rides the existing hourly * `polar-sync` tick (one extra request per connected user per hour). */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { recordPolarSyncFailure, syncUserPolar } from "@/lib/polar/sync"; import { syncUserPolarWorkouts } from "@/lib/polar/sync-workouts"; import type { SyncWriteResult } from "@/lib/outcome/written-outcome"; @@ -75,7 +76,7 @@ export const handlePolarSync = makePollCohortHandler({ taskName: "job.polar_sync", findCohort: async (prisma) => { const users = await prisma.user.findMany({ - where: { polarAccessTokenEncrypted: { not: null } }, + where: { polarAccessTokenEncrypted: { not: null }, ...ACTIVE_ACCOUNT }, select: { id: true }, }); return users.map((u) => u.id); diff --git a/src/lib/jobs/reminder/strava-sync.ts b/src/lib/jobs/reminder/strava-sync.ts index 660d2a9d8..964b1dfc0 100644 --- a/src/lib/jobs/reminder/strava-sync.ts +++ b/src/lib/jobs/reminder/strava-sync.ts @@ -11,6 +11,7 @@ * The queue name, cron schedule, and boss.work registration live in * `register-integration-sync.ts`. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { recordStravaFailure, syncUserStrava } from "@/lib/strava/sync"; import { makePollCohortHandler, type PollCohortPayload } from "./poll-cohort"; @@ -20,7 +21,7 @@ export const handleStravaSync = makePollCohortHandler({ taskName: "job.strava_sync", findCohort: async (prisma) => { const users = await prisma.user.findMany({ - where: { stravaAccessTokenEncrypted: { not: null } }, + where: { stravaAccessTokenEncrypted: { not: null }, ...ACTIVE_ACCOUNT }, select: { id: true }, }); return users.map((u) => u.id); diff --git a/src/lib/jobs/reminder/telegram-deletion-cleanup.ts b/src/lib/jobs/reminder/telegram-deletion-cleanup.ts new file mode 100644 index 000000000..4088ddb62 --- /dev/null +++ b/src/lib/jobs/reminder/telegram-deletion-cleanup.ts @@ -0,0 +1,100 @@ +/** + * The scheduled removal of Telegram messages the reminder tick sent, run at + * the start of every tick. Moved out of `medication-reminder-check.ts` + * unchanged: it reads no medication and no account beyond the bot token of + * the messages it removes. + */ +import { decrypt } from "@/lib/crypto"; +import { deleteMessage } from "@/lib/telegram"; +import { getWorkerPrisma } from "./shared"; + +/** + * Process expired TelegramScheduledDeletion records. + * Deletes messages from Telegram and removes the DB records. + * Called at the start of every reminder check (every 15 minutes). + */ +export async function cleanupScheduledTelegramDeletions(): Promise { + const prisma = getWorkerPrisma(); + try { + let totalDeleted = 0; + + // Process in batches until all expired records are handled + while (true) { + // account-deletion: removes Telegram messages already sent; cleanup, not a reminder. + const expired = await prisma.telegramScheduledDeletion.findMany({ + where: { deleteAfter: { lte: new Date() } }, + take: 100, + }); + + if (expired.length === 0) break; + + // Group by userId to fetch bot token once per user + const byUser = new Map< + string, + { chatId: string; messageId: number; id: string }[] + >(); + for (const record of expired) { + const list = byUser.get(record.userId) ?? []; + list.push({ + chatId: record.chatId, + messageId: record.messageId, + id: record.id, + }); + byUser.set(record.userId, list); + } + + const deletedIds: string[] = []; + for (const [userId, messages] of byUser) { + const user = await prisma.user.findFirst({ + where: { id: userId, telegramBotToken: { not: null } }, + select: { telegramBotToken: true }, + }); + if (!user?.telegramBotToken) { + // No bot token — just clean up the records + deletedIds.push(...messages.map((m) => m.id)); + continue; + } + const botToken = decrypt(user.telegramBotToken); + for (const msg of messages) { + try { + await deleteMessage(botToken, msg.chatId, msg.messageId); + } catch { + // Best-effort: message may already be deleted + } + deletedIds.push(msg.id); + } + } + + if (deletedIds.length > 0) { + await prisma.telegramScheduledDeletion.deleteMany({ + where: { id: { in: deletedIds } }, + }); + totalDeleted += deletedIds.length; + } + } + + if (totalDeleted > 0) { + const { getEvent } = await import("@/lib/logging/context"); + getEvent()?.addMeta("telegram_scheduled_cleanup", totalDeleted); + } + + // Prune expired force-reply correlation rows in the same + // tick (no new sweep). An unanswered mood-note prompt leaves a + // TelegramPromptContext row; once past `expiresAt` it can never bind a + // reply, so drop it. The prompt MESSAGE itself self-cleans through the + // scheduled-deletion rows above. + const prunedContexts = await prisma.telegramPromptContext.deleteMany({ + where: { expiresAt: { lte: new Date() } }, + }); + if (prunedContexts.count > 0) { + const { getEvent } = await import("@/lib/logging/context"); + getEvent()?.addMeta( + "telegram_prompt_context_cleanup", + prunedContexts.count, + ); + } + } catch (err) { + const { getEvent } = await import("@/lib/logging/context"); + getEvent()?.addWarning(`telegram-scheduled-cleanup failed: ${err}`); + } +} diff --git a/src/lib/jobs/reminder/whoop-sync.ts b/src/lib/jobs/reminder/whoop-sync.ts index bf22df904..51bf621fc 100644 --- a/src/lib/jobs/reminder/whoop-sync.ts +++ b/src/lib/jobs/reminder/whoop-sync.ts @@ -4,6 +4,7 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { type Job } from "pg-boss"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; @@ -96,6 +97,7 @@ export async function runWhoopResourceSync( if (targets.length === 0) { // Cron tick — re-walk every connection's collection. const connections = await prisma.whoopConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }); targets.push(...connections); diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index ca5de77a6..39e509919 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -4,6 +4,7 @@ * Extracted from reminder-worker.ts, which owns the queue names, cron * schedules, and boss.work registrations. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { type Job } from "pg-boss"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError, recordWithingsSync } from "@/lib/jobs/worker-status"; @@ -85,6 +86,7 @@ export async function handleWithingsFallbackSync( evt.addWarning("Withings plan-park release failed; continuing"); } const connections = await prisma.withingsConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }); @@ -193,6 +195,7 @@ export async function handleWithingsEcgSync( // No user-specific enqueue → cron fallback iterating every connection. if (targets.length === 0) { const connections = await prisma.withingsConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }); targets.push(...connections); @@ -277,6 +280,7 @@ export async function handleWithingsActivitySync( // No user-specific enqueue → cron fallback iterating everyone. if (targets.length === 0) { const connections = await prisma.withingsConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }); targets.push(...connections); @@ -350,6 +354,7 @@ export async function handleWithingsSleepSync( } if (targets.length === 0) { const connections = await prisma.withingsConnection.findMany({ + where: OWNED_BY_ACTIVE_ACCOUNT, select: { userId: true }, }); targets.push(...connections); diff --git a/src/lib/jobs/sleep-timeline-backfill.ts b/src/lib/jobs/sleep-timeline-backfill.ts index 2bec8fae6..7a154a556 100644 --- a/src/lib/jobs/sleep-timeline-backfill.ts +++ b/src/lib/jobs/sleep-timeline-backfill.ts @@ -30,6 +30,7 @@ * never provisions it and the boot enqueue silently never drains (the v1.4.37 * dead-queue class). */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -153,11 +154,11 @@ export async function enqueueBootTimeSleepTimelineBackfill( try { const [whoop, withings] = await Promise.all([ prisma.whoopConnection.findMany({ - where: { sleepTimelineBackfillAt: null }, + where: { sleepTimelineBackfillAt: null, ...OWNED_BY_ACTIVE_ACCOUNT }, select: { userId: true }, }), prisma.withingsConnection.findMany({ - where: { sleepTimelineBackfillAt: null }, + where: { sleepTimelineBackfillAt: null, ...OWNED_BY_ACTIVE_ACCOUNT }, select: { userId: true }, }), ]); diff --git a/src/lib/jobs/soft-delete-retention.ts b/src/lib/jobs/soft-delete-retention.ts index e2a9e2bf0..9d3a38529 100644 --- a/src/lib/jobs/soft-delete-retention.ts +++ b/src/lib/jobs/soft-delete-retention.ts @@ -33,6 +33,7 @@ * `soft-delete-retention-register.test.ts` derives the set from the schema, * so a new nulling key into a purged model cannot ship without one. */ +// account-deletion(file): a retention sweep over rows already deleted, with its own retention clock; it does no work for an account. import type { PrismaClient } from "@/generated/prisma/client"; import { TOMBSTONE_RETENTION_DAYS } from "@/lib/auth/native-client"; import { getCoachMessageRetentionDays } from "@/lib/jobs/coach-message-cleanup"; diff --git a/src/lib/jobs/status-cron-candidates.ts b/src/lib/jobs/status-cron-candidates.ts index 25a545ddf..78cb63291 100644 --- a/src/lib/jobs/status-cron-candidates.ts +++ b/src/lib/jobs/status-cron-candidates.ts @@ -34,6 +34,7 @@ * `briefing` switch is on; when it is off the 04:30 pass no-ops, so the * 02:00 batch keeps covering everyone. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getAssistantFlags } from "@/lib/feature-flags"; import { PREGENERATE_STALE_MS } from "@/lib/jobs/insight-pregenerate"; @@ -53,7 +54,10 @@ export async function findStatusCronCandidates( const optedOut = await userIdsWithModuleOff(prisma, "insights"); const users = await prisma.user.findMany({ - where: optedOut.length > 0 ? { id: { notIn: optedOut } } : {}, + where: { + ...ACTIVE_ACCOUNT, + ...(optedOut.length > 0 ? { id: { notIn: optedOut } } : {}), + }, select: { id: true, locale: true, insightsCachedAt: true }, }); if (users.length === 0) return []; diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts index 0c1108a9e..51575b5fd 100644 --- a/src/lib/jobs/step-consolidation-repair.ts +++ b/src/lib/jobs/step-consolidation-repair.ts @@ -38,6 +38,7 @@ * `src/lib/jobs/reminder/register-rollup.ts` or pg-boss never provisions it * and the boot enqueue silently never drains (the v1.4.37 dead-queue class). */ +import { dropPendingDeletion } from "@/lib/account-deletion/active-account"; import type { MeasurementSource } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; @@ -269,7 +270,9 @@ export async function enqueueBootTimeStepConsolidationRepair( // Splice-free: every literal is a compile-time constant; the horizon // is a bound parameter. `NOT EXISTS` excludes wedge-blocked rows so the // set shrinks to empty once every resurrectable row is live. - const users = await prisma.$queryRaw>` + const users = await dropPendingDeletion( + prisma, + await prisma.$queryRaw>` SELECT DISTINCT t."user_id" AS id FROM measurements t WHERE t."type" = 'ACTIVITY_STEPS' @@ -287,7 +290,9 @@ export async function enqueueBootTimeStepConsolidationRepair( AND l."sleep_stage" IS NULL AND l."deleted_at" IS NULL ) - `; + `, + (u) => u.id, + ); if (users.length === 0) { return { enqueued: 0, skipped: 0, error: null }; diff --git a/src/lib/jobs/step-consolidation.ts b/src/lib/jobs/step-consolidation.ts index e49747268..c85b18c70 100644 --- a/src/lib/jobs/step-consolidation.ts +++ b/src/lib/jobs/step-consolidation.ts @@ -11,6 +11,7 @@ * `src/lib/jobs/reminder-worker.ts` so pg-boss provisions it at boot; * an unregistered queue silently never drains. */ +import { dropPendingDeletion } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -121,7 +122,9 @@ export async function enqueueBootTimeStepConsolidation( // GOOGLE_HEALTH / FITBIT daily totals (`stats:steps:…`) from the boot // sweep that this closes. Splice-free: every literal is a compile-time // constant, not user input. - const users = await prisma.$queryRaw>` + const users = await dropPendingDeletion( + prisma, + await prisma.$queryRaw>` SELECT DISTINCT m."user_id" AS id FROM measurements m WHERE m."type" = 'ACTIVITY_STEPS' @@ -131,7 +134,9 @@ export async function enqueueBootTimeStepConsolidation( m."external_id" IS NULL OR m."external_id" NOT LIKE 'stats:%' ) - `; + `, + (u) => u.id, + ); if (users.length === 0) { return { enqueued: 0, skipped: 0, error: null }; diff --git a/src/lib/jobs/strava-backfill.ts b/src/lib/jobs/strava-backfill.ts index a18a85d73..ceb71d57e 100644 --- a/src/lib/jobs/strava-backfill.ts +++ b/src/lib/jobs/strava-backfill.ts @@ -14,6 +14,7 @@ * `register-integration-sync.ts` or pg-boss never provisions it and the boot * enqueue silently never drains (the v1.4.37 dead-queue class). */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -194,6 +195,7 @@ export async function enqueueBootTimeStravaBackfill( where: { stravaAccessTokenEncrypted: { not: null }, stravaBackfillCompletedAt: null, + ...ACTIVE_ACCOUNT, }, select: { id: true }, }); diff --git a/src/lib/jobs/subsystem-surface.ts b/src/lib/jobs/subsystem-surface.ts index d90560c84..23354bcdd 100644 --- a/src/lib/jobs/subsystem-surface.ts +++ b/src/lib/jobs/subsystem-surface.ts @@ -101,6 +101,7 @@ const subsystemSurface = { "data-backup-offhost": { audience: "system" }, "data-restore-drill": { audience: "system" }, "offhost-backup-purge": { audience: "system" }, + "account-deletion-purge": { audience: "system" }, "backup-restore": { audience: "system" }, "host-metric-sample": { audience: "system" }, "feedback-aggregator": { audience: "system" }, diff --git a/src/lib/jobs/tls-pin-monitor.ts b/src/lib/jobs/tls-pin-monitor.ts index 16cc93c10..2ead46354 100644 --- a/src/lib/jobs/tls-pin-monitor.ts +++ b/src/lib/jobs/tls-pin-monitor.ts @@ -28,6 +28,7 @@ * set from, and an unset baseline fails LOUD ("not configured"), never by * auto-adopting whatever it observed. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import type { PrismaClient } from "@/generated/prisma/client"; import { getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -62,7 +63,7 @@ async function alertAdminsOfPinChange( knownPins: string[], ): Promise { const admins = await prisma.user.findMany({ - where: { role: "ADMIN" }, + where: { role: "ADMIN", ...ACTIVE_ACCOUNT }, select: { id: true }, }); diff --git a/src/lib/jobs/whoop-backfill.ts b/src/lib/jobs/whoop-backfill.ts index d61822d8a..5060bb717 100644 --- a/src/lib/jobs/whoop-backfill.ts +++ b/src/lib/jobs/whoop-backfill.ts @@ -16,6 +16,7 @@ * `src/lib/jobs/reminder-worker.ts` or pg-boss never provisions it and the * boot enqueue silently never drains (the v1.4.37 dead-queue class). */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -135,7 +136,7 @@ export async function enqueueBootTimeWhoopBackfill( try { const connections = await prisma.whoopConnection.findMany({ - where: { backfillCompletedAt: null }, + where: { backfillCompletedAt: null, ...OWNED_BY_ACTIVE_ACCOUNT }, select: { userId: true }, }); diff --git a/src/lib/managed-profiles/__tests__/lifecycle-transaction-budget.test.ts b/src/lib/managed-profiles/__tests__/lifecycle-transaction-budget.test.ts index ded662439..ea8c29d03 100644 --- a/src/lib/managed-profiles/__tests__/lifecycle-transaction-budget.test.ts +++ b/src/lib/managed-profiles/__tests__/lifecycle-transaction-budget.test.ts @@ -7,7 +7,7 @@ * on every retry. Pinned at the call: a budget declared and not passed is the * failure this replaces. */ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const { transaction } = vi.hoisted(() => ({ transaction: vi.fn() })); @@ -44,6 +44,10 @@ function fakeTx() { }; } +afterEach(() => { + vi.unstubAllEnvs(); +}); + beforeEach(() => { transaction.mockReset(); transaction.mockImplementation( @@ -69,6 +73,8 @@ describe("account deletion transaction budget", () => { }); it("is passed by the managed profile deletion", async () => { + // The immediate erasure: the transaction that ends in `user.delete`. + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0"); await deleteManagedProfile({ profileId: "profile-1", guardianId: "guardian-1", diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 899d673f7..8c2d9c73f 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -1,7 +1,12 @@ +import { kickOffhostPurge } from "@/lib/jobs/offhost-purge"; import { - kickOffhostPurge, - requestOffhostPurge, -} from "@/lib/jobs/offhost-purge"; + accountDeletionGraceDays, + accountDeletionPurgeAt, +} from "@/lib/account-deletion/grace"; +import { + eraseAccount, + lockAccountForDeletion, +} from "@/lib/account-deletion/lifecycle"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; @@ -49,7 +54,11 @@ export async function withManagedProfileLock( tx: Transaction, profileId: string, operation: ( - profile: { id: string; managedProfileAt: Date | null } | null, + profile: { + id: string; + managedProfileAt: Date | null; + deletionRequestedAt: Date | null; + } | null, ) => Promise, ): Promise { await tx.$queryRaw` @@ -58,7 +67,7 @@ export async function withManagedProfileLock( `; const profile = await tx.user.findUnique({ where: { id: profileId }, - select: { id: true, managedProfileAt: true }, + select: { id: true, managedProfileAt: true, deletionRequestedAt: true }, }); return operation(profile); } @@ -110,10 +119,18 @@ export const ACCOUNT_DELETION_TRANSACTION_OPTIONS = { * Keep account deletion and its Guardian checks inside the same transaction. * Profiles are locked in id order so an account protecting more than one * profile cannot deadlock with a concurrent lifecycle operation. + * + * `erasing: false` is the request that only locks the account for a grace + * window: the Guardian invariant is decided exactly as for an erasure, so a + * request that could never complete is refused while the person is still + * there to act on the answer, but the grants stay and so does any provider + * app a profile borrowed through them. The purge job runs this again with + * `erasing: true` when the window ends, and the invariant is decided again. */ export async function deleteGuardianAccountWithLifecycle( guardianId: string, deleteAccount: (tx: Transaction) => Promise, + options: { erasing: boolean } = { erasing: true }, ): Promise { return prisma.$transaction(async (tx) => { const now = new Date(); @@ -150,6 +167,7 @@ export async function deleteGuardianAccountWithLifecycle( } const result = await deleteAccount(tx); + if (!options.erasing) return result; // The guardian's grants went with their account; a provider app any of // their profiles borrowed from them goes too. for (const { grantorId } of grants) { @@ -224,13 +242,32 @@ export async function protectManagedProfilesDuringDataWipe( return affectedProfiles.map((p) => p.grantorId); } +/** + * Delete a managed profile on a Guardian's word. + * + * With a grace window the profile is locked rather than erased (see + * `lockAccountForDeletion`): it leaves every Guardian's roster, nothing acts + * for it, and the purge job erases it when the window ends unless an admin + * undoes it first. Without one (`ACCOUNT_DELETION_GRACE_DAYS=0`) it is + * erased here, as before. Answers when it will be erased, or null when it + * already was. + */ export async function deleteManagedProfile(input: { profileId: string; guardianId: string; -}): Promise { +}): Promise<{ scheduledPurgeAt: Date | null }> { + const requestedAt = new Date(); + const purgeAt = accountDeletionPurgeAt( + requestedAt, + accountDeletionGraceDays(), + ); await prisma.$transaction(async (tx) => { await withManagedProfileLock(tx, input.profileId, async (profile) => { - if (!profile) throw new ManagedProfileLifecycleError("not_found"); + // A profile already pending deletion has left every roster; to its + // Guardians it is gone, and only an admin can bring it back. + if (!profile || profile.deletionRequestedAt) { + throw new ManagedProfileLifecycleError("not_found"); + } if (!profile.managedProfileAt) { throw new ManagedProfileLifecycleError("not_managed"); } @@ -247,14 +284,26 @@ export async function deleteManagedProfile(input: { await auditLog("managed_profile.deleted", { userId: input.guardianId, - details: { profileId: profile.id }, + details: { + profileId: profile.id, + scheduledPurgeAt: purgeAt?.toISOString() ?? null, + }, client: tx, }); - await requestOffhostPurge(tx, profile.id, "managed_profile_deleted"); - await tx.user.delete({ where: { id: profile.id } }); + if (purgeAt) { + await lockAccountForDeletion(tx, { + userId: profile.id, + requestedById: input.guardianId, + requestedAt, + purgeAt, + }); + return; + } + await eraseAccount(tx, profile.id, "managed_profile_deleted"); }); }, ACCOUNT_DELETION_TRANSACTION_OPTIONS); - await kickOffhostPurge(); + if (!purgeAt) await kickOffhostPurge(); + return { scheduledPurgeAt: purgeAt }; } /** @@ -330,9 +379,13 @@ export async function readManagedProfileForGuardian(input: { }): Promise { const profile = await prisma.user.findUnique({ where: { id: input.profileId }, - select: { ...MANAGED_PROFILE_VIEW_SELECT, managedProfileAt: true }, + select: { + ...MANAGED_PROFILE_VIEW_SELECT, + managedProfileAt: true, + deletionRequestedAt: true, + }, }); - if (!profile?.managedProfileAt) return null; + if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null; const guardian = await prisma.accountGrant.findFirst({ where: { @@ -387,7 +440,9 @@ export async function updateManagedProfile(input: { }): Promise { return prisma.$transaction(async (tx) => { return withManagedProfileLock(tx, input.profileId, async (profile) => { - if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile || profile.deletionRequestedAt) { + throw new ManagedProfileLifecycleError("not_found"); + } if (!profile.managedProfileAt) { throw new ManagedProfileLifecycleError("not_managed"); } diff --git a/src/lib/measurements/consolidation-base.ts b/src/lib/measurements/consolidation-base.ts index 2b717a0d3..c74e79e52 100644 --- a/src/lib/measurements/consolidation-base.ts +++ b/src/lib/measurements/consolidation-base.ts @@ -26,6 +26,7 @@ * is no value-level cycle between the two — a cycle would otherwise trip * a module-init TDZ in the production bundle. */ +import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { annotate } from "@/lib/logging/context"; import { AccountRestoreInProgressError } from "@/lib/export/restore-lock"; import type { @@ -115,6 +116,7 @@ export async function loadConsolidationUsers( select: { id: true, timezone: true }, }) : prismaClient.user.findMany({ + where: ACTIVE_ACCOUNT, select: { id: true, timezone: true }, }); } @@ -354,6 +356,7 @@ export async function* iterateSourcePages< } : baseWhere; + // account-deletion: pages through the rows its caller scoped (`baseWhere`), one account at a time. const page = (await prismaClient.measurement.findMany({ where, select: scanSelect, diff --git a/src/lib/notifications/__tests__/client-managed-apns.test.ts b/src/lib/notifications/__tests__/client-managed-apns.test.ts index 47984be11..d2a864b2a 100644 --- a/src/lib/notifications/__tests__/client-managed-apns.test.ts +++ b/src/lib/notifications/__tests__/client-managed-apns.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), upsert: vi.fn(), }, - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), count: vi.fn().mockResolvedValue(0) }, }, })); diff --git a/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts b/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts index 5dbf4434c..c3b88f791 100644 --- a/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts +++ b/src/lib/notifications/__tests__/dispatcher-global-channel-switch.test.ts @@ -16,7 +16,7 @@ vi.mock("@/lib/db", () => ({ appSettings: { findUnique: vi.fn() }, notificationChannel: { findMany: vi.fn(), update: vi.fn() }, pushAttempt: { create: vi.fn() }, - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), count: vi.fn().mockResolvedValue(0) }, }, })); diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index f7ac2fa05..7aa7046e9 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -18,7 +18,7 @@ vi.mock("@/lib/db", () => ({ update: vi.fn(), upsert: vi.fn(), }, - user: { findUnique: vi.fn() }, + user: { findUnique: vi.fn(), count: vi.fn().mockResolvedValue(0) }, auditLog: { create: vi.fn() }, }, })); diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index c68b9ea35..c281ec9b4 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; import { activeGuardianWhere, withManagedProfileLock, @@ -91,6 +92,12 @@ export async function resolveManagedGuardianRecipientIds( * The final authorization is a separate short transaction that commits a * durable egress claim immediately before the provider operation starts. */ +/** No delivery: the record or the recipient is pending deletion. */ +function deletionPending(): null { + getEvent()?.addMeta("notification_skipped_deletion_pending", true); + return null; +} + export async function resolveNotificationDeliveryIdentity( payload: NotificationPayload, ): Promise { @@ -107,9 +114,14 @@ export async function resolveNotificationDeliveryIdentity( const record = await prisma.user.findUnique({ where: { id: recordUserId }, - select: { managedProfileAt: true }, + select: { managedProfileAt: true, deletionRequestedAt: true }, }); if (!record) return null; + // Nothing is sent about, or to, an account pending deletion: it is locked, + // and the person asked to be gone. Every reminder, digest and nudge passes + // here, so a job that enumerates without the shared filter + // (`active-account.ts`) still sends nothing. + if (record.deletionRequestedAt != null) return deletionPending(); if (record.managedProfileAt == null) { return recipientUserId === recordUserId @@ -120,6 +132,10 @@ export async function resolveNotificationDeliveryIdentity( if (recipientUserId === recordUserId) return null; if (!isManagedGuardianFanoutAllowed(payload)) return null; + const guardianPending = await prisma.user.count({ + where: { id: recipientUserId, deletionRequestedAt: { not: null } }, + }); + if (guardianPending > 0) return deletionPending(); return withManagedGuardianAuthorization(recordUserId, recipientUserId, () => Promise.resolve({ recordUserId, recipientUserId, managed: true }), diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index eefaaf11d..bccb253a6 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -40,6 +40,7 @@ import { DEFAULT_ERROR_CODES } from "@/lib/api/default-error-codes"; */ export const ERROR_CODE_CATALOGUE: Readonly> = { + account: ["account.deletion.not_pending"], activity: ["activity.calendar.future_date", "activity.calendar.too_old"], ai: [ "ai.budget.exceeded", @@ -72,6 +73,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "anamnesis.lifeSections.invalid", ], auth: [ + "auth.account.deletion_pending", "auth.admin.required", "auth.already_authenticated", "auth.mfa.code_invalid", @@ -132,6 +134,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = ], "cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"], "disable-coach": ["disable-coach.body.invalid_json"], + data_wipe: ["data_wipe.copy_failed"], dawarich: [ "dawarich.credentials_rejected", "dawarich.home_place_unknown", diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index a42bc4a88..348cbd1e1 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -1177,18 +1177,23 @@ export const accountSharingPaths: NonNullable = { tags: ["Account sharing"], summary: "Delete a managed record", description: - "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second factor.", + "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second factor.\n\n" + + "With a deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 days by default) the record is locked rather than erased: it leaves every Guardian's view at once (reads of it answer 404), nothing acts for it, and the purge job erases it at `scheduledPurgeAt`. Until then an admin can undo the deletion. Without a window it is erased now and `scheduledPurgeAt` is null.", requestParams: { path: z.object({ id: z.string() }), }, responses: { ...stdResponses, "200": { - description: "The record is gone.", + description: + "The record is gone from the Guardian's view; it is erased at `scheduledPurgeAt`, or already was when that is null.", content: { "application/json": { schema: dataEnvelope( - z.object({ deleted: z.literal(true) }), + z.object({ + deleted: z.literal(true), + scheduledPurgeAt: isoDateTime().nullable(), + }), "DeletedManagedProfileEnvelope", ), }, diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 409078787..90e3f004d 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -938,7 +938,8 @@ export const authPaths: NonNullable = { }, "403": { description: - "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account. A sign-in to any other account on that browser is refused before its password is checked, so the answer is the same whether the password was right or wrong.", + "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account. A sign-in to any other account on that browser is refused before its password is checked, so the answer is the same whether the password was right or wrong.\n\n" + + "Also 403 `auth.account.deletion_pending`, after the password was proved and before a second factor is asked for: the account is pending deletion and locked. `meta.purgeAt` is when it is erased; an admin can undo the deletion until then. Every other sign-in surface (passkey, second-factor completion, refresh, the native token exchanges) refuses with the same code.", content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 2a269fcb5..82bb91ffd 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -8,6 +8,7 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; +import { isoDateTime } from "@/lib/validations/datetime"; import { emailSettingsSchema, notificationChannelEnabledSchema, @@ -207,6 +208,12 @@ const privacySummaryResponse = z .describe( "Fixed 90-day window on the push-attempt and mood-dispatch ledgers.", ), + accountDeletionGraceDays: z + .number() + .int() + .describe( + "Days between asking to delete the account (or a managed profile, or every entry) and the erasure completing. The account is locked at once; until the window ends an admin can undo the deletion, or restore the copy a data wipe took first. 0 means the erasure completes immediately. Read from the same resolver the purge job uses.", + ), }) .describe( "Retention windows as the privacy dashboard states them. Off-host backup retention is operator infrastructure rather than an app setting and is deliberately absent — it is disclosed as prose in the UI, not as a number here.", @@ -255,6 +262,11 @@ const dataWipeResponse = z .describe( "Per-model row counts, listing only the models that actually had rows. A model absent from this map had nothing to delete — it was not skipped.", ), + restorableUntil: isoDateTime() + .nullable() + .describe( + "Until when an admin can restore the record from the account copy taken immediately before the wipe; the off-host copies from before the wipe are kept until then too. Null when the instance has no deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS=0`): the wipe was final.", + ), }) .meta({ id: "DataWipeResponse" }); @@ -623,9 +635,10 @@ export const settingsPaths: NonNullable = { "/api/settings/account": { delete: { tags: ["Auth"], - summary: "Permanently delete the account and everything in it", + summary: "Delete the account and everything in it", description: - "Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There is no undo and no grace period.\n\n" + + "Erasure, after a grace window. With `ACCOUNT_DELETION_GRACE_DAYS` above 0 (14 by default) the account is locked at once — every session, refresh token, API token, connector and share link is revoked, sign-in is refused with 403 `auth.account.deletion_pending` and the erasure date in `meta.purgeAt`, and nothing acts for the account any more — and the purge job erases it when the window ends. Until then an admin can undo the deletion, and the record is exactly as it was. `scheduledPurgeAt` in the answer is that date. With the window set to 0 the account is erased in this request, as before, and `scheduledPurgeAt` is null.\n\n" + + "The erasure itself: the user row goes, every related row cascades with it, the off-host copies are purged, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned.\n\n" + "Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" + "How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from the web.\n\n" + "Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended.", @@ -635,11 +648,15 @@ export const settingsPaths: NonNullable = { }, responses: { "200": { - description: "Account and all associated data deleted.", + description: + "Account locked for deletion (erased at `scheduledPurgeAt`), or erased now when there is no grace window (`scheduledPurgeAt` null).", content: { "application/json": { schema: dataEnvelope( - z.object({ deleted: z.boolean() }), + z.object({ + deleted: z.boolean(), + scheduledPurgeAt: isoDateTime().nullable(), + }), "DeleteAccountEnvelope", ), }, @@ -860,6 +877,7 @@ export const settingsPaths: NonNullable = { "The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record.\n\n" + "The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything.\n\n" + "One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data.\n\n" + + "With a deletion grace window (`ACCOUNT_DELETION_GRACE_DAYS`, 14 days by default) the whole record is first stored as an account copy of its own (`PRE_WIPE_`), through the writer the weekly copy uses. The wipe leaves that copy in place, an admin can restore it until `restorableUntil`, and the off-host purge waits until then too. A copy that cannot be written stops the wipe before anything is deleted: 503 `data_wipe.copy_failed`.\n\n" + "Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" + "One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.\n\n" + "A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again rather than retrying with the same credential.", @@ -882,6 +900,11 @@ export const settingsPaths: NonNullable = { "The caller is the last Guardian of a managed profile (`meta.errorCode` = `managed_profile.guardian.required`). Nothing was deleted — the managed records are locked for the whole transaction, so the refusal is atomic rather than partial. Add another Guardian, or delete the managed profile, first.", content: { "application/json": { schema: errorEnvelope } }, }, + "503": { + description: + "The account copy kept before a wipe could not be written (`meta.errorCode` = `data_wipe.copy_failed`), so nothing was deleted. Only with a deletion grace window.", + content: { "application/json": { schema: errorEnvelope } }, + }, "413": { description: "Request body exceeds 65536 bytes. Nothing was deleted.", content: { "application/json": { schema: errorEnvelope } }, diff --git a/src/lib/telegram-webhook-handlers.ts b/src/lib/telegram-webhook-handlers.ts index 23301c3eb..13cb85c13 100644 --- a/src/lib/telegram-webhook-handlers.ts +++ b/src/lib/telegram-webhook-handlers.ts @@ -1,3 +1,4 @@ +import { activeAccount } from "@/lib/account-deletion/active-account"; import { prisma } from "@/lib/db"; import { isP2025 } from "@/lib/prisma-errors"; import { decrypt } from "@/lib/crypto"; @@ -147,9 +148,8 @@ async function isManagedReminderCallback(input: { ); } -// v1.19.0 — the auto-delete window dropped from 1 h to ~30 min and moved to -// the shared `scheduleTelegramAutoDelete` helper so the inbound (free-text) -// and outbound (unanswered prompt) paths agree on the window. +// The auto-delete window lives in `scheduleTelegramAutoDelete`, so the inbound +// (free-text) and outbound (unanswered prompt) paths agree on it. async function scheduleAutoDelete( userId: string, chatId: string, @@ -160,11 +160,11 @@ async function scheduleAutoDelete( async function findTelegramUser(chatId: string) { return prisma.user.findFirst({ - where: { + where: activeAccount({ telegramEnabled: true, telegramChatId: chatId, telegramBotToken: { not: null }, - }, + }), select: { id: true, telegramBotToken: true, diff --git a/src/lib/whoop/webhook-handler.ts b/src/lib/whoop/webhook-handler.ts index 63cdbc3f1..7df6689d9 100644 --- a/src/lib/whoop/webhook-handler.ts +++ b/src/lib/whoop/webhook-handler.ts @@ -179,9 +179,11 @@ export async function processWhoopNotification( const connection = await prisma.whoopConnection.findUnique({ where: { whoopUserId }, - select: { userId: true }, + select: { userId: true, user: { select: { deletionRequestedAt: true } } }, }); - if (!connection) { + // A record pending deletion is frozen: its notifications sync nothing, and + // the answer is the one an unknown account gets, so WHOOP stops retrying. + if (!connection || connection.user?.deletionRequestedAt != null) { // Unknown / disconnected user. Return 200 so WHOOP stops retrying. annotate({ action: { name: "whoop.webhook.unknown_user" } }); return NextResponse.json({ status: "unknown_user" }, { status: 200 }); diff --git a/src/lib/withings/webhook-handler.ts b/src/lib/withings/webhook-handler.ts index f831c98ca..b60fdaeb3 100644 --- a/src/lib/withings/webhook-handler.ts +++ b/src/lib/withings/webhook-handler.ts @@ -23,6 +23,7 @@ * by a single proxy rule) is the largest shift Withings supports * end-to-end. */ +import { OWNED_BY_ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account"; import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { syncUserMeasurements } from "@/lib/withings/sync"; @@ -143,8 +144,9 @@ export async function processWithingsNotification( // A Withings account belongs to one record; callbacks refuse a second // holder. A pair linked before that rule routes to the record that linked // the account first, never to whichever row a lookup happens to find. + // A record pending deletion is frozen: its notifications sync nothing. const holders = await prisma.withingsConnection.findMany({ - where: { withingsUserId }, + where: { withingsUserId, ...OWNED_BY_ACTIVE_ACCOUNT }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: { userId: true }, take: 2, diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index ff5e45346..7874b638e 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -2680,6 +2680,9 @@ "createdAt": "", "updatedAt": "", "managedProfileAt": null, + "deletionRequestedAt": null, + "deletionPurgeAt": null, + "deletionRequestedById": null, "heightCm": 2.5, "dateOfBirth": "2026-06-30T12:34:56.789Z", "gender": "FEMALE", diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index b73ea56a6..3a9a76399 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -2174,6 +2174,9 @@ "createdAt": "", "updatedAt": "", "managedProfileAt": null, + "deletionRequestedAt": null, + "deletionPurgeAt": null, + "deletionRequestedById": null, "heightCm": 2.5, "dateOfBirth": "2026-06-30T12:34:56.789Z", "gender": "FEMALE", diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 901ab0a0f..598d0504e 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -819,6 +819,9 @@ "createdAt": "", "updatedAt": "", "managedProfileAt": null, + "deletionRequestedAt": null, + "deletionPurgeAt": null, + "deletionRequestedById": null, "heightCm": null, "dateOfBirth": null, "gender": null, diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index bc26e8895..46b916981 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -959,6 +959,9 @@ "createdAt": "", "updatedAt": "", "managedProfileAt": null, + "deletionRequestedAt": null, + "deletionPurgeAt": null, + "deletionRequestedById": null, "heightCm": null, "dateOfBirth": null, "gender": null, diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 1e9a3bc58..92fc81a50 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -2285,6 +2285,9 @@ "createdAt": "2026-06-30T12:34:56.789Z", "updatedAt": "2026-05-07T00:48:00.000Z", "managedProfileAt": "2026-06-30T12:34:56.789Z", + "deletionRequestedAt": "2026-06-30T12:34:56.789Z", + "deletionPurgeAt": "2026-06-30T12:34:56.789Z", + "deletionRequestedById": "rt-User-deletionRequestedById", "heightCm": 2.5, "dateOfBirth": "2026-06-30T12:34:56.789Z", "gender": "FEMALE", diff --git a/tests/integration/account-deletion-grace.test.ts b/tests/integration/account-deletion-grace.test.ts new file mode 100644 index 000000000..cb7f1f94d --- /dev/null +++ b/tests/integration/account-deletion-grace.test.ts @@ -0,0 +1,541 @@ +/** + * Account deletion, data wipe and managed-profile deletion wait out a grace + * window, against real Postgres. + * + * Everything here goes through production code: the deletion and wipe + * routes, the sign-in route, the admin undo route, the restore route and job, + * the purge job, and the queries background passes enumerate accounts with. + * Mutations that must turn this red: + * - erase in the route again (the account row is gone after the request); + * - lock without revoking credentials (the session and token survive); + * - drop the pending check from the session resolver (the late session + * reads `/api/auth/me`); + * - drop the predicate from the off-host cohort or the pregenerate query + * (the pending account is enumerated); + * - let the wipe delete its own `PRE_WIPE_` copy (nothing to restore); + * - let the purge job erase an account that is not yet due. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const { hashPassword } = await import("@/lib/auth/password"); + +const PASSWORD = "Correct horse battery staple!42"; +const DAY_MS = 24 * 60 * 60 * 1000; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.rateLimit.deleteMany(); + cookieJar.clear(); + headerJar.clear(); + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function jsonRequest(path: string, method: string, body?: unknown) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +/** An account with a password, a small record and a stored weekly copy. */ +async function seedAccount(username: string, role = "USER") { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username, + email: `${username}@example.test`, + role, + passwordHash: await hashPassword(PASSWORD), + onboardingCompletedAt: new Date(), + }, + }); + await prisma.measurement.createMany({ + data: [0, 1, 2].map((i) => ({ + userId: user.id, + type: "WEIGHT" as const, + value: 80 + i, + unit: "kg", + measuredAt: new Date(Date.UTC(2026, 8, 1 + i, 7)), + source: "MANUAL" as const, + })), + }); + await prisma.medication.create({ + data: { userId: user.id, name: "Ramipril", dose: "5 mg" }, + }); + await prisma.apiToken.create({ + data: { + userId: user.id, + name: "connector", + tokenHash: `hash-${username}`, + permissions: ["*"], + }, + }); + await prisma.dataBackup.create({ + data: { userId: user.id, type: "WEEKLY_AUTO", data: "sealed-copy" }, + }); + return user; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session.id; +} + +async function recordCounts(userId: string) { + const prisma = getPrismaClient(); + const [measurements, medications, backups] = await Promise.all([ + prisma.measurement.count({ where: { userId } }), + prisma.medication.count({ where: { userId } }), + prisma.dataBackup.count({ where: { userId } }), + ]); + return { measurements, medications, backups }; +} + +async function deleteOwnAccount() { + const { DELETE } = await import("@/app/api/settings/account/route"); + return DELETE( + jsonRequest("/api/settings/account", "DELETE", { + confirm: "DELETE_ACCOUNT", + }), + ); +} + +async function passwordLogin(email: string) { + const { POST } = await import("@/app/api/auth/login/route"); + return POST( + jsonRequest("/api/auth/login", "POST", { email, password: PASSWORD }), + ); +} + +describe("account deletion with a grace window", () => { + it("locks the account, keeps the record, and an admin's undo gives it back", async () => { + const prisma = getPrismaClient(); + const user = await seedAccount("leaving"); + const before = await recordCounts(user.id); + await signIn(user.id); + + const res = await deleteOwnAccount(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { deleted: boolean; scheduledPurgeAt: string | null }; + }; + expect(body.data.deleted).toBe(true); + const purgeAt = new Date(body.data.scheduledPurgeAt ?? 0); + expect(purgeAt.getTime() - Date.now()).toBeGreaterThan(13.9 * DAY_MS); + expect(purgeAt.getTime() - Date.now()).toBeLessThan(14.1 * DAY_MS); + + // Locked: every credential revoked, the record and its copies untouched. + const row = await prisma.user.findUniqueOrThrow({ where: { id: user.id } }); + expect(row.deletionRequestedAt).not.toBeNull(); + expect(row.deletionPurgeAt?.toISOString()).toBe(purgeAt.toISOString()); + expect(row.deletionRequestedById).toBe(user.id); + expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0); + expect( + await prisma.apiToken.count({ + where: { userId: user.id, revoked: false }, + }), + ).toBe(0); + expect(await recordCounts(user.id)).toEqual(before); + expect(await prisma.offhostPurgeRequest.count()).toBe(0); + + // Sign-in is refused with the date, after the password was proved. + const refused = await passwordLogin(user.email!); + expect(refused.status).toBe(403); + const refusal = (await refused.json()) as { + meta: { errorCode: string; purgeAt: string }; + }; + expect(refusal.meta.errorCode).toBe("auth.account.deletion_pending"); + expect(refusal.meta.purgeAt).toBe(purgeAt.toISOString()); + expect(await prisma.session.count({ where: { userId: user.id } })).toBe(0); + + // A wrong password learns nothing about the deletion. + const { POST } = await import("@/app/api/auth/login/route"); + const wrong = await POST( + jsonRequest("/api/auth/login", "POST", { + email: user.email, + password: "not the password", + }), + ); + expect(wrong.status).toBe(401); + + // A session that slipped past the lock is not honoured. + await signIn(user.id); + const { GET: me } = await import("@/app/api/auth/me/route"); + expect((await me()).status).toBe(401); + + // An admin undoes it: nothing was changed, so nothing has to come back. + const admin = await seedAccount("admin", "ADMIN"); + await signIn(admin.id); + const { POST: undo } = + await import("@/app/api/admin/users/[id]/undo-deletion/route"); + const undone = await undo( + jsonRequest(`/api/admin/users/${user.id}/undo-deletion`, "POST"), + { params: Promise.resolve({ id: user.id }) }, + ); + expect(undone.status).toBe(200); + const restored = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(restored.deletionRequestedAt).toBeNull(); + expect(restored.deletionPurgeAt).toBeNull(); + expect(await recordCounts(user.id)).toEqual(before); + expect( + await prisma.auditLog.count({ + where: { action: "admin.user.deletion.undo", userId: admin.id }, + }), + ).toBe(1); + expect( + await prisma.auditLog.count({ + where: { action: "user.account.deletion_cancelled", userId: user.id }, + }), + ).toBe(1); + + // A second undo has nothing to undo. + const again = await undo( + jsonRequest(`/api/admin/users/${user.id}/undo-deletion`, "POST"), + { params: Promise.resolve({ id: user.id }) }, + ); + expect(again.status).toBe(409); + + // And the person signs in again. + cookieJar.clear(); + expect((await passwordLogin(user.email!)).status).toBe(200); + }); + + it("lists the pending account for admins with its date", async () => { + const user = await seedAccount("listed"); + await signIn(user.id); + expect((await deleteOwnAccount()).status).toBe(200); + + const admin = await seedAccount("admin", "ADMIN"); + await signIn(admin.id); + const { GET } = await import("@/app/api/admin/users/route"); + const res = await GET(); + const { data } = (await res.json()) as { + data: Array<{ id: string; deletion: unknown }>; + }; + const row = data.find((u) => u.id === user.id); + expect(row?.deletion).toMatchObject({ requestedBy: "self" }); + expect(data.find((u) => u.id === admin.id)?.deletion).toBeNull(); + }); + + it("keeps background passes away from the pending account", async () => { + const prisma = getPrismaClient(); + const pending = await seedAccount("pending"); + const active = await seedAccount("active"); + await signIn(pending.id); + expect((await deleteOwnAccount()).status).toBe(200); + cookieJar.clear(); + + const { readOffhostCohort } = + await import("@/lib/jobs/offhost-backup-cohort"); + const cohort = await readOffhostCohort(prisma, undefined); + expect(cohort.users.map((u) => u.id)).toEqual([active.id]); + + const { findPregenerateCandidates } = + await import("@/lib/jobs/insight-pregenerate"); + const candidates = await findPregenerateCandidates(prisma, new Date(), 50); + expect(candidates.map((c) => c.id)).toEqual([active.id]); + + // Nothing is delivered about or to it, whoever enumerated it. + const { resolveNotificationDeliveryIdentity } = + await import("@/lib/notifications/delivery-identity"); + const payload = (userId: string) => ({ + eventType: "MEDICATION_REMINDER" as const, + userId, + title: "t", + message: "m", + }); + expect( + await resolveNotificationDeliveryIdentity(payload(pending.id)), + ).toBeNull(); + expect( + await resolveNotificationDeliveryIdentity(payload(active.id)), + ).toMatchObject({ recordUserId: active.id }); + + // And no model work is admitted for it. + const { loadAiCapabilityInputs } = + await import("@/lib/ai/capabilities/load"); + const { providerWorkAuthorityForRecord } = + await import("@/lib/sharing/provider-work-authority"); + const inputsFor = (recordId: string) => + loadAiCapabilityInputs({ + recordId, + authority: providerWorkAuthorityForRecord(recordId), + sections: null, + recordKind: "self", + }); + expect((await inputsFor(pending.id))?.providerWorkAdmitted).toBe(false); + expect((await inputsFor(active.id))?.providerWorkAdmitted).toBe(true); + }); + + it("erases the account when the window ends, and only then", async () => { + const prisma = getPrismaClient(); + const due = await seedAccount("due"); + const notYet = await seedAccount("not-yet"); + for (const user of [due, notYet]) { + await signIn(user.id); + expect((await deleteOwnAccount()).status).toBe(200); + } + await prisma.user.update({ + where: { id: due.id }, + data: { deletionPurgeAt: new Date(Date.now() - 60_000) }, + }); + + const { processDueAccountDeletions } = + await import("@/lib/jobs/account-deletion-purge"); + const report = await processDueAccountDeletions(new Date()); + expect(report).toMatchObject({ due: 1, purged: 1, refused: 0 }); + + expect(await prisma.user.findUnique({ where: { id: due.id } })).toBeNull(); + expect(await recordCounts(due.id)).toEqual({ + measurements: 0, + medications: 0, + backups: 0, + }); + // The account's own audit history goes with it. + expect(await prisma.auditLog.count({ where: { userId: due.id } })).toBe(0); + + const kept = await prisma.user.findUnique({ where: { id: notYet.id } }); + expect(kept?.deletionRequestedAt).not.toBeNull(); + expect((await recordCounts(notYet.id)).measurements).toBe(3); + }); + + it("erases at once with no window, as before", async () => { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0"); + const prisma = getPrismaClient(); + const user = await seedAccount("immediate"); + await signIn(user.id); + + const res = await deleteOwnAccount(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { scheduledPurgeAt: string | null }; + }; + expect(body.data.scheduledPurgeAt).toBeNull(); + expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull(); + expect((await recordCounts(user.id)).measurements).toBe(0); + }); + + it("does not let the last admin hide behind another admin pending deletion", async () => { + const prisma = getPrismaClient(); + const first = await seedAccount("admin-one", "ADMIN"); + const second = await seedAccount("admin-two", "ADMIN"); + await signIn(first.id); + expect((await deleteOwnAccount()).status).toBe(200); + + await signIn(second.id); + const res = await deleteOwnAccount(); + expect(res.status).toBe(400); + const row = await prisma.user.findUniqueOrThrow({ + where: { id: second.id }, + }); + expect(row.deletionRequestedAt).toBeNull(); + }); +}); + +describe("managed-profile deletion with a grace window", () => { + it("locks the profile away from its Guardian until an admin undoes it", async () => { + const prisma = getPrismaClient(); + const guardian = await seedAccount("guardian"); + const profile = await prisma.user.create({ + data: { + username: "managed-child", + displayName: "Child", + managedProfileAt: new Date(), + timezone: "Europe/Berlin", + }, + }); + await prisma.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await prisma.measurement.create({ + data: { + userId: profile.id, + type: "WEIGHT", + value: 21, + unit: "kg", + measuredAt: new Date(Date.UTC(2026, 8, 1, 7)), + source: "MANUAL", + }, + }); + + const { deleteManagedProfile, readManagedProfileForGuardian } = + await import("@/lib/managed-profiles/lifecycle"); + const { scheduledPurgeAt } = await deleteManagedProfile({ + profileId: profile.id, + guardianId: guardian.id, + }); + expect(scheduledPurgeAt).not.toBeNull(); + + const row = await prisma.user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(row.deletionRequestedById).toBe(guardian.id); + expect( + await readManagedProfileForGuardian({ + profileId: profile.id, + guardianId: guardian.id, + }), + ).toBeNull(); + expect( + await prisma.measurement.count({ where: { userId: profile.id } }), + ).toBe(1); + // A second deletion of a profile already pending is refused. + await expect( + deleteManagedProfile({ profileId: profile.id, guardianId: guardian.id }), + ).rejects.toMatchObject({ code: "not_found" }); + + const admin = await seedAccount("admin", "ADMIN"); + const { cancelAccountDeletion } = + await import("@/lib/account-deletion/lifecycle"); + expect( + await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }), + ).toBe("cancelled"); + expect( + await readManagedProfileForGuardian({ + profileId: profile.id, + guardianId: guardian.id, + }), + ).toMatchObject({ id: profile.id }); + expect( + await prisma.measurement.count({ where: { userId: profile.id } }), + ).toBe(1); + }); +}); + +describe("delete all data with a grace window", () => { + it("keeps a copy of the record that an admin can restore", async () => { + const prisma = getPrismaClient(); + const user = await seedAccount("wiping"); + const before = await recordCounts(user.id); + await signIn(user.id); + + const { DELETE } = await import("@/app/api/settings/data/route"); + const res = await DELETE( + jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { restorableUntil: string | null }; + }; + expect(body.data.restorableUntil).not.toBeNull(); + + // The record is gone; the copy taken first is not, and the weekly copy + // went with the record as before. + expect((await recordCounts(user.id)).measurements).toBe(0); + const copies = await prisma.dataBackup.findMany({ + where: { userId: user.id }, + select: { id: true, type: true }, + }); + expect(copies).toHaveLength(1); + expect(copies[0]!.type).toMatch(/^PRE_WIPE_\d+$/); + + // A second wipe keeps the first copy beside its own. + const second = await DELETE( + jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }), + ); + expect(second.status).toBe(200); + expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe( + 2, + ); + + // The ordinary restore brings the record back from the first copy. + const admin = await seedAccount("admin", "ADMIN"); + await signIn(admin.id); + const { POST } = await import("./restore-job-driver"); + const restored = await POST( + new Request( + `http://localhost/api/admin/backups/${copies[0]!.id}/restore`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }, + ) as unknown as Parameters[0], + { params: Promise.resolve({ id: copies[0]!.id }) }, + ); + expect(restored.status).toBe(200); + const after = await recordCounts(user.id); + expect(after.measurements).toBe(before.measurements); + expect(after.medications).toBe(before.medications); + + // When the window ends, the purge job deletes the copies. + await prisma.dataBackup.updateMany({ + where: { userId: user.id }, + data: { createdAt: new Date(Date.now() - 15 * DAY_MS) }, + }); + const { processDueAccountDeletions } = + await import("@/lib/jobs/account-deletion-purge"); + const report = await processDueAccountDeletions(new Date()); + expect(report.preWipeCopiesDeleted).toBe(2); + expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe( + 0, + ); + expect((await recordCounts(user.id)).measurements).toBe( + before.measurements, + ); + }); + + it("takes no copy with no window, as before", async () => { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "0"); + const prisma = getPrismaClient(); + const user = await seedAccount("wiping-now"); + await signIn(user.id); + const { DELETE } = await import("@/app/api/settings/data/route"); + const res = await DELETE( + jsonRequest("/api/settings/data", "DELETE", { confirm: "DELETE" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { restorableUntil: string | null }; + }; + expect(body.data.restorableUntil).toBeNull(); + expect(await prisma.dataBackup.count({ where: { userId: user.id } })).toBe( + 0, + ); + }); +}); diff --git a/tests/integration/offhost-purge-on-deletion.test.ts b/tests/integration/offhost-purge-on-deletion.test.ts index 3cf7ac76d..4e141292b 100644 --- a/tests/integration/offhost-purge-on-deletion.test.ts +++ b/tests/integration/offhost-purge-on-deletion.test.ts @@ -8,9 +8,15 @@ * turn this red: drop `requestOffhostPurge` from either route (no request is * written and the objects stay), or write the request outside the deletion's * transaction and let it fail (the deletion commits without one). + * + * With a deletion grace window the objects stay until it ends: an account + * deletion writes no request until the purge job erases the account, and a + * wipe's request waits for its `notBefore`. Mutations that must turn the + * second block red: purge in the deletion route again, or drop the + * `notBefore` filter from the purge job. */ import { NextRequest } from "next/server"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; @@ -168,3 +174,119 @@ describe("off-host copies leave with the account", () => { expect(await prisma.offhostPurgeRequest.count()).toBe(0); }); }); + +describe("with a deletion grace window, the copies wait for it", () => { + beforeEach(() => { + vi.stubEnv("ACCOUNT_DELETION_GRACE_DAYS", "14"); + }); + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function deleteAccount() { + const { DELETE } = await import("@/app/api/settings/account/route"); + return DELETE( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ); + } + + it("keeps an undone deletion's copies, and purges them once the window ends", async () => { + const prisma = getPrismaClient(); + const other = await prisma.user.create({ + data: { username: "neighbour3", email: "neighbour3@example.test" }, + }); + const admin = await prisma.user.create({ + data: { username: "admin3", email: "admin3@example.test", role: "ADMIN" }, + }); + const userId = await seedUserWithSession("changing-mind"); + seedBucket(userId, other.id); + + // Locked, not erased: no request, every copy still there. + expect((await deleteAccount()).status).toBe(200); + expect(await prisma.offhostPurgeRequest.count()).toBe(0); + const { processOffhostPurges } = await import("@/lib/jobs/offhost-purge"); + await processOffhostPurges(prisma); + expect([...bucket].filter((k) => k.includes(userId))).toHaveLength(3); + + // Undone: still every copy. + const { cancelAccountDeletion } = + await import("@/lib/account-deletion/lifecycle"); + expect(await cancelAccountDeletion({ userId, adminId: admin.id })).toBe( + "cancelled", + ); + expect([...bucket].filter((k) => k.includes(userId))).toHaveLength(3); + + // Deleted again and left to run out: the purge job erases the account and + // writes the request with the erasure; the off-host purge empties it. + const session = await prisma.session.create({ + data: { userId, expiresAt: new Date(Date.now() + 3_600_000) }, + }); + cookieJar.set("healthlog_session", session.id); + expect((await deleteAccount()).status).toBe(200); + await prisma.user.update({ + where: { id: userId }, + data: { deletionPurgeAt: new Date(Date.now() - 1000) }, + }); + const { processDueAccountDeletions } = + await import("@/lib/jobs/account-deletion-purge"); + expect(await processDueAccountDeletions(new Date())).toMatchObject({ + purged: 1, + }); + expect(await prisma.user.findUnique({ where: { id: userId } })).toBeNull(); + const pending = await prisma.offhostPurgeRequest.findMany(); + expect(pending.map((r) => [r.subjectId, r.reason, r.notBefore])).toEqual([ + [userId, "account_deleted", null], + ]); + const report = await processOffhostPurges(prisma); + expect(report).toMatchObject({ completed: 1, objectsDeleted: 3 }); + expect([...bucket].filter((k) => k.includes(userId))).toEqual([]); + expect([...bucket].filter((k) => k.includes(other.id))).toHaveLength(3); + }); + + it("holds a wipe's purge back until the window ends", async () => { + const prisma = getPrismaClient(); + const other = await prisma.user.create({ + data: { username: "neighbour4", email: "neighbour4@example.test" }, + }); + const userId = await seedUserWithSession("wiping-later"); + seedBucket(userId, other.id); + + const { DELETE } = await import("@/app/api/settings/data/route"); + const res = await DELETE( + new NextRequest("http://localhost/api/settings/data", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE" }), + }), + ); + expect(res.status).toBe(200); + + const [request] = await prisma.offhostPurgeRequest.findMany(); + expect(request?.reason).toBe("data_wiped"); + expect(request!.notBefore!.getTime() - Date.now()).toBeGreaterThan( + 13.9 * 24 * 60 * 60 * 1000, + ); + + // Not due: nothing leaves the bucket. + const { processOffhostPurges } = await import("@/lib/jobs/offhost-purge"); + expect(await processOffhostPurges(prisma)).toMatchObject({ + pending: 0, + objectsDeleted: 0, + }); + expect([...bucket].filter((k) => k.includes(userId))).toHaveLength(3); + + // Due: the copies from before the wipe go, as an immediate wipe's did. + await prisma.offhostPurgeRequest.update({ + where: { id: request!.id }, + data: { notBefore: new Date(Date.now() - 1000) }, + }); + const report = await processOffhostPurges(prisma); + expect(report.objectsDeleted).toBe(3); + expect([...bucket].filter((k) => k.includes(userId))).toEqual([]); + expect([...bucket].filter((k) => k.includes(other.id))).toHaveLength(3); + }); +}); diff --git a/tests/integration/settings-erasure-bearer-step-up.test.ts b/tests/integration/settings-erasure-bearer-step-up.test.ts index a9cb80e69..9ecabde82 100644 --- a/tests/integration/settings-erasure-bearer-step-up.test.ts +++ b/tests/integration/settings-erasure-bearer-step-up.test.ts @@ -264,7 +264,11 @@ describe("DELETE /api/settings/account over Bearer with a second factor", () => const res = await deleteAccount(); expect(res.status).toBe(200); - expect(await res.json()).toEqual({ data: { deleted: true }, error: null }); + // No grace window in this suite: erased now, nothing scheduled. + expect(await res.json()).toEqual({ + data: { deleted: true, scheduledPurgeAt: null }, + error: null, + }); expect(await userExists()).toBe(false); // The token and its elevation went with the account. expect(await getPrismaClient().apiToken.count()).toBe(0); diff --git a/vitest.integration.config.mts b/vitest.integration.config.mts index 29d4b78f4..ddb78f103 100644 --- a/vitest.integration.config.mts +++ b/vitest.integration.config.mts @@ -37,6 +37,11 @@ export default defineConfig({ API_TOKEN_HMAC_KEY: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", SESSION_SECRET: "integration-test-session-secret-32-bytes", + // Deletions erase at once here, the behaviour the suite was written + // against; the grace window (14 days in production) is pinned by the + // files that set it themselves (`account-deletion-grace.test.ts`, + // `offhost-purge-on-deletion.test.ts`). + ACCOUNT_DELETION_GRACE_DAYS: "0", }, include: ["tests/integration/**/*.test.ts"], // Container boot + migration apply is slow; give each test a generous