Commit Graph
6 Commits
Author SHA1 Message Date
Marc-André Bombeck a69666fa16 feat(account): hold account deletion and data wipe for a grace window
Deleting an account, a guardian deleting a managed profile, and "Delete
all data" were final the moment they were confirmed: the account row
cascaded into every table and every stored copy, and the off-host objects
were purged in the same breath. Nothing short of rolling the whole
instance back could return one account.

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

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

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

The privacy summary publishes the window. Two pieces of the reminder tick
and the off-host pass moved into modules of their own to keep the files
inside their size ratchet.
2026-10-25 13:50:14 +01:00
Marc-André Bombeck c7f69fc212 fix(auth): answer 503 when the session store cannot be read
The cookie resolvers folded a failed query into null, so a database restart
answered 401 and every client discarded a good credential. The lookup now
raises SessionStoreUnavailableError, which reaches the wire as 503 with
auth.session.unavailable. Logout still clears the cookie on a failed lookup
and records a warning.
2026-10-20 05:43:58 +02:00
Marc-André Bombeck 0fc83cb0a1 fix(auth): ask for a fresh proof before the record or a credential to it leaves
Exporting the whole record, making a clinician share link, minting a token,
and the admin backup download, upload and restore, data wipe and password
reset ran on a live session alone. The plaintext full backup did, while the
encrypted one beside it asked for a second factor. Whoever held a stolen
session could take the record, or leave behind a credential that outlives
the session.

These now go through one gate, requireRecentProof (assertRecentCookieProof
for the routes that resolve their caller themselves). A browser session
passes after a sign-in or re-proof within five minutes: a second factor on
an account that has one, otherwise the password or a passkey. The refusal is
401 auth.reproof.required with meta.methods, and the web opens the re-proof
dialog, posts the proof to the new POST /api/auth/reproof and retries. A
password proof stamps the new Session.reproofAt (migration 0359), never
mfaVerifiedAt, so it cannot stand in for a second factor anywhere.

On Bearer, the full backup asks for an X-Step-Up elevation; the encrypted
export asks for a second-factor elevation on an account that has one. Share
links and connector tokens keep taking the token alone, because the shipped
app calls them without an elevation and reads a 401 there as a dead
session; the guard test names them.

Key rotation keeps its stricter second-factor gate.
2026-09-26 19:54:38 +02:00
Marc-André Bombeck 2c63b0c4a7 fix(api): give the remaining 401 throws a code, and say what the code covers
Three refusals still answered 401 with prose alone: the Bearer re-read on
`/api/auth/me/sessions`, and the wrong-second-factor arm on both MFA step-up
routes. The shared 401 description told clients the code is the field to
branch on, so a client that took that advice landed in its default arm on all
three — and on the MFA pair that is the difference between re-prompting for a
digit and signing the user out over a typo.

The sessions re-read is the same missing-credential fact the gate already
names, so it reuses `auth.missing`. The MFA pair gets a code of its own,
`auth.mfa.code_invalid`: none of the existing ones fit, because every other
401 code asks the client to discard something, and this one asks it to keep
the session and try another code.

The description narrows to match what is actually true. The generic gates
name a reason; a route that checks a credential of its own — sign-in, the MFA
challenge exchange, a password confirmation — still answers 401 with prose,
and the text now says so instead of promising a field those responses do not
carry.
2026-09-10 04:59:40 +02:00
Marc-André Bombeck 21dd7c7e48 fix(api): give the generic auth refusals a stable errorCode
`requireAuth`, `requireAdmin`, `requireCookieAuth`, `requireFreshMfa` and
`requireBearerAuth` all refused with a bare prose string and no `meta`.
So the single most important branch a native client makes — refresh the
token and retry, drop the session and show the login screen, or stop
retrying because this credential will never work — could only be made by
string-matching the English sentence "Token expired". Any rewording of
that string, or a localisation pass, silently turns a recoverable
refresh into a forced logout for every already-shipped build.

The mechanism was already here and these gates were the ones skipped:
the assistant, consent, step-up, module and sharing refusals all carry
`meta.errorCode`, and `/api/auth/refresh` emits `auth.refresh.*`. So
`HttpError` takes an optional code and the wrapper puts it in the same
field. Five values, dot.case like the majority: `auth.missing`,
`auth.token.expired`, `auth.token.invalid`, `auth.scope.insufficient`,
`auth.admin.required`. A revoked token reads invalid, not expired —
refreshing a revoked token loops forever.

Additive: an HttpError thrown without a code serialises byte-identically
to before, which is most of them. The published 401 description now
enumerates the codes and says what a client should do with each.
2026-09-10 04:24:34 +02:00
Marc-André Bombeck b7dfd236ee Move the API error classes into a leaf module
api-handler imports the record-session fence and the fence threw
RecordSessionChangedError / SharingAccessDeniedError imported back from
api-handler — the one real import cycle in the tree, working only by
evaluation-order luck. HttpError, the SharingAuthError family and
StepUpRequiredError now live in api-errors.ts (importing nothing but
the fence contract), api-handler re-exports the identical class objects
so no importer moves, and the fence imports the leaf. A guard pins both
the identity and the leaf structure.
2026-08-14 02:26:05 +02:00