diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd50e0c2..828e226ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.38.20] — 2026-09-11 + +Signing in no longer tells anybody which addresses have an account here. + +### Security + +- **Signing in now takes the same time whether or not the account exists.** + A wrong password and an address nobody has registered here have always + given you the same answer on screen, but the server used to reach that + answer much faster when there was no account to check, and the same when + the account signs in with a passkey and has no password at all. Someone + probing the sign-in page could read that difference and learn which + addresses are registered, and which of those have a password. Every + refusal now does the same work before it answers. +- **Changing your email address is limited to ten changes an hour.** The + server tells you when the address you typed already belongs to another + account, which is the only sensible thing to say to someone who typed one. + Nothing stopped a signed-in caller from asking that question over and over + about addresses that were not theirs. Ten an hour per account is far above + any real use of the field, and a save that leaves your address as it is + does not count against it. If you hit the limit while saving other things + on the settings page, those are saved and only the address is held back, + with a note under the field. If you write against the API, a request that + asked for nothing but the address gets a 429 on `PUT /api/auth/profile` + and `PATCH /api/user/profile` with `meta.errorCode` set to + `profile.update.emailRateLimited` and a `Retry-After` header saying how + long to wait; a request that carried other fields gets 200 with `email` + listed in `rejectedFields` under the code `rate_limited`. + +### Removed + +- **The endpoint that answered whether an address has an account here is + gone.** `POST /api/auth/check-user` took an email address or a username + and said, to anyone who asked and without any sign-in, whether an account + existed and whether it had a passkey or a password. It was built to decide + which field a sign-in screen shows first, which is not worth telling the + world who has an account here. The path now answers 410 and says so in the + API reference rather than disappearing, so anything still calling it gets + a clear "this is gone, stop retrying" instead of a 404 that looks like a + broken server. If you wrote something against it, the sign-in request + itself is the replacement: send the credential and read the answer. + ## [1.38.19] — 2026-09-11 The first run tells you what is actually true about your account, an diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3649c8157..fb07ebd4e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.19 + version: 1.38.20 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -1809,31 +1809,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - /api/auth/check-user: - post: - tags: - - Auth - summary: Resolve the next sign-in step for a typed identifier - description: Given an email or username, returns which onboarding branch the iOS client should render plus the - `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a - second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same - whether or not the identifier matched, and the identifier is never echoed back. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CheckUserRequest" - responses: - "200": - description: Discovery result. - content: - application/json: - schema: - $ref: "#/components/schemas/CheckUserEnvelope" - "401": *a1 - "422": *a4 - "429": *a5 /api/auth/login: post: security: [] @@ -3123,7 +3098,22 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": &a14 + description: "Too many email-address changes, and the request asked for nothing else. The address change shares one + per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — + keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. + `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from + the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any + update that does not touch `email` at all. A request that carries other fields beside the refused address + does NOT get this response — it answers 200, saves those fields, and names `email` in `rejectedFields` with + code `rate_limited`, so one spent budget cannot block an unrelated profile edit for an hour. The + `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that + bucket; back off on `Retry-After` rather than guessing." + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/auth/oidc/login: get: security: [] @@ -4239,7 +4229,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a15 + "403": &a16 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -7717,8 +7707,15 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 + "409": + description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing + was written. Shared with PUT /api/auth/profile, which runs the same handler. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a14 /api/auth/me/notification-prefs: get: tags: @@ -9118,7 +9115,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a14 + "404": &a15 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9154,7 +9151,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a14 + "404": *a15 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10278,7 +10275,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/insights/narrative: @@ -11048,7 +11045,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11275,7 +11272,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/mood/insights: @@ -11297,7 +11294,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11430,7 +11427,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a16 + "404": &a17 description: Not found / not owned by the caller. content: application/json: @@ -11465,7 +11462,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11501,7 +11498,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11554,7 +11551,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 delete: @@ -11585,7 +11582,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11750,7 +11747,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "409": &a17 + "409": &a18 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -11782,7 +11779,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 put: @@ -11817,8 +11814,8 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a16 - "409": *a17 + "404": *a17 + "409": *a18 "422": *a4 "429": *a5 delete: @@ -11844,7 +11841,7 @@ paths: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11994,7 +11991,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a18 + "400": &a19 description: Body is not parseable JSON. content: application/json: @@ -12007,13 +12004,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a19 + "413": &a20 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a20 + "415": &a21 description: Content-Type is not `application/json`. content: application/json: @@ -12073,7 +12070,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a18 + "400": *a19 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12088,8 +12085,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a19 - "415": *a20 + "413": *a20 + "415": *a21 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12143,10 +12140,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a18 + "400": *a19 "401": *a1 - "413": *a19 - "415": *a20 + "413": *a20 + "415": *a21 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -12645,7 +12642,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a21 + "400": &a22 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." content: @@ -12694,7 +12691,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a21 + "400": *a22 "401": *a1 "422": *a4 "429": *a5 @@ -12980,7 +12977,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a9 - "404": &a22 + "404": &a23 description: Measurement reminder not found / not owned. content: application/json: @@ -13015,7 +13012,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 delete: @@ -13038,7 +13035,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13063,7 +13060,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13091,7 +13088,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13121,7 +13118,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13155,7 +13152,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13196,7 +13193,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/labs: @@ -13320,7 +13317,7 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a9 - "404": &a23 + "404": &a24 description: Lab result not found (or owned by another user). content: application/json: @@ -13355,7 +13352,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 delete: @@ -13378,7 +13375,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/labs/restore: @@ -13534,7 +13531,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a9 - "409": &a25 + "409": &a26 description: A biomarker with this name already exists for the caller. content: application/json: @@ -13563,7 +13560,7 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a9 - "404": &a24 + "404": &a25 description: Biomarker not found (or owned by another user). content: application/json: @@ -13598,8 +13595,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a9 - "404": *a24 - "409": *a25 + "404": *a25 + "409": *a26 "422": *a4 "429": *a5 delete: @@ -13622,7 +13619,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 /api/encounters: @@ -13761,7 +13758,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a9 - "404": &a26 + "404": &a27 description: Visit not found (or owned by another user). content: application/json: @@ -13795,7 +13792,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13820,7 +13817,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13846,7 +13843,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13876,7 +13873,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13905,7 +13902,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/practitioners: @@ -13984,7 +13981,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a9 - "404": &a27 + "404": &a28 description: Practitioner not found (or owned by another user). content: application/json: @@ -14018,7 +14015,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14042,7 +14039,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14068,7 +14065,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations: @@ -14163,7 +14160,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a9 - "404": &a28 + "404": &a29 description: Vaccination not found (or owned by another user). content: application/json: @@ -14198,7 +14195,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14223,7 +14220,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14248,7 +14245,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14314,7 +14311,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14372,7 +14369,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14401,7 +14398,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14485,7 +14482,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": &a29 + "404": &a30 description: Illness episode not found (or owned by another user). content: application/json: @@ -14520,7 +14517,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14544,7 +14541,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14572,7 +14569,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "409": *a7 "422": *a4 "429": *a5 @@ -14605,7 +14602,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14656,7 +14653,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 post: @@ -14693,7 +14690,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14720,7 +14717,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/insights: @@ -14936,7 +14933,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a9 - "404": &a30 + "404": &a31 description: Allergy not found (or owned by another user). content: application/json: @@ -14970,7 +14967,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14994,7 +14991,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/family-history: @@ -15068,7 +15065,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": &a31 + "404": &a32 description: Family history entry not found (or owned by another user). content: application/json: @@ -15102,7 +15099,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15126,7 +15123,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/documents/inbound: @@ -16021,7 +16018,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a33 + "409": &a34 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16050,7 +16047,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a9 - "404": &a32 + "404": &a33 description: Custom metric not found (or owned by another user). content: application/json: @@ -16084,8 +16081,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a32 - "409": *a33 + "404": *a33 + "409": *a34 "422": *a4 "429": *a5 delete: @@ -16108,7 +16105,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16152,7 +16149,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 post: @@ -16181,7 +16178,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a32 + "404": *a33 "409": *a7 "422": *a4 "429": *a10 @@ -16217,7 +16214,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a34 + "404": &a35 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16250,7 +16247,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16659,7 +16656,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a35 + "403": &a36 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -16714,7 +16711,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16743,7 +16740,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "409": *a7 "422": *a4 "429": *a5 @@ -17245,26 +17242,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a36 + "400": &a37 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a37 + "413": &a38 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a38 + "415": &a39 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a39 + "422": &a40 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -17450,11 +17447,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17597,11 +17594,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17754,11 +17751,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17834,21 +17831,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a40 + "422": &a41 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a41 + "429": &a42 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a42 + "502": &a43 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -17893,11 +17890,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17973,9 +17970,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/strava/credentials: get: tags: @@ -18011,11 +18008,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -18093,9 +18090,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/status: get: tags: @@ -18131,7 +18128,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/fitbit/test: post: tags: @@ -18149,9 +18146,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/google-health/resume: post: tags: @@ -18168,7 +18165,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/google-health/test: post: tags: @@ -18201,9 +18198,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/whoop/resume: post: tags: @@ -18220,7 +18217,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/whoop/test: post: tags: @@ -18237,9 +18234,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/withings/resume: post: tags: @@ -18257,7 +18254,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/withings/test: post: tags: @@ -18275,8 +18272,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 + "422": *a41 + "429": *a42 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -18340,7 +18337,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18513,7 +18510,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a43 + "403": &a44 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -18538,7 +18535,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "422": *a4 "429": *a5 post: @@ -18633,7 +18630,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "404": description: No such invitation for this account. content: @@ -18677,7 +18674,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "404": description: No such grant on this account's record. content: @@ -18718,7 +18715,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "404": description: No such grant held by this account. content: @@ -19100,7 +19097,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -19430,7 +19427,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a44 + "403": &a45 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -19463,7 +19460,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -19490,7 +19487,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "422": *a4 "429": *a5 post: @@ -19520,7 +19517,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "413": description: Body exceeds 16 KiB. content: @@ -19563,7 +19560,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -19621,7 +19618,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a45 + - &a46 name: family in: path required: true @@ -19665,7 +19662,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a45 + - *a46 requestBody: required: true content: @@ -19817,7 +19814,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/environment/home: @@ -19854,7 +19851,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -19908,7 +19905,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19967,7 +19964,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -20006,7 +20003,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -20059,7 +20056,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -20200,13 +20197,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a46 + "403": &a47 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a47 + "404": &a48 description: No stored copy with this id. content: application/json: @@ -20265,8 +20262,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a46 - "404": *a47 + "403": *a47 + "404": *a48 "409": description: Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. @@ -20422,6 +20419,36 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/auth/check-user: + post: + security: [] + deprecated: true + tags: + - Retired + summary: POST /api/auth/check-user — removed in v1.38.20 + description: >- + Removed in v1.38.20. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Took an email address or a username and answered, to any caller and with no credential at all, whether an + account existed here and whether it carried a passkey or a password. It was built to decide which field a + sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST + /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the + account exists. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.38.20 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/auth/me/research-mode: get: security: [] @@ -21129,17 +21156,6 @@ components: server-side recovery. required: - passphrase - CheckUserRequest: - type: object - properties: - identifier: - type: string - minLength: 1 - maxLength: 254 - description: The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back. - required: - - identifier - description: "Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step." LoginPasswordRequest: type: object properties: @@ -30002,9 +30018,9 @@ components: onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — - practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, - profile.update.nothingSaved; record_write — record_write.rate_limited; report-selection — - report-selection.body.invalid_json, report-selection.body.invalid_shape, + practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, + profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited; + report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, @@ -36587,49 +36603,6 @@ components: omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`." - CheckUserEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/CheckUserResponse" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - CheckUserResponse: - type: object - properties: - branch: - type: string - enum: - - not_found - - passkey_only - - email_fallback - - exists - description: "Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` - (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — - recovery path)." - hasPasskey: - type: boolean - description: The account has at least one registered passkey. - hasPassword: - type: boolean - description: The account has a password hash. - required: - - branch - - hasPasskey - - hasPassword - additionalProperties: false - description: Account-existence + credential shape. The response is identical whether or not the identifier matched - (account-existence is the explicit contract iOS needs); the identifier is never echoed. LoginResponse: type: object properties: @@ -37946,9 +37919,11 @@ components: description: Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence. rejectedFields: - description: "Present only on a PARTIAL success: the fields that failed validation and were skipped while the rest of - the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the - response as a clean save." + description: "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A + 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. + `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address + change that has spent the account's hourly budget; that one is not a bad value and the same address will be + accepted once the window rolls over." type: array items: type: object @@ -46842,6 +46817,28 @@ components: hasInsuranceNumber: type: boolean description: Whether a KVNR is on file (the value itself is not echoed). + rejectedFields: + description: "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A + 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. + `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address + change that has spent the account's hourly budget; that one is not a bad value and the same address will be + accepted once the window rolls over. The handler has always been able to answer with this key on this path; + it was published only on PUT /api/auth/profile." + type: array + items: + type: object + properties: + path: + type: string + code: + type: string + message: + type: string + required: + - path + - code + - message + additionalProperties: false required: - username - email diff --git a/messages/de.json b/messages/de.json index d139727ec..3a48fb6f1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.", "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", - "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet." + "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.", + "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.", "invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.", "invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.", + "rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.", "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen." }, "avatar": { diff --git a/messages/en.json b/messages/en.json index cda491a00..b2a37305f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Nothing was saved. Check the highlighted field and try again.", "invalidBody": "The profile update could not be read.", - "emailInUse": "This email address is already in use." + "emailInUse": "This email address is already in use.", + "emailRateLimited": "Too many email-address changes. Try again later." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "That is not one of the answers this field accepts.", "invalidFormat": "That is not written in a form this field can read.", "invalidType": "That value did not arrive in a form this field can store.", + "rateLimited": "Changed too often. Wait a little and try again.", "other": "This value was not accepted. Check it and try again." }, "avatar": { diff --git a/messages/es.json b/messages/es.json index fbe4b3570..e3f4ca56c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.", "invalidBody": "No se pudo leer la actualización del perfil.", - "emailInUse": "Esta dirección de correo ya está en uso." + "emailInUse": "Esta dirección de correo ya está en uso.", + "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "Esa no es una de las respuestas que acepta este campo.", "invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.", "invalidType": "El valor no llegó en un formato que este campo pueda guardar.", + "rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.", "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo." }, "avatar": { diff --git a/messages/fr.json b/messages/fr.json index fc7ef2799..48fc47a50 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.", "invalidBody": "La mise à jour du profil n'a pas pu être lue.", - "emailInUse": "Cette adresse e-mail est déjà utilisée." + "emailInUse": "Cette adresse e-mail est déjà utilisée.", + "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.", "invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.", "invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.", + "rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.", "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez." }, "avatar": { diff --git a/messages/it.json b/messages/it.json index 99a3df2bd..0e97ed7be 100644 --- a/messages/it.json +++ b/messages/it.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.", "invalidBody": "Impossibile leggere l'aggiornamento del profilo.", - "emailInUse": "Questo indirizzo email è già in uso." + "emailInUse": "Questo indirizzo email è già in uso.", + "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "Questa non è una delle risposte accettate da questo campo.", "invalidFormat": "Non è scritto in un formato che questo campo possa leggere.", "invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.", + "rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.", "other": "Questo valore non è stato accettato. Controllalo e riprova." }, "avatar": { diff --git a/messages/ko.json b/messages/ko.json index ca5b3deb6..d09db31aa 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.", "invalidBody": "프로필 변경 내용을 읽지 못했어요.", - "emailInUse": "이미 사용 중인 이메일 주소예요." + "emailInUse": "이미 사용 중인 이메일 주소예요.", + "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "이 항목이 받는 답변이 아니에요.", "invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.", "invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.", + "rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.", "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요." }, "avatar": { diff --git a/messages/pl.json b/messages/pl.json index f436bcffb..3c84cbc85 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.", "invalidBody": "Nie udało się odczytać aktualizacji profilu.", - "emailInUse": "Ten adres e-mail jest już używany." + "emailInUse": "Ten adres e-mail jest już używany.", + "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później." } } }, @@ -5508,6 +5509,7 @@ "invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.", "invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.", "invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.", + "rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.", "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie." }, "avatar": { diff --git a/package.json b/package.json index d4de393df..b3ea6e51f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.19", + "version": "1.38.20", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 99bfccfd2..8c4284924 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.19"; + /* @sw-version-fallback */ "v1.38.20"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/api/auth/check-user/route.test.ts b/src/__tests__/api/auth/check-user/route.test.ts deleted file mode 100644 index 8ae3cbd40..000000000 --- a/src/__tests__/api/auth/check-user/route.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * v1.4.41 W-IOS-COORD SB-7 follow-up — pin the four-branch discovery - * contract for `POST /api/auth/check-user`. The iOS onboarding screen - * branches on `branch` to decide which credential form to render; any - * regression that drops a branch silently makes the wrong form appear. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findFirst: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - }), - // v1.4.43 W13 M-4 — the route now routes through the auth-surface - // wrapper. Default to a clean per-IP result. - checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - ip: "203.0.113.1", - }), - rateLimitHeaders: vi.fn(() => ({})), -})); - -vi.mock("@/lib/auth/hmac", () => ({ - hashToken: vi.fn( - (raw: string) => - `hash-${Buffer.from(raw, "utf-8") - .toString("hex") - .slice(0, 16) - .padStart(16, "0")}`, - ), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "@/app/api/auth/check-user/route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; - -interface CheckUserBody { - data: { - branch: "not_found" | "passkey_only" | "email_fallback" | "exists"; - hasPasskey: boolean; - hasPassword: boolean; - }; -} - -function makeRequest(body: unknown) { - return new Request("http://localhost/api/auth/check-user", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }) as unknown as Parameters[0]; -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - ip: "203.0.113.1", - } as never); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("POST /api/auth/check-user", () => { - it("returns `not_found` when no account matches the identifier", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - const res = await POST(makeRequest({ identifier: "ghost@example.com" })); - const body = (await res.json()) as CheckUserBody; - expect(res.status).toBe(200); - expect(body.data.branch).toBe("not_found"); - expect(body.data.hasPasskey).toBe(false); - expect(body.data.hasPassword).toBe(false); - }); - - it("returns `passkey_only` when the user has passkeys and no password", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "u1", - passwordHash: null, - _count: { passkeys: 2 }, - } as never); - const res = await POST(makeRequest({ identifier: "testuser" })); - const body = (await res.json()) as CheckUserBody; - expect(body.data.branch).toBe("passkey_only"); - expect(body.data.hasPasskey).toBe(true); - expect(body.data.hasPassword).toBe(false); - }); - - it("returns `email_fallback` when the user has a password (regardless of passkey count)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "u2", - passwordHash: "argon2id$…", - _count: { passkeys: 1 }, - } as never); - const res = await POST(makeRequest({ identifier: "testuser" })); - const body = (await res.json()) as CheckUserBody; - expect(body.data.branch).toBe("email_fallback"); - expect(body.data.hasPasskey).toBe(true); - expect(body.data.hasPassword).toBe(true); - }); - - it("returns `exists` when the user has neither passkey nor password (recovery path)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "u3", - passwordHash: null, - _count: { passkeys: 0 }, - } as never); - const res = await POST(makeRequest({ identifier: "testuser" })); - const body = (await res.json()) as CheckUserBody; - expect(body.data.branch).toBe("exists"); - expect(body.data.hasPasskey).toBe(false); - expect(body.data.hasPassword).toBe(false); - }); - - it("422s when identifier is missing or blank", async () => { - const res = await POST(makeRequest({})); - expect(res.status).toBe(422); - }); - - it("queries the identifier exactly as iOS sends it (no case-fold)", async () => { - // Register stores email + username verbatim (no `.toLowerCase()` - // transform in `registerSchema`); folding here would route a - // mixed-case existing account to the sign-up branch. - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - await POST(makeRequest({ identifier: "MixedCase@Example.com" })); - expect(prisma.user.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - OR: [ - { username: "MixedCase@Example.com" }, - { email: "MixedCase@Example.com" }, - ], - }, - }), - ); - }); - - it("returns 429 when the per-IP rate-limit is exhausted", async () => { - vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 15 * 60 * 1000, - ip: "203.0.113.1", - } as never); - const res = await POST(makeRequest({ identifier: "anyone@example.com" })); - expect(res.status).toBe(429); - expect(prisma.user.findFirst).not.toHaveBeenCalled(); - }); -}); diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index e59dffb9d..79de4e666 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/auth/password", () => ({ - verifyPassword: vi.fn().mockResolvedValue(true), + verifyPasswordOrDummy: vi.fn().mockResolvedValue(true), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -142,8 +142,8 @@ describe("login MFA branch", () => { }); it("wrong password never reaches the MFA branch", async () => { - const { verifyPassword } = await import("@/lib/auth/password"); - vi.mocked(verifyPassword).mockResolvedValueOnce(false); + const { verifyPasswordOrDummy } = await import("@/lib/auth/password"); + vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false); vi.mocked(prisma.user.findFirst).mockResolvedValue({ ...BASE_USER, totpConfirmedAt: new Date(), diff --git a/src/__tests__/profile-email-rate-limit-contract.test.ts b/src/__tests__/profile-email-rate-limit-contract.test.ts new file mode 100644 index 000000000..fbcf6d450 --- /dev/null +++ b/src/__tests__/profile-email-rate-limit-contract.test.ts @@ -0,0 +1,140 @@ +/** + * The profile email-address ceiling is the one the contract describes. + * + * Changing the address asks whether another account already holds it, and the + * answer is a 409 versus a 200 — an existence check over every address on the + * instance, available to any signed-in caller, including the native client's + * own wildcard token. Neither route that reaches it had a ceiling of any kind, + * and `apiHandler` supplies no default, so the sweep was bounded only by the + * network. + * + * A published number is worth reading only if it cannot drift from the code, + * so this pulls the bucket key and the window out of `prisma.$queryRaw` — the + * technique `rate-limit-auth-surface.test.ts` and the record-write contract + * both use — and holds the emitted OpenAPI description to what came back. + * Reading both from the same constant would prove nothing: the sentence is + * hand-written, deliberately, and this is what keeps the hand-written half + * honest. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PROFILE_EMAIL_BUCKET_PREFIX, + PROFILE_EMAIL_LIMIT, + PROFILE_EMAIL_WINDOW_MS, + checkProfileEmailRateLimit, +} from "@/lib/rate-limit"; +import { buildOpenApiDocument } from "@/lib/openapi/registry"; +import { profileEmailRateLimitResponse } from "@/lib/openapi/routes/shared"; +import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); + +import { prisma } from "@/lib/db"; + +/** The published sentence, read from the object the route modules spread. */ +const published = profileEmailRateLimitResponse["429"].description; + +/** Both doors onto the one handler that answers the conflict. */ +const OPERATIONS: Array<[path: string, method: "put" | "patch"]> = [ + ["/api/auth/profile", "put"], + ["/api/user/profile", "patch"], +]; + +beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockReset(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); +}); + +describe("the profile email-address bucket", () => { + it("keys on the acting account under the name the contract publishes", async () => { + await checkProfileEmailRateLimit("account-7"); + + const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!; + // `$queryRaw`INSERT … VALUES (${key}, …)`` lands the key at index 1 and + // the window interval at index 2. Read from the query rather than from + // the constant, so a renamed prefix that never reached the SQL fails. + expect(call[1]).toBe("profile-email:account-7"); + expect(call[2]).toBe(`${PROFILE_EMAIL_WINDOW_MS} milliseconds`); + expect(`${PROFILE_EMAIL_BUCKET_PREFIX}:account-7`).toBe(call[1]); + expect(published).toContain("`profile-email:`"); + }); + + it("gives a different account a different bucket, not a shared one", async () => { + await checkProfileEmailRateLimit("account-7"); + const first = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1]; + await checkProfileEmailRateLimit("account-8"); + const second = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1]; + expect(first).not.toBe(second); + }); + + it("refuses at the cap the contract publishes", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: PROFILE_EMAIL_LIMIT, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + await expect( + checkProfileEmailRateLimit("account-7"), + ).resolves.toMatchObject({ + allowed: true, + limit: PROFILE_EMAIL_LIMIT, + remaining: 0, + }); + + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { + count: PROFILE_EMAIL_LIMIT + 1, + reset_at: new Date(Date.now() + 60_000), + }, + ] as never); + await expect( + checkProfileEmailRateLimit("account-7"), + ).resolves.toMatchObject({ allowed: false }); + + const windowSeconds = PROFILE_EMAIL_WINDOW_MS / 1000; + expect(published).toContain( + `${PROFILE_EMAIL_LIMIT} requests per ${windowSeconds} seconds`, + ); + }); + + it("says the headers describe that bucket, and declares them", () => { + expect(Object.keys(profileEmailRateLimitResponse["429"].headers)).toEqual([ + "Retry-After", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ]); + expect(published).toContain("Retry-After"); + }); + + it("publishes a code a client can branch on, and registers it", () => { + expect(published).toContain("profile.update.emailRateLimited"); + expect(ERROR_CODE_CATALOGUE.profile).toContain( + "profile.update.emailRateLimited", + ); + }); + + it("says this response is only for a request that asked for nothing else", () => { + // The refusal narrows to the field when the request carries other + // fields, so a client reading the 429 as "the whole save failed" would + // be wrong. The sentence has to carry that or it is worse than silence. + expect(published).toContain("rejectedFields"); + expect(published).toContain("rate_limited"); + }); + + it("is published on both operations that answer it", () => { + const doc = buildOpenApiDocument() as unknown as { + paths: Record< + string, + Record }> + >; + }; + const unpublished = OPERATIONS.filter( + ([path, method]) => + doc.paths[path]?.[method]?.responses?.["429"]?.description !== + published, + ).map(([path]) => path); + expect(unpublished).toEqual([]); + }); +}); diff --git a/src/app/api/auth/check-user/__tests__/route.test.ts b/src/app/api/auth/check-user/__tests__/route.test.ts deleted file mode 100644 index 660ee1abf..000000000 --- a/src/app/api/auth/check-user/__tests__/route.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// --- Mocks must come before importing the route. --- - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { findFirst: vi.fn() }, - }, -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the -// shape only matters here, so a deterministic stand-in keeps the test -// stable without standing up the env-keyed CipherSuite. The PII check -// below asserts the raw identifier never appears in the audit payload. -vi.mock("@/lib/auth/hmac", () => ({ - hashToken: vi.fn( - (raw: string) => - `hash-${Buffer.from(raw, "utf-8") - .toString("hex") - .slice(0, 16) - .padStart(16, "0")}`, - ), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi - .fn() - .mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }), - // v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface - // wrapper so the trust-violation branch can swap to a tighter global - // bucket. Default to a clean per-IP result. - checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 30, - resetAt: 0, - ip: "203.0.113.1", - }), - rateLimitHeaders: vi.fn(() => ({})), -})); - -vi.mock("@/lib/logging/transports", () => ({ - emitIfSampled: vi.fn(), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; -import { hashToken } from "@/lib/auth/hmac"; - -function expectedHash(identifier: string): string { - return (hashToken as unknown as (s: string) => string)(identifier); -} - -function makeRequest(body: { identifier: string }): NextRequest { - return new NextRequest("http://localhost/api/auth/check-user", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.mocked(prisma.user.findFirst).mockReset(); - vi.mocked(auditLog).mockReset(); - vi.mocked(auditLog).mockResolvedValue(undefined as never); -}); - -describe("POST /api/auth/check-user — audit-log row (M-1)", () => { - it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - - const res = await POST(makeRequest({ identifier: "ghost@example.com" })); - expect(res.status).toBe(200); - - expect(auditLog).toHaveBeenCalledTimes(1); - const args = vi.mocked(auditLog).mock.calls[0]; - expect(args[0]).toBe("auth.check-user"); - expect(args[1]).toEqual({ - ipAddress: "203.0.113.1", - details: { - branch: "not_found", - identifier_hash: expectedHash("ghost@example.com"), - }, - }); - // The raw identifier must not leak into the details — every other - // /api/auth/* audit row already hashes; the check-user route follows - // the same shape. - const details = args[1]?.details as Record | undefined; - expect(details).not.toHaveProperty("identifier"); - expect(JSON.stringify(details)).not.toContain("ghost@example.com"); - }); - - it("writes an audit row for the passkey_only branch with userId attached", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-1", - passwordHash: null, - _count: { passkeys: 2 }, - } as never); - - const res = await POST(makeRequest({ identifier: "testuser" })); - expect(res.status).toBe(200); - - expect(auditLog).toHaveBeenCalledTimes(1); - expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({ - userId: "user-1", - ipAddress: "203.0.113.1", - details: { - branch: "passkey_only", - identifier_hash: expectedHash("testuser"), - }, - }); - }); - - it("writes an audit row for the email_fallback branch with userId attached", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-2", - passwordHash: "$argon2id$...", - _count: { passkeys: 0 }, - } as never); - - const res = await POST(makeRequest({ identifier: "user@example.com" })); - expect(res.status).toBe(200); - - const args = vi.mocked(auditLog).mock.calls[0]; - expect(args[1]?.details).toEqual({ - branch: "email_fallback", - identifier_hash: expectedHash("user@example.com"), - }); - expect(args[1]?.userId).toBe("user-2"); - }); - - it("writes an audit row for the exists branch (no passkey + no password)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-3", - passwordHash: null, - _count: { passkeys: 0 }, - } as never); - - const res = await POST(makeRequest({ identifier: "stub" })); - expect(res.status).toBe(200); - - const args = vi.mocked(auditLog).mock.calls[0]; - expect(args[1]?.details).toEqual({ - branch: "exists", - identifier_hash: expectedHash("stub"), - }); - expect(args[1]?.userId).toBe("user-3"); - }); -}); diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts deleted file mode 100644 index 1aaf77170..000000000 --- a/src/app/api/auth/check-user/route.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * `POST /api/auth/check-user` — four-branch discovery for iOS onboarding. - * - * v1.4.41 W-IOS-COORD SB-7 follow-up. The iOS team's onboarding flow needs - * to know, given a typed identifier (username or email), what the next - * UX step should be: - * - * - `not_found` — no account exists; show the sign-up screen. - * - `passkey_only` — account exists, has at least one Passkey, no - * password hash. Show "Sign in with Passkey". - * - `email_fallback` — account exists, has a password hash (with or - * without a Passkey). Show password field plus - * a "Use Passkey" affordance when applicable. - * - `exists` — account exists with no usable credential - * (neither passkey nor password). Treat as - * recovery path; show "Reset access" hint. - * - * The route is intentionally narrow: - * - * - Accepts `{ identifier: string }` (either an email or a username). - * - Returns `{ branch, hasPasskey, hasPassword }` — booleans are - * included so the iOS client can render a "or sign in with Passkey" - * button alongside the password field without a second round-trip. - * - Never leaks PII. The response shape is the same regardless of - * whether the identifier matched (callers learn account-existence - * either way — that is the explicit contract iOS needs). The - * handler does NOT echo the identifier back. - * - Per-IP rate-limited (30 requests / 15 min) so the by-design - * enumeration contract cannot be brute-forced. Mirrors the - * `/api/auth/passkey/login-options` throttle shape. - * - The identifier is queried EXACTLY as iOS sends it (no - * `.toLowerCase()` normalisation). Email + username columns are - * stored verbatim by `/api/auth/register`, so case-folding here - * would route legitimate existing users to the sign-up branch. - * A future server-side normalisation pass on register write is - * the long-term fix. - */ -import { z } from "zod/v4"; -import { prisma } from "@/lib/db"; -import { - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; -import { apiHandler } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { hashToken } from "@/lib/auth/hmac"; -import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export const dynamic = "force-dynamic"; - -const bodySchema = z - .object({ - identifier: z.string().trim().min(1).max(254), - }) - .strict(); - -export type CheckUserBranch = - "not_found" | "passkey_only" | "email_fallback" | "exists"; - -export const POST = apiHandler(async (request: NextRequest) => { - // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. - const rl = await checkAuthSurfaceRateLimit( - request, - "auth:check-user", - 30, - 15 * 60 * 1000, - ); - const ip = rl.ip ?? "unknown"; - if (!rl.allowed) { - return NextResponse.json( - { data: null, error: "Too many requests. Please try again later." }, - { status: 429, headers: rateLimitHeaders(rl) }, - ); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = bodySchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "identifier required", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } - - const identifier = parsed.data.identifier; - - // Match on either username or email — both are unique in the schema. - const user = await prisma.user.findFirst({ - where: { - OR: [{ username: identifier }, { email: identifier }], - }, - select: { - id: true, - passwordHash: true, - _count: { select: { passkeys: true } }, - }, - }); - - // v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin - // can later answer "did anyone enumerate accounts last week?". The - // wide-event `annotate()` log is sampled and short-retention; the - // audit ledger is durable. We hash the identifier (same shape as the - // `auth.login.failed` row) so the literal email/username never lands - // in `AuditLog.details` — the v1.4.20 retroactive PII directive bars - // raw user-typed strings from operator-readable artefacts. - const identifierHash = hashToken(identifier); - - if (!user) { - annotate({ - action: { name: "auth.check-user" }, - meta: { branch: "not_found" }, - }); - await auditLog("auth.check-user", { - ipAddress: ip, - details: { branch: "not_found", identifier_hash: identifierHash }, - }); - return apiSuccess({ - branch: "not_found" satisfies CheckUserBranch, - hasPasskey: false, - hasPassword: false, - }); - } - - const hasPasskey = user._count.passkeys > 0; - const hasPassword = Boolean(user.passwordHash); - - let branch: CheckUserBranch; - if (hasPasskey && !hasPassword) branch = "passkey_only"; - else if (hasPassword) branch = "email_fallback"; - else branch = "exists"; - - annotate({ action: { name: "auth.check-user" }, meta: { branch } }); - await auditLog("auth.check-user", { - userId: user.id, - ipAddress: ip, - details: { branch, identifier_hash: identifierHash }, - }); - return apiSuccess({ branch, hasPasskey, hasPassword }); -}); diff --git a/src/app/api/auth/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts new file mode 100644 index 000000000..87be3506b --- /dev/null +++ b/src/app/api/auth/login/__tests__/constant-work.test.ts @@ -0,0 +1,155 @@ +/** + * Every login outcome reaches the verifier. + * + * The three refusals sign-in can produce — no such account, an account with + * no password hash, a wrong password — return the identical 401 body, and + * used to take three very different amounts of time to produce it, because + * the first two returned before any hashing happened. Argon2id at 19 MiB and + * t=2 against one indexed SELECT is a gap a caller can read over a network. + * + * The passkey-only account is the case that matters most. If it kept the + * cheap path, the channel would still answer "this account exists and has no + * password" — which is exactly what the removed discovery endpoint published + * in a field. + * + * What this proves is that the call happens on all three arms, not that the + * three durations match: the verifier is mocked here, and wall-clock equality + * is not something a unit test can assert. `password-dummy-verify.test.ts` + * carries the other half, that the verifier really does Argon2id work at the + * real cost parameters when there is no stored hash. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + }, +})); + +vi.mock("@/lib/auth/password", () => ({ + verifyPasswordOrDummy: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 5, + reset: 0, + ip: "1.2.3.4", + }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn(() => "aa".repeat(32)), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "someone@example.com", + password: "a-typed-password", + }), + }); +} + +beforeEach(() => { + vi.mocked(verifyPasswordOrDummy).mockReset(); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false); +}); + +describe("POST /api/auth/login — constant work across the refusals", () => { + it("verifies even when no account matches the identifier", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never); + + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1); + expect(verifyPasswordOrDummy).toHaveBeenCalledWith( + undefined, + "a-typed-password", + ); + }); + + it("verifies for a passkey-only account, which carries no password hash", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-1", + passwordHash: null, + totpConfirmedAt: null, + mfaEnforced: false, + } as never); + + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1); + expect(verifyPasswordOrDummy).toHaveBeenCalledWith( + null, + "a-typed-password", + ); + }); + + it("verifies for an account that does carry a password hash", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-2", + passwordHash: "$argon2id$stored", + totpConfirmedAt: null, + mfaEnforced: false, + } as never); + + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1); + expect(verifyPasswordOrDummy).toHaveBeenCalledWith( + "$argon2id$stored", + "a-typed-password", + ); + }); + + it("answers the three refusals with the identical status and body", async () => { + const bodies: string[] = []; + const statuses: number[] = []; + const accounts = [ + null, + { id: "user-1", passwordHash: null }, + { id: "user-2", passwordHash: "$argon2id$stored" }, + ]; + for (const account of accounts) { + vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never); + const res = await POST(makeRequest()); + statuses.push(res.status); + bodies.push(await res.text()); + } + expect(new Set(statuses)).toEqual(new Set([401])); + expect(new Set(bodies).size).toBe(1); + }); +}); diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index fc1212912..7089dbff9 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/password", () => ({ - verifyPassword: vi.fn(), + verifyPasswordOrDummy: vi.fn(), })); vi.mock("@/lib/auth/mfa-enrollment", () => ({ @@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { checkRateLimit, checkAuthSurfaceRateLimit, @@ -108,7 +108,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never); vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); - vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true); vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); vi.mocked(prisma.refreshToken.create).mockResolvedValue({ id: "rt-1", diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index c82732f95..fb2df339b 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/password", () => ({ - verifyPassword: vi.fn(), + verifyPasswordOrDummy: vi.fn(), })); vi.mock("@/lib/auth/session", () => ({ @@ -71,7 +71,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, @@ -93,7 +93,7 @@ function makeRequest(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never); - vi.mocked(verifyPassword).mockResolvedValue(false); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false); vi.mocked(auditLog).mockResolvedValue(undefined as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -175,7 +175,7 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => { const res = await POST(makeRequest()); // Falls through to normal password verification (which fails here - // since verifyPassword is mocked to return false) rather than 403 — + // since verifyPasswordOrDummy is mocked to return false) rather than 403 — // a half-set OIDC group must never lock everyone out. expect(res.status).toBe(401); }); diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts index cb895be6b..f8eab01a4 100644 --- a/src/app/api/auth/login/__tests__/trusted-device.test.ts +++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts @@ -8,7 +8,7 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ verifyPasswordOrDummy: vi.fn() })); vi.mock("@/lib/auth/login-response", () => ({ finishLogin: vi.fn(async () => new Response(null, { status: 200 })), @@ -58,7 +58,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { finishLogin } from "@/lib/auth/login-response"; import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; @@ -88,7 +88,7 @@ beforeEach(() => { describe("POST /api/auth/login — trusted device", () => { it("skips the second factor when the device is trusted (no challenge minted)", async () => { - vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true); vi.mocked(consumeTrustedDevice).mockResolvedValue(true); const res = await POST(req()); @@ -103,7 +103,7 @@ describe("POST /api/auth/login — trusted device", () => { }); it("falls back to the MFA challenge when the device is not trusted", async () => { - vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true); vi.mocked(consumeTrustedDevice).mockResolvedValue(false); const res = await POST(req()); @@ -115,7 +115,7 @@ describe("POST /api/auth/login — trusted device", () => { }); it("still requires the password — a wrong password never consults the trusted device", async () => { - vi.mocked(verifyPassword).mockResolvedValue(false); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false); const res = await POST(req()); expect(res.status).toBe(401); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index eb3272f8f..5c163c0dd 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { loginPasswordSchema } from "@/lib/validations/auth"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; import { @@ -69,11 +69,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const { email, password } = parsed.data; const identifier = email.trim(); - // HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`, - // mirrors the `/api/auth/check-user` pattern. The raw identifier - // stays out of the audit row (H-1 contract); the hash gives a - // future spray-detector a forensic anchor it can correlate across - // IPs without having to look up users by email. + // HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`. The + // raw identifier stays out of the audit row (H-1 contract); the hash + // gives a future spray-detector a forensic anchor it can correlate + // across IPs without having to look up users by email. const identifierHash = hashToken(identifier); const user = await prisma.user.findFirst({ @@ -85,6 +84,18 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + // One verification for every outcome. An unknown identifier and an + // account carrying no password hash (a passkey-only account) used to + // return here before any hashing, so the three answers cost one indexed + // SELECT, one indexed SELECT, and a full Argon2id verification + // respectively — identical bodies, three different durations, which is a + // readable answer to "is this address registered, and does it have a + // password". `verifyPasswordOrDummy` verifies against a fixed dummy hash + // minted at the same cost parameters when there is no stored hash, and + // returns false. Keep the call ahead of the branch: moving it back under + // an `if` restores the channel. + const valid = await verifyPasswordOrDummy(user?.passwordHash, password); + if (!user || !user.passwordHash) { // v1.4.43 W3-SECURITY (H-1): never write the typed identifier into // the audit row — `reason` already tells the operator what @@ -99,7 +110,6 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid credentials", 401); } - const valid = await verifyPassword(user.passwordHash, password); if (!valid) { await auditLog("auth.login.failed", { userId: user.id, diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index d3221afbc..d89662cbd 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -143,6 +143,16 @@ export const POST = apiHandler(async (request: NextRequest) => { } // Check if email or username already taken (unified message to prevent enumeration) + // + // The ordering is deliberate and load-bearing. This runs BEFORE the password + // strength check, the breach lookup and the Argon2id hash below, so a taken + // address answers after one indexed SELECT and a free one answers after + // zxcvbn plus an outbound round-trip plus tens of milliseconds of hashing. + // The status code already gives the answer away, so today the gap tells a + // caller nothing extra. It would be the whole answer the moment somebody + // unified the 409 into the success shape to close the oracle: the clock + // would then say what the status code no longer does. Anyone doing that has + // to move this block after the hash in the same change. const [existingEmail, existingUsername] = await Promise.all([ prisma.user.findUnique({ where: { email } }), prisma.user.findUnique({ where: { username } }), diff --git a/src/lib/__tests__/rate-limit-auth-surface.test.ts b/src/lib/__tests__/rate-limit-auth-surface.test.ts index 25558cae5..8331e5479 100644 --- a/src/lib/__tests__/rate-limit-auth-surface.test.ts +++ b/src/lib/__tests__/rate-limit-auth-surface.test.ts @@ -146,7 +146,6 @@ describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => { ["auth:passkey-login-options", 10], ["auth:passkey-verify", 10], ["auth:refresh", 60], - ["auth:check-user", 30], ]; const seenKeys = new Set(); for (const [prefix, limit] of surfaces) { diff --git a/src/lib/auth/__tests__/password-dummy-verify.test.ts b/src/lib/auth/__tests__/password-dummy-verify.test.ts new file mode 100644 index 000000000..482dbeb50 --- /dev/null +++ b/src/lib/auth/__tests__/password-dummy-verify.test.ts @@ -0,0 +1,159 @@ +/** + * Signing in must cost the same whether or not the account exists. + * + * The login route used to return the moment it found no account, or an + * account with no password hash, before it reached the verifier. An account + * that did have a password paid a full Argon2id verification. Same status, + * same body, very different durations — so the clock answered "is this + * address registered, and does it carry a password", which is the question + * the removed discovery endpoint used to answer out loud. + * + * What this file pins is the work, not the wall clock: that the empty arms + * reach the verifier at all, that the hash they verify against was minted at + * the same cost parameters every real hash carries, and that no outcome on + * those arms can be read as a successful verification. A unit test cannot + * prove two durations are equal — it can only prove the same call is made. + * + * `@node-rs/argon2` is mocked so the assertions are about which calls happen + * with which arguments, and so the suite does not spend real Argon2 time. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Hoisted so the mock is in place before `password.ts` is imported. +const { hashMock, verifyMock } = vi.hoisted(() => ({ + hashMock: vi.fn(async (input: string) => `hashed:${input}`), + verifyMock: vi.fn(async (_hash: string, _password: string) => false), +})); + +vi.mock("@node-rs/argon2", () => ({ hash: hashMock, verify: verifyMock })); + +import { ARGON2_HASH_OPTIONS } from "@/lib/auth/argon2-params.mjs"; + +/** + * The dummy hash is minted once per module instance, so each test gets a + * fresh instance — otherwise the first successful mint is cached and the + * mint-failure cases could never reach the mint at all. + */ +let verifyPasswordOrDummy: ( + storedHash: string | null | undefined, + password: string, +) => Promise; + +beforeEach(async () => { + verifyMock.mockReset(); + verifyMock.mockResolvedValue(false); + hashMock.mockReset(); + hashMock.mockImplementation(async (input: string) => `hashed:${input}`); + vi.resetModules(); + ({ verifyPasswordOrDummy } = await import("@/lib/auth/password")); +}); + +describe("verifyPasswordOrDummy", () => { + it("mints the dummy hash under the same Argon2id parameters real hashes use", async () => { + await verifyPasswordOrDummy(null, "anything"); + expect(hashMock).toHaveBeenCalledWith( + expect.any(String), + ARGON2_HASH_OPTIONS, + ); + }); + + it("mints once and reuses it for every later refusal", async () => { + await verifyPasswordOrDummy(null, "anything"); + await verifyPasswordOrDummy(null, "anything"); + await verifyPasswordOrDummy(undefined, "anything"); + // Three refusals, three verifications, one mint. + expect(verifyMock).toHaveBeenCalledTimes(3); + expect(hashMock).toHaveBeenCalledTimes(1); + }); + + it("verifies against the stored hash when the account has one", async () => { + verifyMock.mockResolvedValueOnce(true); + await expect( + verifyPasswordOrDummy("$argon2id$stored", "correct horse"), + ).resolves.toBe(true); + expect(verifyMock).toHaveBeenCalledTimes(1); + expect(verifyMock).toHaveBeenCalledWith( + "$argon2id$stored", + "correct horse", + ); + }); + + it("still verifies when there is no account at all (null hash)", async () => { + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); + expect(verifyMock).toHaveBeenCalledTimes(1); + // Against the dummy, not against nothing. + expect(verifyMock.mock.calls[0][0]).toMatch(/^hashed:/); + }); + + it("still verifies for a passkey-only account (undefined hash)", async () => { + // The passkey-only case is the one that matters most: if it took the + // cheap path the channel would still answer "this account has no + // password", which is half of what the removed endpoint published. + await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe( + false, + ); + expect(verifyMock).toHaveBeenCalledTimes(1); + }); + + it("never reports success from the dummy arm, whatever the verifier says", async () => { + verifyMock.mockResolvedValue(true); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); + await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe( + false, + ); + }); + + it("swallows a throw from the dummy arm rather than answering differently", async () => { + // A 500 only the unknown-account arm can produce is the same oracle in + // a different field. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + verifyMock.mockRejectedValueOnce(new Error("malformed hash")); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe( + false, + ); + expect(logged).toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); + + it("swallows a failed MINT too, not only a failed verify", async () => { + // The mint is awaited as an argument to the verifier, so an unguarded + // `await` on a rejected mint throws before the verifier is reached and + // before any `.catch` on it can apply. That answers 500 on exactly the + // arm that must look ordinary — and a passkey-only account could not + // sign in at all. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + hashMock.mockRejectedValueOnce(new Error("out of memory")); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe( + false, + ); + expect(verifyMock).not.toHaveBeenCalled(); + expect(logged).toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); + + it("re-mints after a failure instead of caching the rejection forever", async () => { + // A rejected promise stays rejected for the life of the process. Caching + // one would send every later refusal back down the cheap path with the + // timing channel reopened and nothing to notice it by. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + hashMock.mockRejectedValueOnce(new Error("out of memory")); + await verifyPasswordOrDummy(null, "anything"); + expect(verifyMock).not.toHaveBeenCalled(); + + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe( + false, + ); + expect(hashMock).toHaveBeenCalledTimes(2); + expect(verifyMock).toHaveBeenCalledTimes(1); + } finally { + logged.mockRestore(); + } + }); +}); diff --git a/src/lib/auth/__tests__/profile-update-email-limit.test.ts b/src/lib/auth/__tests__/profile-update-email-limit.test.ts new file mode 100644 index 000000000..7693c5ca3 --- /dev/null +++ b/src/lib/auth/__tests__/profile-update-email-limit.test.ts @@ -0,0 +1,240 @@ +/** + * The email-address conflict answer is metered, and metered before it answers. + * + * `applyProfileUpdate` is the one place both profile routes reach, and its + * 409 tells any signed-in caller whether a given address belongs to an + * account on this instance. Until this bucket existed neither route had a + * ceiling of any kind — `apiHandler` supplies none — so one account could + * walk an address book at whatever rate the network allowed. + * + * What is pinned here. The charge happens BEFORE the uniqueness probe, so a + * refused request never learns the answer it was refused for. A save that + * carries the address already on file is not charged at all, so a settings + * form that re-posts the whole profile does not spend an honest afternoon of + * edits on the ceiling. A refusal drops the address and keeps the rest of the + * save, so one spent budget cannot block somebody setting their timezone; the + * published 429 is for the request that asked for nothing else. And both + * answers worth detecting leave an audit row, because neither reaches the + * ordinary `profile.update` row written after the save. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkProfileEmailRateLimit: vi.fn(), +})); + +import { applyProfileUpdate } from "../profile-update"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; + +const USER_ID = "user-1"; +const ON_FILE = "mine@example.test"; + +/** The `where` clauses the helper can send, told apart by their shape. */ +function selfLookups() { + return vi + .mocked(prisma.user.findUnique) + .mock.calls.filter((c) => "id" in (c[0]!.where as object)); +} +function emailProbes() { + return vi + .mocked(prisma.user.findUnique) + .mock.calls.filter((c) => "email" in (c[0]!.where as object)); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: USER_ID, + username: "u", + email: ON_FILE, + } as never); + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: true, + limit: 10, + remaining: 9, + resetAt: Date.now() + 3_600_000, + }); + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: ON_FILE }; + return null; + }) as never); +}); + +describe("applyProfileUpdate — the email conflict is metered", () => { + it("charges the acting account before asking about the address", async () => { + await applyProfileUpdate(USER_ID, { email: "someone@example.test" }); + + expect(checkProfileEmailRateLimit).toHaveBeenCalledWith(USER_ID); + expect(emailProbes()).toHaveLength(1); + // Ordering, not just presence: the probe must not have run first. + const chargedAt = vi.mocked(checkProfileEmailRateLimit).mock + .invocationCallOrder[0]; + const probedAt = vi.mocked(prisma.user.findUnique).mock.invocationCallOrder[ + selfLookups().length + ]; + expect(chargedAt).toBeLessThan(probedAt); + }); + + it("refuses with 429 and the published code when the address was all that was asked for", async () => { + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: false, + limit: 10, + remaining: 0, + resetAt: Date.now() + 3_600_000, + }); + + const result = await applyProfileUpdate(USER_ID, { + email: "someone@example.test", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(429); + expect(result.errorCode).toBe("profile.update.emailRateLimited"); + } + // The whole point: a refused caller is told nothing about the address. + expect(emailProbes()).toHaveLength(0); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("does not charge a save that carries the address already on file", async () => { + const result = await applyProfileUpdate(USER_ID, { + // Same address, differently typed — normalisation is what decides. + email: " Mine@Example.Test ", + heightCm: 180, + }); + + expect(result.ok).toBe(true); + expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); + expect(emailProbes()).toHaveLength(0); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); + + it("does not charge an update that never touches the address", async () => { + await applyProfileUpdate(USER_ID, { heightCm: 181 }); + + expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); + }); + + it("keeps the rest of the save when the budget is spent, dropping only the address", async () => { + // The web form posts the whole profile in one request. Failing all of it + // would stop somebody setting their timezone for an hour and tell them it + // was about email addresses. + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: false, + limit: 10, + remaining: 0, + resetAt: Date.now() + 3_600_000, + }); + + const result = await applyProfileUpdate(USER_ID, { + email: "someone@example.test", + timezone: "Europe/Lisbon", + heightCm: 180, + }); + + expect(result.ok).toBe(true); + const written = vi.mocked(prisma.user.update).mock.calls[0]![0]! + .data as Record; + expect(Object.keys(written).sort()).toEqual(["heightCm", "timezone"]); + expect("email" in written).toBe(false); + if (result.ok) { + expect(result.rejectedFields).toEqual([ + expect.objectContaining({ path: "email", code: "rate_limited" }), + ]); + } + // Still no probe: the refused caller learns nothing about the address. + expect(emailProbes()).toHaveLength(0); + }); + + it("records both answers worth detecting in the audit ledger", async () => { + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: false, + limit: 10, + remaining: 0, + resetAt: Date.now() + 3_600_000, + }); + await applyProfileUpdate(USER_ID, { email: "someone@example.test" }); + expect(auditLog).toHaveBeenCalledWith( + "profile.email.rate_limited", + expect.objectContaining({ userId: USER_ID }), + ); + + vi.mocked(auditLog).mockClear(); + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: true, + limit: 10, + remaining: 9, + resetAt: Date.now() + 3_600_000, + }); + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: ON_FILE }; + return { id: "someone-else" }; + }) as never); + + await applyProfileUpdate(USER_ID, { email: "taken@example.test" }); + expect(auditLog).toHaveBeenCalledWith( + "profile.email.conflict", + expect.objectContaining({ userId: USER_ID }), + ); + // Neither path reaches the ordinary post-save row. + expect(auditLog).not.toHaveBeenCalledWith( + "profile.update", + expect.anything(), + ); + }); + + it("does not charge an account whose stored address is spelled differently", async () => { + // Registration writes the address as typed, so an account created as + // `Mine@Example.Test` has that in the column. Comparing it raw against + // the normalised submission would charge its first ordinary save. + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: "Mine@Example.Test" }; + return null; + }) as never); + + const result = await applyProfileUpdate(USER_ID, { email: ON_FILE }); + + expect(result.ok).toBe(true); + expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); + expect(emailProbes()).toHaveLength(0); + }); + + it("still answers 409 when the bucket has room and the address is taken", async () => { + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: ON_FILE }; + return { id: "someone-else" }; + }) as never); + + const result = await applyProfileUpdate(USER_ID, { + email: "taken@example.test", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(409); + expect(result.errorCode).toBe("profile.update.emailInUse"); + } + expect(checkProfileEmailRateLimit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index 89c75b2cb..eb89c0ff4 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), update: vi.fn(), }, + // The email-address change is metered before it asks whether another + // account holds the address; the limiter talks to Postgres directly. + $queryRaw: vi.fn(), }, })); @@ -34,6 +37,10 @@ const STUB_USER = { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.update).mockResolvedValue(STUB_USER as never); + // A bucket with room left, unless a test says otherwise. + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); }); describe("applyProfileUpdate timezone validation", () => { diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 570700b88..eb3f4ae8e 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -20,6 +20,90 @@ export async function verifyPassword( return verify(storedHash, password); } +/** + * A stand-in stored hash for the login arms that have nothing to verify. + * + * Sign-in used to return the moment it found no account, or an account with + * no password — one indexed SELECT — while an account that did have a + * password paid a full Argon2id verification at 19 MiB and t=2. The two + * answers were byte-identical and the two costs were not, so the clock told + * a caller which addresses are registered and which of those carry a + * password. Verifying against this hash on the empty arms makes the work + * comparable. + * + * Minted from `ARGON2_HASH_OPTIONS`, the same parameters every real hash + * carries, because Argon2 encodes its cost in the hash string and the + * verifier pays whatever the string says. A dummy minted at cheaper settings + * would leave the gap it was added to close. + * + * The input is a fixed constant rather than a random string so the module + * has no hidden state, and it is never compared to anything: the arm that + * verifies against it discards the verdict and returns false. There is no + * path on which a caller can make this hash accept. + */ +const DUMMY_VERIFY_INPUT = "healthlog: not a credential, never accepted"; + +let dummyHashPromise: Promise | null = null; + +/** + * The dummy hash, minted on first use and kept for the process afterwards. + * + * Lazy rather than minted at import: every process that imports this module + * would otherwise spend 19 MiB and a thread-pool slot at boot, including the + * queue worker and each build worker, none of which ever authenticates + * anybody. From the second refusal onward the cost profile is identical + * either way. + * + * A rejected promise stays rejected for the life of the process, so caching + * one would degrade every later refusal back to the cheap path and leave the + * timing channel open with no way to notice. A failed mint is therefore + * dropped and the next call mints again. + */ +function dummyHash(): Promise { + if (!dummyHashPromise) { + const pending = hash(DUMMY_VERIFY_INPUT, ARGON2_HASH_OPTIONS); + dummyHashPromise = pending; + pending.catch(() => { + if (dummyHashPromise === pending) dummyHashPromise = null; + }); + } + return dummyHashPromise; +} + +/** + * Verify `password` against `storedHash`, or against the dummy hash when + * there is no stored hash to verify against. + * + * Returns false whenever `storedHash` is absent — an account without a + * password, or no account at all. The dummy verification's own verdict is + * thrown away, so a false here is never a rejected credential being + * mistaken for anything else, and a true can only come from a real stored + * hash. + * + * Callers on an anonymous surface should use this rather than branching on + * the absent hash themselves: the branch is what the clock reads. + */ +export async function verifyPasswordOrDummy( + storedHash: string | null | undefined, + password: string, +): Promise { + if (storedHash) return verify(storedHash, password); + try { + // Same verifier, same cost profile, verdict discarded. + await verify(await dummyHash(), password); + } catch (error) { + // Both the mint and the verify are inside the guard, and the mint has to + // be: an unguarded `await` on a rejected mint would throw out of here and + // answer 500 on exactly the arm this function exists to make ordinary — + // an unknown identifier answering 500 while a real account answers 401 is + // the oracle again, in the status code, and cheaper to read than the + // timing gap was. The breadcrumb makes a degraded instance visible; the + // answer below is the same either way. + console.error("[auth] dummy password verification failed", error); + } + return false; +} + export interface PasswordStrength { score: number; // 0-4 feedback: string[]; diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 58c4adbf3..1891c1575 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -13,6 +13,8 @@ import { invalidateUserProfile, } from "@/lib/cache/invalidate"; import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response"; +import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -238,17 +240,95 @@ export async function applyProfileUpdate( const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; if (data.email !== undefined && normalizedEmail) { - const existing = await prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true }, + // A settings form re-posts the whole profile, so the address arrives + // with every save whether or not it changed. Those saves ask nothing + // about anyone else's account — the address on file already belongs to + // this caller — so they skip both the uniqueness probe and the budget. + // Charging them would spend an honest afternoon of edits on the + // ceiling and lock the account out of its own settings page. + // + // The stored side is normalised for the comparison too. Registration + // writes the address exactly as typed, so an account registered as + // `MixedCase@Example.com` would otherwise fail to match its own address + // and pay for its first ordinary save. + const current = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, }); - if (existing && existing.id !== userId) { - return { - ok: false, - status: 409, - message: "Email already in use", - errorCode: "profile.update.emailInUse", - }; + const addressOnFile = current?.email?.trim().toLowerCase() ?? null; + + if (addressOnFile !== normalizedEmail) { + // Charged BEFORE the probe, not after: a refused request must not + // learn the answer it was refused for. The 409 below tells any + // signed-in caller whether an address is registered here, and until + // this bucket existed neither route that reaches it had a ceiling of + // any kind — `apiHandler` supplies no default. + const rl = await checkProfileEmailRateLimit(userId); + if (!rl.allowed) { + annotate({ action: { name: "profile.email.throttled" } }); + await auditLog("profile.email.rate_limited", { + userId, + ipAddress: ipAddress ?? null, + }); + + // Narrow the refusal to the field, the way the demo narrowing above + // does: the web form posts the whole profile in one request, so + // failing all of it would stop somebody setting their timezone for + // an hour and tell them it was about email addresses. The address is + // dropped, everything else is saved, and `rejectedFields` says which + // field did not go through. + const { email: _refusedAddress, ...keepable } = data; + if (Object.keys(keepable).length > 0) { + data = keepable; + rejectedFields = [ + ...rejectedFields, + { + path: "email", + code: "rate_limited", + message: "Too many email-address changes.", + }, + ]; + } else { + // Nothing else was asked for, so there is nothing to salvage and a + // partial success would be a lie — the same rule the all-fields- + // rejected 422 above follows. This is the arm the published 429 + // describes. + return { + ok: false, + status: 429, + message: "Too many email-address changes. Try again later.", + errorCode: "profile.update.emailRateLimited", + }; + } + } else { + // Case-sensitive, because the column is: registration writes the + // address as typed, so `Mixed@Example.com` and `mixed@example.com` + // are two rows this probe cannot see as one. Pre-existing and not + // widened here — the fix is a normalisation pass on the register + // write, which is where the two spellings are created. + const existing = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + if (existing && existing.id !== userId) { + // The informative answer is the one worth recording: it says an + // account holds that address. The ordinary save's `profile.update` + // row is written further down and this path never reaches it, so + // without a row here the only probes in the ledger would be the + // ones that came back negative. + annotate({ action: { name: "profile.email.conflict" } }); + await auditLog("profile.email.conflict", { + userId, + ipAddress: ipAddress ?? null, + }); + return { + ok: false, + status: 409, + message: "Email already in use", + errorCode: "profile.update.emailInUse", + }; + } + } } } diff --git a/src/lib/http/retired-routes.ts b/src/lib/http/retired-routes.ts index 0f38cc2a0..8ed40ff6d 100644 --- a/src/lib/http/retired-routes.ts +++ b/src/lib/http/retired-routes.ts @@ -55,14 +55,17 @@ * client's repository, not in this one, and an inference drawn from this side * would be a guess wearing a citation. Nothing below is a claim about a caller. * - * How the set was found: every route path under `src/app` at every release tag, - * unioned, then differenced against the current tree. Twenty-seven paths have - * been removed across the project's history. The five here are the ones that - * were published or client-facing. The other twenty-two are admin-console - * routes, operator diagnostics, aggregators superseded in place, and the - * moodLog bridge — surfaces reachable only from the operator's own browser, - * which ships with the server and reads no contract to find out what it talks - * to. `moodlog-removal-guard.test.ts` separately holds that bridge removed. + * How the set was seeded: every route path under `src/app` at every release + * tag, unioned, then differenced against the current tree. Twenty-seven paths + * had been removed across the project's history up to that point. Five of them + * were published or client-facing and are the first five entries below; the + * other twenty-two are admin-console routes, operator diagnostics, aggregators + * superseded in place, and the moodLog bridge — surfaces reachable only from + * the operator's own browser, which ships with the server and reads no + * contract to find out what it talks to. `moodlog-removal-guard.test.ts` + * separately holds that bridge removed. Every entry after those five was + * written by the release that removed the path, which is where a tombstone + * belongs. * * Sample tags instead of all of them and this undercounts badly: six evenly * spaced tags find fourteen of the twenty-seven, because a path added and @@ -141,6 +144,14 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [ "The whole-record operation moved under the resource type it operates on, which is where FHIR R4 defines it and where the capability statement points.", methods: ["GET"], }, + { + path: "/api/auth/check-user", + removedIn: "1.38.20", + replacedBy: null, + reason: + "Took an email address or a username and answered, to any caller and with no credential at all, whether an account existed here and whether it carried a passkey or a password. It was built to decide which field a sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the account exists.", + methods: ["POST"], + }, { path: "/api/auth/me/research-mode", removedIn: "1.37.2", diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index ade5c0dec..7b1f82e55 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -250,6 +250,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = practitioner: ["practitioner.invalid"], profile: [ "profile.update.emailInUse", + "profile.update.emailRateLimited", "profile.update.invalidBody", "profile.update.nothingSaved", ], diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index be835e889..26a80fa82 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -38,6 +38,7 @@ import { } from "@/lib/validations/step-up"; import { dataEnvelope, + profileEmailRateLimitResponse, stdResponses, errorEnvelope, loginPasswordSchema, @@ -304,43 +305,6 @@ const successFlagResponse = z .object({ success: z.boolean() }) .meta({ id: "AuthSuccessFlagResponse" }); -// ── iOS onboarding discovery (check-user) ──────────────────────────── - -const checkUserRequest = z - .object({ - identifier: z - .string() - .trim() - .min(1) - .max(254) - .describe( - "The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.", - ), - }) - .meta({ - id: "CheckUserRequest", - description: - "Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step.", - }); - -const checkUserResponse = z - .object({ - branch: z - .enum(["not_found", "passkey_only", "email_fallback", "exists"]) - .describe( - "Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — recovery path).", - ), - hasPasskey: z - .boolean() - .describe("The account has at least one registered passkey."), - hasPassword: z.boolean().describe("The account has a password hash."), - }) - .meta({ - id: "CheckUserResponse", - description: - "Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.", - }); - /** * Shared tail for every route the step-up elevation can unlock. Kept in one * place so the published contract cannot describe the set inconsistently. @@ -563,7 +527,7 @@ const authProfileUpdateResponse = z ) .optional() .describe( - "Present only on a PARTIAL success: the fields that failed validation and were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save.", + "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.", ), }) .meta({ @@ -620,29 +584,6 @@ const codexDevicePollResponse = z .meta({ id: "CodexDevicePollResponse" }); export const authPaths: NonNullable = { - "/api/auth/check-user": { - post: { - tags: ["Auth"], - summary: "Resolve the next sign-in step for a typed identifier", - description: - "Given an email or username, returns which onboarding branch the iOS client should render plus the `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same whether or not the identifier matched, and the identifier is never echoed back.", - requestBody: { - required: true, - content: { "application/json": { schema: checkUserRequest } }, - }, - responses: { - "200": { - description: "Discovery result.", - content: { - "application/json": { - schema: dataEnvelope(checkUserResponse, "CheckUserEnvelope"), - }, - }, - }, - ...stdResponses, - }, - }, - }, "/api/auth/login": { post: { // No credential: this operation is reachable before one exists. @@ -1826,6 +1767,7 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + ...profileEmailRateLimitResponse, "422": { description: "Either the body was not a usable object (`meta.errorCode` = `profile.update.invalidBody`) or every supplied field was rejected (`profile.update.nothingSaved`). Both carry the per-field issue list. A body where SOME fields validate never reaches this response — it succeeds with `rejectedFields`.", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f8ee62338..96dd3d464 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -46,6 +46,7 @@ import { errorEnvelope, invalidBaseTokenResponse, malformedJsonResponse, + profileEmailRateLimitResponse, recordRefusal, stdResponses, updatedAtTokenField, @@ -482,6 +483,18 @@ const profileUpdateResponse = z hasInsuranceNumber: z .boolean() .describe("Whether a KVNR is on file (the value itself is not echoed)."), + rejectedFields: z + .array( + z.object({ + path: z.string(), + code: z.string(), + message: z.string(), + }), + ) + .optional() + .describe( + "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.", + ), }) .meta({ id: "ProfileUpdateResponse", @@ -1476,7 +1489,13 @@ export const profilePaths: NonNullable = { }, }, }, + "409": { + description: + "The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, + ...profileEmailRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index a97b71a82..b0a6149b1 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -318,6 +318,32 @@ export const recordWriteRateLimitResponse = { }, }; +/** + * The 429 the profile email-address bucket answers with. + * + * Changing the address asks whether another account already holds it, and the + * answer is a 409 versus a 200 — an existence check over every address on the + * instance, available to any signed-in caller. Neither route that reaches it + * had a ceiling of any kind, and `apiHandler` supplies no default, so the + * sweep was bounded only by the network. This is the response that names the + * bucket now capping it. + * + * Spread AFTER `...stdResponses` so it replaces the generic 429 on those two + * operations. The headers block is the one the standard 429 already declares. + * + * `profile-email-rate-limit-contract.test.ts` holds the numbers in this + * sentence to the constants in `src/lib/rate-limit.ts`, so the paragraph + * cannot drift away from the bucket it describes. + */ +export const profileEmailRateLimitResponse = { + "429": { + description: + "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. A request that carries other fields beside the refused address does NOT get this response — it answers 200, saves those fields, and names `email` in `rejectedFields` with code `rate_limited`, so one spent budget cannot block an unrelated profile edit for an hour. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing.", + content: { "application/json": { schema: errorEnvelope } }, + headers: stdResponses["429"].headers, + }, +}; + // ── Idempotent writes ──────────────────────────────────────────────── /** diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts index da0a3e604..87225925e 100644 --- a/src/lib/profile/rejected-fields.ts +++ b/src/lib/profile/rejected-fields.ts @@ -73,6 +73,11 @@ export function describeRejectedProfileField( * degrade to something honest instead of to silence. */ const REJECTION_REASON_KEYS: Record = { + // Not a Zod code: the profile helper refuses an email-address change that + // has spent the account's hourly budget this way, so the rest of the save + // still lands. Without an entry it would fall back to "check it and try + // again", which is wrong — the value was fine, the timing was not. + rate_limited: "settings.profileRejection.rateLimited", too_big: "settings.profileRejection.tooBig", too_small: "settings.profileRejection.tooSmall", invalid_value: "settings.profileRejection.invalidValue", diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d6cc7b624..5a8816b4b 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -160,6 +160,46 @@ export async function checkRecordWriteRateLimit( ); } +/** + * Per-account ceiling for the profile email-address change. + * + * Changing the address means asking whether another account already holds + * it, and the answer to that question is 409 versus 200 — an existence check + * over every address on the instance, available to any signed-in caller. The + * two routes that reach it (`PUT /api/auth/profile`, `PATCH + * /api/user/profile`) had no ceiling at all and `apiHandler` supplies none, + * so a single account could walk an address book at whatever rate it liked. + * + * Keyed on the acting account rather than on the address, the frozen + * precedent from the record writes: an attacker cannot collect a fresh + * allowance by switching the address they probe, and one account's sweep + * cannot lock a stranger out of their own settings page. + * + * Ten an hour is far above honest use — an address is changed once in a + * while, not ten times a day — and it is only charged when the submitted + * address actually differs from the one on file, so re-saving a settings + * form that carries the unchanged address never spends the budget. What it + * ends is the unbounded sweep: two hundred and forty questions a day per + * account instead of as many as the network will carry. Both answers worth + * detecting write an audit row of their own — `profile.email.conflict` when + * an address turns out to be taken, `profile.email.rate_limited` when the + * budget runs out. The ordinary `profile.update` row is written after the + * save, which neither of those paths reaches. + */ +export const PROFILE_EMAIL_BUCKET_PREFIX = "profile-email"; +export const PROFILE_EMAIL_LIMIT = 10; +export const PROFILE_EMAIL_WINDOW_MS = 60 * 60 * 1000; + +export async function checkProfileEmailRateLimit( + accountId: string, +): Promise { + return checkRateLimit( + `${PROFILE_EMAIL_BUCKET_PREFIX}:${accountId}`, + PROFILE_EMAIL_LIMIT, + PROFILE_EMAIL_WINDOW_MS, + ); +} + /** * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). * The bucket is keyed by the `:` binding the Bearer @@ -240,7 +280,7 @@ const TIGHT_ANON_WINDOW_MS = 15 * 60 * 1000; /** * Rate-limit helper for anonymous auth surfaces (login, register, - * passkey-login-{options,verify}, refresh, check-user). + * passkey-login-{options,verify}, refresh). * * Per-IP path: when the trust chain resolves cleanly, route the request * to a bucket keyed by `{prefix}:{ip ?? "unknown"}` with the caller's