mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
5856b2a547
commit
df5dd1c160
4 files changed
+90
-20
No files matched your search
@@ -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
@@ -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:
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user