feat(account): hold account deletion and data wipe for a grace window

Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

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

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

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

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:50:14 +01:00
1 parent 41c6eea2a1
commit a69666fa16
158 files changed
+3164 -461

No files matched your search

+10
View File
@@ -446,6 +446,16 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
# 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.
+1
View File
@@ -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.
+107 -38
View File
@@ -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_<ms>`), 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"
@@ -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$;
+26
View File
@@ -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")
+5
View File
@@ -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.",
@@ -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: <reason>` (a ledger read, the purge's own query,
* …), or the module says so for all of its queries:
* `// account-deletion(file): <reason>` (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): <reason>` 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 <the query>` 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: <reason>` 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);
});
});
@@ -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,
@@ -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,
@@ -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,
@@ -235,7 +235,7 @@ const ALLOWED: Readonly<Record<string, { count: number; reason: string }>> = {
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:
-2
View File
@@ -55,8 +55,6 @@ const SRC = join(process.cwd(), "src");
const NO_FLOOR_NEEDED: Readonly<Record<string, string>> = {
// 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":
@@ -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),
@@ -303,6 +303,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
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
+4 -1
View File
@@ -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 } },
+9
View File
@@ -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),
@@ -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 });
},
);
@@ -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");
+17
View File
@@ -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,
})),
);
});
+2
View File
@@ -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,
@@ -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);
+7 -1
View File
@@ -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" } });
+55 -22
View File
@@ -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");
+7 -1
View File
@@ -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" } });
@@ -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
+12 -3
View File
@@ -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 });
},
);
@@ -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",
+74 -36
View File
@@ -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,
});
});
+54 -3
View File
@@ -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_<ms>`). 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<string, number>;
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,
});
});
@@ -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(),
};
}
@@ -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.
@@ -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");
@@ -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");
@@ -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);
});
});
@@ -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());
});
});
@@ -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<T extends object>(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<T extends object>(
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<T>(
db: {
user: {
findMany(args: {
where: { deletionRequestedAt: { not: null } };
select: { id: true };
}): Promise<Array<{ id: string }>>;
};
},
rows: readonly T[],
accountOf: (row: T) => string,
): Promise<T[]> {
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)));
}
+56
View File
@@ -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_<ms>`), 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_<ms>` 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);
}
+156
View File
@@ -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<void> {
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<void> {
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<CancelDeletionOutcome> {
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";
});
}
+59
View File
@@ -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<Prisma.TransactionClient, "user">;
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<PendingDeletion | null> {
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<void> {
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);
}
}
+36
View File
@@ -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_<unix-ms>`:
*
* - 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_<ms>` 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 };
}
+12
View File
@@ -0,0 +1,12 @@
/**
* The `DataBackup.type` of the account copy a data wipe takes first
* (`PRE_WIPE_<unix-ms>`, 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);
}
+19 -1
View File
@@ -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<boolean> {
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,
+30
View File
@@ -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<string, unknown>;
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.
*
+3 -1
View File
@@ -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<T extends (...args: any[]) => Promise<Response>>(
data: null,
error: error.message,
...(error.errorCode
? { meta: { errorCode: error.errorCode } }
? { meta: { ...error.extraMeta, errorCode: error.errorCode } }
: {}),
},
{ status: error.statusCode },
+13 -1
View File
@@ -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({
+61
View File
@@ -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<unknown>[] {
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 } }),
];
}
+5
View File
@@ -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);
+15 -1
View File
@@ -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, "id" | "username" | "onboardingCompletedAt">;
user: Pick<
User,
| "id"
| "username"
| "onboardingCompletedAt"
| "deletionRequestedAt"
| "deletionPurgeAt"
>;
request: NextRequest;
ip: string;
userAgent: string | null;
@@ -58,6 +66,12 @@ export async function finishLogin(
): Promise<Response> {
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
@@ -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<string, unknown> = {}) {
@@ -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,
+5
View File
@@ -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<CreatedChallenge> {
// 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({
+3
View File
@@ -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,
+22 -33
View File
@@ -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<string | null> {
// 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<void> {
* survives — the rows linger as evidence of the rotation event.
*/
export async function destroyAllSessions(userId: string): Promise<void> {
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));
}
/**
+31 -3
View File
@@ -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<Record<string, string>> = {
"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<string, { keep: object; reason: string }>
> = {
DataBackup: {
keep: { NOT: { type: { startsWith: PRE_WIPE_BACKUP_TYPE_PREFIX } } },
reason:
"the account copies taken before a wipe (`PRE_WIPE_<ms>`), 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 {
+50
View File
@@ -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,
});
},
);
}
@@ -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",
+22 -4
View File
@@ -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<string[]> {
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<string[]> {
select: { id: true },
}),
prisma.healthKitTypeSync.findMany({
where: OWNED_BY_ACTIVE_ACCOUNT,
distinct: ["userId"],
select: { userId: true },
}),
@@ -144,6 +161,7 @@ async function listWatchedUserIds(): Promise<string[]> {
{ silentSince: { not: null } },
{ incidentReason: { not: null } },
],
...OWNED_BY_ACTIVE_ACCOUNT,
},
select: { userId: true },
}),
@@ -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(
@@ -189,10 +189,12 @@ describe("handleReminderCheck — as-needed skip (v1.16.11, #316)", () => {
where: Record<string, unknown>;
};
// 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 },
});
});
+3 -1
View File
@@ -387,7 +387,9 @@ describe("runMoodReminderTick", () => {
});
expect(prisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { moodReminderEnabled: true } }),
expect.objectContaining({
where: { moodReminderEnabled: true, deletionRequestedAt: null },
}),
);
});
@@ -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.
@@ -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(
@@ -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" }]);
@@ -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);
@@ -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",
@@ -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 * * * *";
+203
View File
@@ -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_<ms>`) 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<boolean> {
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_<ms>` 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<number> {
// 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<AccountDeletionPurgeReport> {
// 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<object>[],
): Promise<JobOutcome> {
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);
}
});
}
+5 -1
View File
@@ -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,
+1
View File
@@ -27,6 +27,7 @@ export async function userIdsWithModuleOff(
prisma: Pick<PrismaClient, "$queryRaw">,
module: ModuleKey,
): Promise<string[]> {
// account-deletion: the accounts to leave out; the candidate queries using it filter.
const rows = await prisma.$queryRaw<Array<{ id: string }>>`
SELECT "id" FROM "users"
WHERE "module_preferences_json" -> ${module}::text = 'false'::jsonb`;
@@ -651,6 +651,7 @@ const LIVE_PG_BOSS_STATES = new Set(["active", "created", "retry"]);
*/
export async function stagedImportFilesInUse(): Promise<StagingInUse | null> {
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<StagingInUse | null> {
*/
export async function reconcileOrphanImportJobs(): Promise<void> {
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"] },
+1
View File
@@ -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";
+1
View File
@@ -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";
+1
View File
@@ -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";
+28
View File
@@ -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<void> {
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 },
+3 -3
View File
@@ -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,
+2
View File
@@ -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 },
+11 -7
View File
@@ -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 },
});
@@ -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";
+2 -1
View File
@@ -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 },
});
+3 -2
View File
@@ -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,
+6 -1
View File
@@ -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 } } },
+2 -1
View File
@@ -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<JobOutcome> {
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(),
@@ -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<Array<{ user_id: string }>>`
const users = await dropPendingDeletion(
prismaClient,
await prismaClient.$queryRaw<Array<{ user_id: string }>>`
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);
}
+15 -10
View File
@@ -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 };
+2
View File
@@ -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,
+3 -1
View File
@@ -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) {
@@ -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: {} } },
+2 -1
View File
@@ -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(),
+1
View File
@@ -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;
@@ -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";
+1
View File
@@ -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";
+2 -1
View File
@@ -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 },
});
+2 -1
View File
@@ -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 },
});
+4 -4
View File
@@ -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 },
});
}
+6 -1
View File
@@ -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: [] };
+6 -1
View File
@@ -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,
+7
View File
@@ -43,6 +43,12 @@ export type JobFacts = Readonly<Record<string, JobFact>>;
*/
export const JOB_FACT_ALLOWLIST: ReadonlySet<string> = 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<string> = new Set([
"persisted",
"plan_reviews_minted",
"practitioner_contacts_migrated",
"pre_wipe_copies_deleted",
"pr_detection_inserted",
"pr_detection_jobs",
"pr_detection_ties",
+3 -1
View File
@@ -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 };
+14 -9
View File
@@ -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 };
+2 -1
View File
@@ -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;
+3 -2
View File
@@ -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: {
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff. Show more