docs: publish the Bearer arm on the two erasure routes

The published contract still told a client that a Bearer token can never
clear the step-up on account deletion or the record wipe, which is what
an app generates its client from. Describe the elevation arm on both:
which factors qualify, that a password proof does not, that the proof
rides the X-Step-Up header, and that it is spent only when the erasure
runs. The 403 a narrow-scope token gets was never published either;
it is named now, ahead of the second-factor question it precedes.

The mint endpoint lists the surfaces an elevation unlocks, so both
routes join that list.

Also note on the record wipe that API tokens are on its own delete list:
a client that wipes over Bearer signs itself out, which is worth knowing
before the retry rather than after it.
This commit is contained in:
Marc-André Bombeck committed 2026-09-02 11:51:19 +02:00
1 parent 5856b2a547
commit df5dd1c160
4 files changed
+90 -20

No files matched your search

+16
View File
@@ -4,6 +4,22 @@
### Fixed
- **An account with two-factor can be deleted from the app.** Deleting
the account, and erasing the record while keeping the account, both demand a
fresh second-factor proof once a factor is enrolled. That proof lived on the
session row, which a native client does not have, so anyone who had turned on
TOTP or registered a security key was refused on both endpoints from the app
no matter what they sent — the only way through was a browser. The app can
now re-prove a factor the same way it already does for second-factor
management: mint a single-use elevation at `POST /api/auth/step-up` with a
TOTP code, security key, or passkey, and send it back in the `X-Step-Up`
header alongside the deletion. The web path is untouched, a token on its own
still gets nowhere, a password-proved elevation is refused for erasure just as
a password login never satisfied the gate on the web, and the proof is spent
only when the erasure is about to run, so a mistyped confirmation does not
burn it. One thing to know when erasing the record over a token: API tokens
are part of what that request deletes, so the app signs itself out and has to
sign in again.
- **Anthropic keys work again on current Claude models.** Every JSON surface
on the Anthropic provider (the daily briefing, the status cards, lab and
document extraction, anything that asks the model for a JSON object) had
+55 -14
View File
@@ -1989,8 +1989,15 @@ paths:
Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST
/api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST
/api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE
/api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET
/api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.
/api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA
routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation
at all. Nothing else accepts one — admin endpoints stay cookie-only.
The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their
gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They
take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to
run.
requestBody:
required: true
content:
@@ -11965,10 +11972,19 @@ paths:
Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor
enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh
factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore
cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on
either transport, deliberately, because a user who never enrolled has no ceremony to complete.
enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh
factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport,
deliberately, because a user who never enrolled has no ceremony to complete.
How the fresh proof is presented depends on the transport. A cookie session carries it on the session row:
complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use
elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or
`passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies
step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when
the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on
the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from
the web.
Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves,
@@ -11993,9 +12009,20 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401":
description: Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough
for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`).
Re-verify the factor on the web and retry; a Bearer token cannot clear this arm.
description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough
for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On
a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST
/api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code
covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from
too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is
audited.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
happens before any second-factor proof is looked at.
content:
application/json:
schema:
@@ -12181,13 +12208,19 @@ paths:
data.
Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account,
which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route
wants.
Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on
a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use
`X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The
confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.
One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction
commits, as the receipt for the erasure the person asked for.
A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token
that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again
rather than retrying with the same credential.
requestBody:
required: true
content:
@@ -12202,9 +12235,17 @@ paths:
schema:
$ref: "#/components/schemas/DataWipeEnvelope"
"401":
description: Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough
description: Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough
for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A
Bearer token cannot clear this arm.
Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an
elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
happens before any second-factor proof is looked at.
content:
application/json:
schema:
+2 -1
View File
@@ -840,7 +840,8 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"WHICH factor you re-prove decides WHAT the elevation reaches. `password` reaches the same routes a plain cookie session reaches. `totp`, `webauthn`, and `passkey` additionally satisfy the fresh-factor routes — MFA disable, recovery-code regeneration, security-key removal — which is precisely the set of ceremonies for which the web marks a session second-factor-verified. The response carries `satisfiesFreshFactor` so a client can choose the right ceremony up front rather than discovering the refusal after spending a proof. A recovery code is NOT accepted here; an account that has lost its authenticator manages its second factor on the web.\n\n" +
"The elevation is bound to the exact token that minted it (another token, including the same account's, cannot redeem it), single-use, and valid for five minutes — the same window the cookie path uses. Present it as `X-Step-Up: hle_…` alongside the normal `Authorization: Bearer` header. It is spent only when the target route is about to act, so a 429, a 422, or a wrong code does not burn it.\n\n" +
"Presenting the token alone mints nothing: the body must carry a fresh factor proof. Every failure — wrong password, no password set on an SSO-provisioned account, an assertion for another account, a stale challenge, a replayed TOTP step — returns the same 401 with the same prose, and is audited server-side. Rate-limited per account (5 / 15 min) and per source address.\n\n" +
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}. The last three of those and disable require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.",
"Accepting routes (the complete set): POST /api/auth/me/mfa/totp/setup; POST /api/auth/me/mfa/totp/confirm; POST /api/auth/me/mfa/disable; POST /api/auth/me/mfa/recovery-codes/regenerate; POST /api/auth/me/mfa/webauthn/register/options; POST /api/auth/me/mfa/webauthn/register/verify; PATCH and DELETE /api/auth/me/mfa/webauthn/{id}; DELETE /api/settings/account; DELETE /api/settings/data. The last three MFA routes, disable, and both erasure routes require a fresh-factor proof. GET /api/auth/me/mfa needs no elevation at all. Nothing else accepts one — admin endpoints stay cookie-only.\n\n" +
"The two erasure routes joined the set so an account with a second factor can be deleted from a native app: their gate was cookie-only, which left an enrolled user unable to delete their own account from the app at all. They take the elevation the same way as the rest — fresh factor required, spent only when the erasure is about to run.",
requestBody: {
required: true,
content: { "application/json": { schema: stepUpMintSchema } },
+17 -5
View File
@@ -562,7 +562,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Permanently delete the account and everything in it",
description:
"Erasure, not deactivation: the user row goes, every related row cascades with it, and the account's own audit history — IP addresses, login geo — is purged inside the same transaction rather than being left orphaned. There is no undo and no grace period.\n\n" +
"Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the session must additionally carry a fresh factor proof, which is cookie-only by construction: a Bearer transport carries no `mfaVerifiedAt` and therefore cannot satisfy it at any scope. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" +
"Two gates, and they compose. The body must carry the typed confirmation. And for an account with a second factor enrolled — a confirmed TOTP secret OR a registered security key — the caller must additionally present a fresh factor proof. An account with NO second factor keeps the typed-confirmation-only contract on either transport, deliberately, because a user who never enrolled has no ceremony to complete.\n\n" +
"How the fresh proof is presented depends on the transport. A cookie session carries it on the session row: complete the second factor within the last five minutes and retry. A Bearer caller presents a single-use elevation from `POST /api/auth/step-up` in the `X-Step-Up` header, minted against a `totp`, `webauthn`, or `passkey` proof — a `password`-proved elevation is refused here exactly as a password login never satisfies step-up on the web. The elevation is bound to the token that minted it, valid five minutes, and spent only when the deletion is about to run, so a rejected confirmation does not burn it. A recovery code is not accepted on the Bearer path: the mint does not take one, so an account that has lost its authenticator deletes itself from the web.\n\n" +
"Two refusals are not about the caller's credentials at all. The last remaining admin cannot delete themselves, and the last Guardian of a managed profile cannot leave that profile unattended.",
requestBody: {
required: true,
@@ -597,7 +598,12 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"401": {
description:
"Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). Re-verify the factor on the web and retry; a Bearer token cannot clear this arm.",
"Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). On a cookie session, re-verify the factor and retry; on a Bearer token, mint an elevation at `POST /api/auth/step-up` with a `totp`, `webauthn`, or `passkey` proof and resend with `X-Step-Up`. The same code covers an elevation that is unknown, expired, already spent, minted for a different token, or minted from too weak a factor — the refusals are deliberately indistinguishable on the wire, and the real reason is audited.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -749,8 +755,9 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"The other half of account deletion: this removes what the account HAS and keeps what signs into it. Ninety-nine models are hard-deleted for this user — measurements and every derived rollup, medications and their intake history, mood, labs, documents, cycle, workouts, nutrients, notification channels, Coach conversations, consent receipts, audit history — with no `deletedAt` filter, so soft-deleted tombstones go too. The account row survives with its personal columns reset: body and identity fields, home location, cached AI output. What is deliberately kept is the credential set (password hash, SSO binding, second-factor secret and enrolment, trusted-device state) and the interface preferences (timezone, locale, units, time and date format, glucose unit), because those are about the account rather than the record.\n\n" +
"The model list is not written at the route. It is declared once in the wipe plan and held against the Prisma schema by a completeness test, which exists because this endpoint once deleted thirteen tables out of eighty-odd while telling people it had deleted everything.\n\n" +
"One transaction, with a two-minute budget: a half-applied wipe is worse than none, because the person is told it succeeded either way. Every cached payload for the account is evicted afterwards so no surface serves pre-wipe data.\n\n" +
"Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account, which is cookie-only by construction. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" +
"One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.",
"Gated like account deletion: the typed confirmation, plus a fresh factor proof for an MFA-enrolled account — on a cookie session the second factor completed within the last five minutes, on a Bearer token a single-use `X-Step-Up` elevation minted at `POST /api/auth/step-up` against a `totp`, `webauthn`, or `passkey` proof. The confirmation string is `DELETE`, NOT the `DELETE_ACCOUNT` that route wants.\n\n" +
"One audit row survives the wipe that clears the rest of the audit history: it is written after the transaction commits, as the receipt for the erasure the person asked for.\n\n" +
"A Bearer client should expect to be signed out by its own request: API tokens are on the wipe list, so the token that called this — and any step-up elevation minted from it — is removed along with the record. Sign in again rather than retrying with the same credential.",
requestBody: {
required: true,
content: { "application/json": { schema: dataWipeRequest } },
@@ -777,7 +784,12 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"401": {
description:
"Not authenticated — or the account has a second factor and the session carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer token cannot clear this arm.",
"Not authenticated — or the account has a second factor and the caller carries no proof of it fresh enough for a destructive action (`meta.errorCode` = `auth.stepup.required`, or `auth.stepup.mfa_not_enrolled`). A Bearer caller clears it with an `X-Step-Up` elevation from `POST /api/auth/step-up`; the same code covers an elevation that is unknown, expired, already spent, bound to another token, or minted from too weak a factor.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
content: { "application/json": { schema: errorEnvelope } },
},
},