From a69666fa16bd6d95566596843ae1f19492bfdbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 25 Oct 2026 13:50:14 +0100 Subject: [PATCH] feat(account): hold account deletion and data wipe for a grace window Deleting an account, a guardian deleting a managed profile, and "Delete all data" were final the moment they were confirmed: the account row cascaded into every table and every stored copy, and the off-host objects were purged in the same breath. Nothing short of rolling the whole instance back could return one account. ACCOUNT_DELETION_GRACE_DAYS (14 by default, at most 30, 0 erases at once as before) now sets a window: - Account and managed-profile deletion lock the account (migration 0488: deletion_requested_at, deletion_purge_at, deletion_requested_by_id). Every session, refresh token, API token, connector, share link and step-up elevation is revoked in the same transaction, and the record, its stored copies and its off-host objects are left as they are. - Sign-in is refused with 403 auth.account.deletion_pending and the erasure date, after the credential was proved and before a second factor is asked for. The session and Bearer resolvers and the acting- account resolver refuse a pending account too, so a credential minted in the moment before the lock is useless. - POST /api/admin/users/{id}/undo-deletion (cookie-only, audit-logged) clears the lock. The admin users list reports the pending state. - The hourly account-deletion-purge job erases accounts whose window has ended, exactly as the immediate deletion did, with the off-host purge request committed alongside. The Guardian invariant is decided again; a refused erasure stays pending and is reported. - A wipe first stores the whole record as a PRE_WIPE_ copy through the writer the weekly copy uses, keeps it out of the wipe, and holds its off-host purge back with a new not_before on the request. The ordinary restore brings the record back; restoring that copy withdraws the held purge. The purge job deletes the copy when its window ends. Background work skips accounts pending deletion through one shared predicate (src/lib/account-deletion/active-account.ts): every reminder, digest, Coach, sync, backfill, rollup, score and backup enumeration filters with it, and account-deletion-enumeration-guard.test.ts fails on a query in background work that neither filters nor says why it need not. Notification delivery and the AI capability loader refuse a pending account as chokepoints behind it, and the Withings, WHOOP and Telegram webhooks and the notification quick actions ignore one. The privacy summary publishes the window. Two pieces of the reminder tick and the off-host pass moved into modules of their own to keep the files inside their size ratchet. --- .env.production.example | 10 + docker-compose.yml | 1 + docs/api/openapi.yaml | 145 +++-- .../0488_account_deletion_grace/migration.sql | 32 ++ prisma/schema.prisma | 26 + scripts/env-manifest.json | 5 + ...account-deletion-enumeration-guard.test.ts | 251 ++++++++ .../baselines/source-file-lines.json | 8 +- .../baselines/source-function-lines.json | 8 +- .../baselines/version-stamp-comments.json | 10 +- .../error-level-discipline-guard.test.ts | 2 +- src/__tests__/guard-floor-guard.test.ts | 2 - .../notification-delivery-identity.test.ts | 2 +- .../openapi-route-coverage-guard.test.ts | 4 + src/app/api/account/grants/route.ts | 5 +- src/app/api/admin/backups/route.ts | 9 + .../admin/users/[id]/undo-deletion/route.ts | 53 ++ .../api/admin/users/__tests__/route.test.ts | 5 + src/app/api/admin/users/route.ts | 17 + src/app/api/auth/mfa/verify/route.ts | 2 + src/app/api/auth/mfa/webauthn/verify/route.ts | 8 +- src/app/api/auth/native/token/route.ts | 8 +- src/app/api/auth/oidc/callback/route.ts | 77 ++- src/app/api/auth/oidc/native/token/route.ts | 8 +- .../api/auth/passkey/login-verify/route.ts | 5 + src/app/api/managed-profiles/[id]/route.ts | 15 +- .../api/notifications/action/[token]/route.ts | 8 +- src/app/api/settings/account/route.ts | 110 ++-- src/app/api/settings/data/route.ts | 57 +- src/app/api/settings/privacy-summary/route.ts | 6 + .../webhook/[token]/__tests__/route.test.ts | 2 +- .../webhook/[token]/__tests__/route.test.ts | 4 +- .../withings/webhook/__tests__/route.test.ts | 4 +- .../__tests__/active-account.test.ts | 49 ++ .../account-deletion/__tests__/grace.test.ts | 57 ++ src/lib/account-deletion/active-account.ts | 91 +++ src/lib/account-deletion/grace.ts | 56 ++ src/lib/account-deletion/lifecycle.ts | 156 +++++ src/lib/account-deletion/pending.ts | 59 ++ src/lib/account-deletion/pre-wipe-copy.ts | 36 ++ src/lib/account-deletion/pre-wipe-type.ts | 12 + src/lib/ai/capabilities/load.ts | 20 +- src/lib/api-errors.ts | 30 + src/lib/api-handler.ts | 4 +- src/lib/auth/bearer.ts | 14 +- src/lib/auth/credential-revocations.ts | 61 ++ src/lib/auth/guards/record.ts | 5 + src/lib/auth/login-response.ts | 16 +- src/lib/auth/mfa/__tests__/challenge.test.ts | 24 + src/lib/auth/mfa/challenge.ts | 5 + src/lib/auth/session-user.ts | 3 + src/lib/auth/session.ts | 55 +- src/lib/data-wipe/wipe-plan.ts | 34 +- src/lib/export/account-copy.ts | 50 ++ src/lib/export/account-settings-classes.ts | 9 + src/lib/integrations/watchdog.ts | 26 +- .../google-health-sleep-repair.test.ts | 4 +- .../medication-reminder-check.test.ts | 2 + src/lib/jobs/__tests__/mood-reminder.test.ts | 4 +- .../__tests__/period-narrative-warm.test.ts | 5 +- .../__tests__/sleep-timeline-backfill.test.ts | 14 +- .../__tests__/status-cron-candidates.test.ts | 6 +- src/lib/jobs/__tests__/whoop-backfill.test.ts | 5 +- .../withings-subscription-retry.test.ts | 5 +- src/lib/jobs/account-deletion-purge-queue.ts | 12 + src/lib/jobs/account-deletion-purge.ts | 203 +++++++ src/lib/jobs/achievement-unlock-sweep.ts | 6 +- src/lib/jobs/ai-job-candidates.ts | 1 + src/lib/jobs/apple-health-import-worker.ts | 2 + src/lib/jobs/audit-log-cleanup.ts | 1 + src/lib/jobs/backup-pass-attempts.ts | 1 + src/lib/jobs/backup-restore-view.ts | 1 + src/lib/jobs/backup-restore.ts | 28 + src/lib/jobs/coach-nudge.ts | 6 +- src/lib/jobs/coach-plan-review.ts | 2 + src/lib/jobs/coach-reminder-sweep.ts | 18 +- src/lib/jobs/compaction-tombstone-purge.ts | 1 + src/lib/jobs/cycle-prediction-refresh.ts | 3 +- src/lib/jobs/cycle-reminder.ts | 5 +- src/lib/jobs/daily-briefing.ts | 7 +- src/lib/jobs/dawarich-sync.ts | 3 +- src/lib/jobs/dense-intraday-hourly-rebuild.ts | 9 +- src/lib/jobs/dense-intraday-retention.ts | 25 +- src/lib/jobs/document-index-retry.ts | 2 + src/lib/jobs/document-thumbnail-backfill.ts | 4 +- .../jobs/environment-air-quality-history.ts | 2 + src/lib/jobs/environment-fetch.ts | 3 +- src/lib/jobs/feedback-aggregator.ts | 1 + src/lib/jobs/free-text-encryption-backfill.ts | 1 + src/lib/jobs/geo-backfill.ts | 1 + src/lib/jobs/google-health-backfill.ts | 3 +- src/lib/jobs/google-health-sleep-repair.ts | 3 +- src/lib/jobs/insight-pregenerate.ts | 8 +- src/lib/jobs/intake-auto-skip.ts | 7 +- src/lib/jobs/intake-auto-take.ts | 7 +- src/lib/jobs/job-outcome.ts | 7 + src/lib/jobs/lab-biomarker-backfill.ts | 4 +- src/lib/jobs/mean-consolidation.ts | 23 +- src/lib/jobs/measurement-fold-repair.ts | 3 +- src/lib/jobs/measurement-reminder.ts | 5 +- src/lib/jobs/measurement-tombstone-cleanup.ts | 1 + src/lib/jobs/med-notes-encryption-backfill.ts | 1 + src/lib/jobs/medication-low-stock.ts | 5 +- src/lib/jobs/mood-prognosis-refresh.ts | 3 + src/lib/jobs/mood-reminder.ts | 3 +- src/lib/jobs/note-encryption-backfill.ts | 1 + src/lib/jobs/offhost-backup-cohort.ts | 46 ++ src/lib/jobs/offhost-backup.ts | 24 +- src/lib/jobs/offhost-purge-queue.ts | 8 + src/lib/jobs/offhost-purge.ts | 12 +- src/lib/jobs/oura-backfill.ts | 2 + src/lib/jobs/period-narrative-warm.ts | 7 +- src/lib/jobs/pr-detection.ts | 22 +- src/lib/jobs/pulse-day-mean.ts | 2 + src/lib/jobs/queue-runtime.ts | 3 + src/lib/jobs/registry/maintenance.ts | 13 + src/lib/jobs/reminder/backup-handlers.ts | 42 +- src/lib/jobs/reminder/google-health-sync.ts | 2 + .../reminder/medication-reminder-check.ts | 149 ++--- src/lib/jobs/reminder/nightscout-sync.ts | 3 +- src/lib/jobs/reminder/oura-sync.ts | 3 +- src/lib/jobs/reminder/polar-sync.ts | 3 +- src/lib/jobs/reminder/strava-sync.ts | 3 +- .../reminder/telegram-deletion-cleanup.ts | 100 ++++ src/lib/jobs/reminder/whoop-sync.ts | 2 + src/lib/jobs/reminder/withings-sync.ts | 5 + src/lib/jobs/sleep-timeline-backfill.ts | 5 +- src/lib/jobs/soft-delete-retention.ts | 1 + src/lib/jobs/status-cron-candidates.ts | 6 +- src/lib/jobs/step-consolidation-repair.ts | 9 +- src/lib/jobs/step-consolidation.ts | 9 +- src/lib/jobs/strava-backfill.ts | 2 + src/lib/jobs/subsystem-surface.ts | 1 + src/lib/jobs/tls-pin-monitor.ts | 3 +- src/lib/jobs/whoop-backfill.ts | 3 +- .../lifecycle-transaction-budget.test.ts | 8 +- src/lib/managed-profiles/lifecycle.ts | 83 ++- src/lib/measurements/consolidation-base.ts | 3 + .../__tests__/client-managed-apns.test.ts | 2 +- .../dispatcher-global-channel-switch.test.ts | 2 +- .../__tests__/dispatcher.test.ts | 2 +- src/lib/notifications/delivery-identity.ts | 18 +- src/lib/openapi/error-codes.ts | 3 + src/lib/openapi/routes/account-sharing.ts | 11 +- src/lib/openapi/routes/auth.ts | 3 +- src/lib/openapi/routes/settings.ts | 31 +- src/lib/telegram-webhook-handlers.ts | 10 +- src/lib/whoop/webhook-handler.ts | 6 +- src/lib/withings/webhook-handler.ts | 4 +- .../restore-disaster-recovery.json | 3 + .../backup-parity/restore-portable.json | 3 + .../backup-parity/restore-skips.json | 3 + .../backup-parity/restore-version-1.json | 3 + .../backup-parity/seeded-account.json | 3 + .../account-deletion-grace.test.ts | 541 ++++++++++++++++++ .../offhost-purge-on-deletion.test.ts | 124 +++- .../settings-erasure-bearer-step-up.test.ts | 6 +- vitest.integration.config.mts | 5 + 158 files changed, 3164 insertions(+), 461 deletions(-) create mode 100644 prisma/migrations/0488_account_deletion_grace/migration.sql create mode 100644 src/__tests__/account-deletion-enumeration-guard.test.ts create mode 100644 src/app/api/admin/users/[id]/undo-deletion/route.ts create mode 100644 src/lib/account-deletion/__tests__/active-account.test.ts create mode 100644 src/lib/account-deletion/__tests__/grace.test.ts create mode 100644 src/lib/account-deletion/active-account.ts create mode 100644 src/lib/account-deletion/grace.ts create mode 100644 src/lib/account-deletion/lifecycle.ts create mode 100644 src/lib/account-deletion/pending.ts create mode 100644 src/lib/account-deletion/pre-wipe-copy.ts create mode 100644 src/lib/account-deletion/pre-wipe-type.ts create mode 100644 src/lib/auth/credential-revocations.ts create mode 100644 src/lib/export/account-copy.ts create mode 100644 src/lib/jobs/account-deletion-purge-queue.ts create mode 100644 src/lib/jobs/account-deletion-purge.ts create mode 100644 src/lib/jobs/offhost-backup-cohort.ts create mode 100644 src/lib/jobs/reminder/telegram-deletion-cleanup.ts create mode 100644 tests/integration/account-deletion-grace.test.ts 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