mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Cap the profile email-address conflict answer
Changing the profile address asks whether another account already holds it, and the answer is 409 versus 200 — an existence check over every address on the instance, open to any signed-in caller. Neither route that reaches it had a ceiling of any kind and the API wrapper supplies no default, so the only bound on a sweep was the network. Both routes now share one per-account bucket, ten an hour, charged before the question is asked so a refused caller learns nothing. Only a request whose address actually differs from the one on file is counted, so re-saving a settings form that carries the unchanged address stays free. The refusal is the standard 429 with Retry-After and the X-RateLimit triple, carries `profile.update.emailRateLimited`, and is published on both operations.
This commit is contained in:
1 parent
6a5fb338b1
commit
fe420f76d8
17 files changed
+596
-181
No files matched your search
@@ -250,6 +250,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
practitioner: ["practitioner.invalid"],
|
||||
profile: [
|
||||
"profile.update.emailInUse",
|
||||
"profile.update.emailRateLimited",
|
||||
"profile.update.invalidBody",
|
||||
"profile.update.nothingSaved",
|
||||
],
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "@/lib/validations/step-up";
|
||||
import {
|
||||
dataEnvelope,
|
||||
profileEmailRateLimitResponse,
|
||||
stdResponses,
|
||||
errorEnvelope,
|
||||
loginPasswordSchema,
|
||||
@@ -1766,6 +1767,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
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`.",
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
errorEnvelope,
|
||||
invalidBaseTokenResponse,
|
||||
malformedJsonResponse,
|
||||
profileEmailRateLimitResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
updatedAtTokenField,
|
||||
@@ -1476,7 +1477,13 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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. The address change shares one per-account bucket across both profile routes — `profile-email:<accountId>`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. Nothing was written. `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. 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 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user