From ab6c1cd917917a60599ad7cb41184b2e047bafa5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 10 Sep 2026 07:42:07 +0200 Subject: [PATCH] fix(api): put a ceiling under the single-record writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The batch endpoints have been capped at 60 calls a minute since they were written. The per-record siblings a naive or older client loops over — the measurement create, the mood entry, both medication-intake writes, a lab result, a custom-metric entry, a cycle day-log, a vaccination, an allergy, a biomarker and an encounter — had no ceiling at all. That is backwards from the risk: the batch endpoint is the one a well-behaved client uses, and the unlimited path is the one a broken loop finds. All eleven share one generous per-account bucket, `record-write`, at 300 requests a minute. Five writes a second sustained for a full minute is beyond any hand-driven session and any sync burst, and every one of these routes is already wrapped in `withIdempotency`, so an honest retry storm is deduplicated before it is counted — the bucket exists to cut off the pathological loop, not to shape traffic. It keys on the ACTING account, the precedent the delegable medication routes set, so a delegate burns their own allowance rather than locking the record's owner out of their own log. The eleven operations publish the bucket name and the cap instead of the generic refusal, on the shared 429 that already declares `Retry-After` and the rate-limit triple. A test pulls the key and the window out of the query the limiter runs and holds the published sentence to them, so the paragraph cannot drift away from the bucket it describes. --- docs/api/openapi.yaml | 713 +++++++++--------- .../record-write-rate-limit-contract.test.ts | 144 ++++ src/app/api/allergies/route.ts | 12 +- .../__tests__/error-contract.test.ts | 11 + src/app/api/biomarkers/route.ts | 11 +- .../[id]/entries/__tests__/route.test.ts | 11 + .../api/custom-metrics/[id]/entries/route.ts | 9 + .../cycle/__tests__/external-id-floor.test.ts | 8 + src/app/api/cycle/day-logs/route.ts | 11 +- src/app/api/encounters/route.ts | 11 +- .../api/labs/__tests__/error-contract.test.ts | 11 + src/app/api/labs/route.ts | 11 +- .../__tests__/arrival-wiring.test.ts | 11 + .../__tests__/zod-multi-issue.test.ts | 11 + src/app/api/measurements/route.ts | 9 + .../[id]/intake/__tests__/route.test.ts | 11 + .../__tests__/source-provenance.test.ts | 11 + src/app/api/medications/[id]/intake/route.ts | 9 + .../intake/__tests__/route.test.ts | 11 + src/app/api/medications/intake/route.ts | 9 + .../api/mood-entries/__tests__/route.test.ts | 11 + src/app/api/mood-entries/route.ts | 9 + src/app/api/vaccinations/route.ts | 11 +- src/lib/openapi/routes/allergies.ts | 2 + src/lib/openapi/routes/biomarkers.ts | 2 + src/lib/openapi/routes/custom-metrics.ts | 2 + src/lib/openapi/routes/cycle.ts | 2 + src/lib/openapi/routes/encounters.ts | 2 + src/lib/openapi/routes/labs.ts | 2 + src/lib/openapi/routes/measurements.ts | 2 + src/lib/openapi/routes/medications/paths.ts | 4 +- src/lib/openapi/routes/mood.ts | 2 + src/lib/openapi/routes/shared.ts | 26 + src/lib/openapi/routes/vaccinations.ts | 2 + src/lib/rate-limit.ts | 38 + 35 files changed, 804 insertions(+), 358 deletions(-) create mode 100644 src/__tests__/record-write-rate-limit-contract.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3a2f547d0..3cb879a6b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -117,7 +117,7 @@ paths: `X-RateLimit-Reset` (the reset instant, ISO-8601, not epoch seconds). Back off on `Retry-After` rather than guessing. A 429 raised by a ceiling that is not one of these buckets — a daily AI budget, an upstream provider's own refusal relayed onward — carries none of them, because there is no bucket to describe. - headers: + headers: &a8 Retry-After: description: Whole seconds to wait before retrying. Rounded up, never below 1. schema: @@ -391,7 +391,18 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": *a5 + "429": &a10 + description: Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — + `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns + their own allowance rather than the record owner's. Nothing was written. 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. A client with more than a handful of rows to send should use the + batch endpoint for its domain instead of looping this one. + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/cycle/day-logs/{id}: patch: tags: @@ -675,7 +686,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a10 + "400": &a12 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -855,7 +866,7 @@ paths: type: string format: binary "401": *a1 - "403": &a8 + "403": &a9 description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the @@ -3457,7 +3468,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series: @@ -3509,7 +3520,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements: @@ -3706,7 +3717,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -3745,7 +3756,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. @@ -3761,7 +3772,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/measurements/{id}: get: tags: @@ -3782,7 +3793,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3830,7 +3841,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3866,7 +3877,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3968,7 +3979,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -3997,7 +4008,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -4028,7 +4039,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a9 + "403": &a11 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 @@ -4069,7 +4080,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/personal-records: @@ -4116,7 +4127,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -4171,7 +4182,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4207,7 +4218,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a13 + "403": &a15 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.' @@ -4376,7 +4387,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -4501,7 +4512,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/medications/{id}/glp1: get: tags: @@ -4526,7 +4537,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4610,7 +4621,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4745,7 +4756,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4861,7 +4872,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4900,7 +4911,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4945,7 +4956,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -4993,7 +5004,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -5041,7 +5052,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No event with that id under this medication and record. content: @@ -5084,7 +5095,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5126,7 +5137,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5171,7 +5182,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5251,7 +5262,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -5282,7 +5293,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -5302,7 +5313,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -5336,7 +5347,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a11 + "422": &a13 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -5381,7 +5392,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5418,7 +5429,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5491,7 +5502,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5500,7 +5511,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 get: tags: - Medications @@ -5565,7 +5576,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5598,7 +5609,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5634,7 +5645,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5678,7 +5689,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5713,7 +5724,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -5791,7 +5802,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5819,7 +5830,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -5994,7 +6005,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6032,7 +6043,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6076,7 +6087,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication or revision not found (or owned by another user). content: @@ -6117,7 +6128,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication or revision not found (or owned by another user). content: @@ -6180,7 +6191,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A dose-history import is already in progress. content: @@ -6242,7 +6253,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such job for this medication (or owned by another user). content: @@ -6276,7 +6287,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: No such job for this account (or owned by another user). content: @@ -6306,7 +6317,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/ai/test: @@ -6496,7 +6507,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a10 + "400": *a12 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -6506,7 +6517,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/auth/me/disable-coach: get: @@ -6544,7 +6555,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a10 + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -6699,7 +6710,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a10 + "400": *a12 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -6709,7 +6720,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/auth/me/source-priority: get: @@ -7540,7 +7551,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a10 + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -7727,7 +7738,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a10 + "400": *a12 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7737,7 +7748,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/auth/me/report-selection: get: @@ -7778,7 +7789,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a10 + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -8753,7 +8764,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 delete: tags: @@ -8842,7 +8853,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -8957,7 +8968,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/anamnesis/facts: get: @@ -8975,7 +8986,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -9085,7 +9096,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a12 + "404": &a14 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9121,7 +9132,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a12 + "404": *a14 "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. @@ -9853,7 +9864,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -9911,7 +9922,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10222,7 +10233,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10245,7 +10256,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10291,7 +10302,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10328,7 +10339,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -10363,7 +10374,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -10437,7 +10448,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/analytics/range: @@ -10507,7 +10518,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -10536,7 +10547,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -10565,7 +10576,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -10594,7 +10605,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -10623,7 +10634,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -10652,7 +10663,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -10744,7 +10755,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -10786,7 +10797,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived: @@ -10895,7 +10906,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -10934,7 +10945,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -10954,7 +10965,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg: @@ -10977,7 +10988,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 post: @@ -11015,7 +11026,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11055,7 +11066,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -11087,7 +11098,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11108,7 +11119,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11126,7 +11137,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11156,7 +11167,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "404": description: No current pattern with that id for this account. content: @@ -11181,7 +11192,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11200,7 +11211,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11219,7 +11230,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11242,7 +11253,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/mood/insights: @@ -11264,7 +11275,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11287,7 +11298,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -11397,7 +11408,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a14 + "404": &a16 description: Not found / not owned by the caller. content: application/json: @@ -11432,7 +11443,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11468,7 +11479,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11521,7 +11532,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 delete: @@ -11552,7 +11563,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a14 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11600,7 +11611,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 /api/mood-entries: get: @@ -11683,7 +11694,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -11716,8 +11727,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "409": &a15 + "403": *a9 + "409": &a17 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. @@ -11726,7 +11737,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/mood-entries/{id}: get: tags: @@ -11748,8 +11759,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a14 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 put: @@ -11783,9 +11794,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a14 - "409": *a15 + "403": *a9 + "404": *a16 + "409": *a17 "422": *a4 "429": *a5 delete: @@ -11810,8 +11821,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a14 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11840,7 +11851,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11869,7 +11880,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11913,7 +11924,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -11961,7 +11972,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a16 + "400": &a18 description: Body is not parseable JSON. content: application/json: @@ -11974,13 +11985,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a17 + "413": &a19 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a18 + "415": &a20 description: Content-Type is not `application/json`. content: application/json: @@ -12038,7 +12049,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a16 + "400": *a18 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12053,8 +12064,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a17 - "415": *a18 + "413": *a19 + "415": *a20 "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 @@ -12108,10 +12119,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a16 + "400": *a18 "401": *a1 - "413": *a17 - "415": *a18 + "413": *a19 + "415": *a20 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. content: @@ -12135,7 +12146,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/settings/global-services: @@ -12575,7 +12586,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a19 + "400": &a21 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: @@ -12624,7 +12635,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a19 + "400": *a21 "401": *a1 "422": *a4 "429": *a5 @@ -12860,7 +12871,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -12885,7 +12896,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -12909,8 +12920,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a8 - "404": &a20 + "403": *a9 + "404": &a22 description: Measurement reminder not found / not owned. content: application/json: @@ -12944,8 +12955,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 delete: @@ -12967,8 +12978,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -12992,8 +13003,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13020,8 +13031,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13050,8 +13061,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13084,8 +13095,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13125,8 +13136,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/labs: @@ -13198,7 +13209,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13225,10 +13236,10 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/labs/{id}: get: tags: @@ -13249,8 +13260,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a8 - "404": &a21 + "403": *a9 + "404": &a23 description: Lab result not found (or owned by another user). content: application/json: @@ -13284,8 +13295,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a8 - "404": *a21 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 delete: @@ -13307,8 +13318,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a8 - "404": *a21 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 /api/labs/restore: @@ -13334,7 +13345,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13438,7 +13449,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13463,15 +13474,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a8 - "409": &a23 + "403": *a9 + "409": &a25 description: A biomarker with this name already exists for the caller. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/biomarkers/{id}: get: tags: @@ -13492,8 +13503,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a8 - "404": &a22 + "403": *a9 + "404": &a24 description: Biomarker not found (or owned by another user). content: application/json: @@ -13527,9 +13538,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a8 - "404": *a22 - "409": *a23 + "403": *a9 + "404": *a24 + "409": *a25 "422": *a4 "429": *a5 delete: @@ -13552,7 +13563,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a22 + "404": *a24 "422": *a4 "429": *a5 /api/encounters: @@ -13610,7 +13621,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13636,10 +13647,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/encounters/suggest: get: tags: @@ -13667,7 +13678,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -13690,8 +13701,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a8 - "404": &a24 + "403": *a9 + "404": &a26 description: Visit not found (or owned by another user). content: application/json: @@ -13724,8 +13735,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13749,8 +13760,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13775,8 +13786,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13806,7 +13817,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a24 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13835,7 +13846,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a24 + "404": *a26 "422": *a4 "429": *a5 /api/practitioners: @@ -13865,7 +13876,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13889,7 +13900,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13913,8 +13924,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a8 - "404": &a25 + "403": *a9 + "404": &a27 description: Practitioner not found (or owned by another user). content: application/json: @@ -13947,8 +13958,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13971,8 +13982,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -13997,8 +14008,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/vaccinations: @@ -14041,7 +14052,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14068,10 +14079,10 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/vaccinations/{id}: get: tags: @@ -14092,8 +14103,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": &a26 + "403": *a9 + "404": &a28 description: Vaccination not found (or owned by another user). content: application/json: @@ -14127,8 +14138,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14152,8 +14163,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14177,8 +14188,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14218,8 +14229,8 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14247,7 +14258,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -14277,7 +14288,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a26 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14306,7 +14317,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a26 + "404": *a28 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14339,7 +14350,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14365,7 +14376,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14389,8 +14400,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": &a27 + "403": *a9 + "404": &a29 description: Illness episode not found (or owned by another user). content: application/json: @@ -14424,8 +14435,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14448,8 +14459,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14476,8 +14487,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "409": *a7 "422": *a4 "429": *a5 @@ -14509,8 +14520,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14560,8 +14571,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 post: @@ -14597,8 +14608,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14625,7 +14636,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a27 + "404": *a29 "422": *a4 "429": *a5 /api/illness/insights: @@ -14698,7 +14709,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14724,7 +14735,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14751,7 +14762,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/allergies: @@ -14784,7 +14795,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14809,10 +14820,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/allergies/{id}: get: tags: @@ -14833,8 +14844,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a8 - "404": &a28 + "403": *a9 + "404": &a30 description: Allergy not found (or owned by another user). content: application/json: @@ -14867,8 +14878,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14891,8 +14902,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 /api/family-history: @@ -14916,7 +14927,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14941,7 +14952,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14965,8 +14976,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": &a29 + "403": *a9 + "404": &a31 description: Family history entry not found (or owned by another user). content: application/json: @@ -14999,8 +15010,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15023,8 +15034,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a29 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15137,7 +15148,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15272,7 +15283,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -15319,7 +15330,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15438,7 +15449,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -15467,7 +15478,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -15894,7 +15905,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15919,7 +15930,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a31 + "409": &a33 description: A custom metric with this name already exists for the caller. content: application/json: @@ -15947,8 +15958,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a8 - "404": &a30 + "403": *a9 + "404": &a32 description: Custom metric not found (or owned by another user). content: application/json: @@ -15982,8 +15993,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a30 - "409": *a31 + "404": *a32 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -16006,7 +16017,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a30 + "404": *a32 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16049,8 +16060,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a8 - "404": *a30 + "403": *a9 + "404": *a32 "422": *a4 "429": *a5 post: @@ -16079,10 +16090,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a30 + "404": *a32 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -16115,7 +16126,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a32 + "404": &a34 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16148,7 +16159,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a32 + "404": *a34 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16486,7 +16497,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a33 + "403": &a35 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 @@ -16541,7 +16552,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a33 + "403": *a35 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16570,7 +16581,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a33 + "403": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -17072,26 +17083,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a34 + "400": &a36 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a35 + "413": &a37 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a36 + "415": &a38 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a37 + "422": &a39 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: @@ -17277,11 +17288,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17424,11 +17435,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17581,11 +17592,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17661,21 +17672,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a38 + "422": &a40 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": &a39 + "429": &a41 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": &a40 + "502": &a42 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` @@ -17720,11 +17731,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17800,9 +17811,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/strava/credentials: get: tags: @@ -17838,11 +17849,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a34 + "400": *a36 "401": *a1 - "413": *a35 - "415": *a36 - "422": *a37 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17920,9 +17931,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/status: get: tags: @@ -17958,7 +17969,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/fitbit/test: post: tags: @@ -17976,9 +17987,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/google-health/resume: post: tags: @@ -17995,7 +18006,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/google-health/test: post: tags: @@ -18028,9 +18039,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/whoop/resume: post: tags: @@ -18047,7 +18058,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/whoop/test: post: tags: @@ -18064,9 +18075,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 - "502": *a40 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/withings/resume: post: tags: @@ -18084,7 +18095,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a39 + "429": *a41 /api/integrations/withings/test: post: tags: @@ -18102,8 +18113,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a38 - "429": *a39 + "422": *a40 + "429": *a41 "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: @@ -18131,7 +18142,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -18167,7 +18178,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18224,7 +18235,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -18243,7 +18254,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -18277,7 +18288,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a11 + "422": *a13 "429": *a5 delete: tags: @@ -18340,7 +18351,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a41 + "403": &a43 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 @@ -18365,7 +18376,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "422": *a4 "429": *a5 post: @@ -18460,7 +18471,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "404": description: No such invitation for this account. content: @@ -18504,7 +18515,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "404": description: No such grant on this account's record. content: @@ -18545,7 +18556,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "404": description: No such grant held by this account. content: @@ -18927,7 +18938,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a41 + "403": *a43 "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 — @@ -19257,7 +19268,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a42 + "403": &a44 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. @@ -19290,7 +19301,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "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. @@ -19317,7 +19328,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "422": *a4 "429": *a5 post: @@ -19347,7 +19358,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "413": description: Body exceeds 16 KiB. content: @@ -19390,7 +19401,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a42 + "403": *a44 "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. @@ -19417,7 +19428,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -19437,7 +19448,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -19448,7 +19459,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: - - &a43 + - &a45 name: family in: path required: true @@ -19472,7 +19483,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -19492,7 +19503,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: - - *a43 + - *a45 requestBody: required: true content: @@ -19513,7 +19524,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -19626,7 +19637,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/environment: @@ -19644,7 +19655,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": *a4 "429": *a5 /api/environment/home: @@ -19681,7 +19692,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19735,7 +19746,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19794,7 +19805,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -19833,7 +19844,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19886,7 +19897,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a13 + "403": *a15 "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. @@ -20027,13 +20038,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a44 + "403": &a46 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": &a45 + "404": &a47 description: No stored copy with this id. content: application/json: @@ -20092,8 +20103,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a44 - "404": *a45 + "403": *a46 + "404": *a47 "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. diff --git a/src/__tests__/record-write-rate-limit-contract.test.ts b/src/__tests__/record-write-rate-limit-contract.test.ts new file mode 100644 index 000000000..e9dec3cfa --- /dev/null +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -0,0 +1,144 @@ +/** + * The single-record write ceiling is the one the contract describes. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written; the per-record creates a naive or older client loops over were not + * capped at all, which is backwards — the batch endpoint is the one a + * well-behaved client uses. Eleven of them share one generous per-account + * bucket now, and the operations publish its name and its cap so a client that + * meets the ceiling can read which one it met. + * + * A published number is only worth reading if it cannot drift from the code, so + * this pulls the bucket key and the limit out of `prisma.$queryRaw` — the same + * technique `rate-limit-auth-surface.test.ts` uses — and holds the emitted + * OpenAPI description to what came back. Reading both from the same constant + * would prove nothing: the sentence is written by hand, deliberately, and this + * is what keeps the hand-written half honest. + * + * The route half is structural: every route in the list below calls the shared + * helper. Its limit is that it reads the file rather than the running handler, + * so a call placed after an early return would still count as present. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + RECORD_WRITE_BUCKET_PREFIX, + RECORD_WRITE_LIMIT, + RECORD_WRITE_WINDOW_MS, + checkRecordWriteRateLimit, +} from "@/lib/rate-limit"; +import { buildOpenApiDocument } from "@/lib/openapi/registry"; +import { recordWriteRateLimitResponse } from "@/lib/openapi/routes/shared"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); + +import { prisma } from "@/lib/db"; + +/** + * The single-record writes on the shared bucket, paired with the operation + * that publishes it. Hand-listed on purpose: the point of the list is that + * adding a create without the limiter is a decision somebody has to make, and + * a list derived from the routes that already call it could not notice one. + */ +const ON_THE_SHARED_BUCKET: Array<[route: string, openApiPath: string]> = [ + ["src/app/api/measurements/route.ts", "/api/measurements"], + ["src/app/api/mood-entries/route.ts", "/api/mood-entries"], + ["src/app/api/medications/intake/route.ts", "/api/medications/intake"], + [ + "src/app/api/medications/[id]/intake/route.ts", + "/api/medications/{id}/intake", + ], + ["src/app/api/labs/route.ts", "/api/labs"], + [ + "src/app/api/custom-metrics/[id]/entries/route.ts", + "/api/custom-metrics/{id}/entries", + ], + ["src/app/api/cycle/day-logs/route.ts", "/api/cycle/day-logs"], + ["src/app/api/vaccinations/route.ts", "/api/vaccinations"], + ["src/app/api/allergies/route.ts", "/api/allergies"], + ["src/app/api/biomarkers/route.ts", "/api/biomarkers"], + ["src/app/api/encounters/route.ts", "/api/encounters"], +]; + +/** The published sentence, read from the object the route modules spread. */ +const published = recordWriteRateLimitResponse["429"].description; + +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 shared single-record write bucket", () => { + it("keys on the account under the name the contract publishes", async () => { + await checkRecordWriteRateLimit("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. + expect(call[1]).toBe("record-write:account-7"); + expect(call[2]).toBe(`${RECORD_WRITE_WINDOW_MS} milliseconds`); + expect(published).toContain("`record-write:`"); + expect(`${RECORD_WRITE_BUCKET_PREFIX}:account-7`).toBe(call[1]); + }); + + it("refuses at the cap the contract publishes", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: RECORD_WRITE_LIMIT, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject( + { allowed: true, limit: RECORD_WRITE_LIMIT, remaining: 0 }, + ); + + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { + count: RECORD_WRITE_LIMIT + 1, + reset_at: new Date(Date.now() + 60_000), + }, + ] as never); + await expect(checkRecordWriteRateLimit("account-7")).resolves.toMatchObject( + { allowed: false }, + ); + + const windowSeconds = RECORD_WRITE_WINDOW_MS / 1000; + expect(published).toContain( + `${RECORD_WRITE_LIMIT} requests per ${windowSeconds} seconds`, + ); + }); + + it("says the headers describe that bucket, and declares them", () => { + expect(Object.keys(recordWriteRateLimitResponse["429"].headers)).toEqual([ + "Retry-After", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ]); + expect(published).toContain("Retry-After"); + }); + + it("is called by every route that publishes it", () => { + const missing = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes("checkRecordWriteRateLimit("); + }).map(([route]) => route); + expect(missing).toEqual([]); + }); + + it("is published on every operation that answers it", () => { + const doc = buildOpenApiDocument() as { + paths: Record< + string, + { post?: { responses?: Record } } + >; + }; + const unpublished = ON_THE_SHARED_BUCKET.filter( + ([, path]) => + doc.paths[path]?.post?.responses?.["429"]?.description !== published, + ).map(([, path]) => path); + expect(unpublished).toEqual([]); + }); +}); diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index f4e69e40c..e7b544667 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -15,11 +15,13 @@ import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { + apiError, apiSuccess, getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -84,7 +86,15 @@ async function postAllergy(request: NextRequest): Promise { // A READ or WRITE delegate reaches neither: the destination is not on their // Settings list, and this line refuses them with `sharing.access.denied` // regardless. - const { user } = await requireRecordAuth("manage", "profile"); + const { user, actor } = await requireRecordAuth("manage", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/biomarkers/__tests__/error-contract.test.ts b/src/app/api/biomarkers/__tests__/error-contract.test.ts index 044cf9d62..dcbfe3362 100644 --- a/src/app/api/biomarkers/__tests__/error-contract.test.ts +++ b/src/app/api/biomarkers/__tests__/error-contract.test.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { biomarker: { findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn() }, diff --git a/src/app/api/biomarkers/route.ts b/src/app/api/biomarkers/route.ts index b0d502964..7a48c1b06 100644 --- a/src/app/api/biomarkers/route.ts +++ b/src/app/api/biomarkers/route.ts @@ -9,6 +9,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { withIdempotency } from "@/lib/idempotency"; @@ -95,7 +96,15 @@ async function postBiomarker(request: NextRequest) { // duplicate pre-check and the `@@unique([userId, name])` backstop both scope // to the same resolved id, so a delegate adding a marker the owner already // tracks gets the ordinary 409 rather than a second copy. - const { user } = await requireRecordAuth("write", "labs"); + const { user, actor } = await requireRecordAuth("write", "labs"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts index c13967317..2f02eb2b3 100644 --- a/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts +++ b/src/app/api/custom-metrics/[id]/entries/__tests__/route.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { customMetric: { findFirst: vi.fn() }, diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index 5fdd9ff4b..0750ce9d3 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -9,6 +9,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { invalidateUserCorrelationPatterns } from "@/lib/cache/invalidate"; import { serialiseCustomMetricEntry } from "@/lib/custom-metrics/custom-metric-store"; @@ -105,6 +106,14 @@ async function postCustomMetricEntry( // tracked value adds it back in the same diff: the argument for admitting it // was never wrong, and the GET arm above stays delegable meanwhile. const { user } = await requireAuth(); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(user.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { id } = await params; const metric = await prisma.customMetric.findFirst({ diff --git a/src/app/api/cycle/__tests__/external-id-floor.test.ts b/src/app/api/cycle/__tests__/external-id-floor.test.ts index 3b28effc5..5e1428830 100644 --- a/src/app/api/cycle/__tests__/external-id-floor.test.ts +++ b/src/app/api/cycle/__tests__/external-id-floor.test.ts @@ -34,6 +34,14 @@ vi.mock("@/lib/auth/audit", () => ({ vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn(), rateLimitHeaders: () => ({}), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), })); vi.mock("@/lib/idempotency", () => ({ withIdempotency: diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 28e91b0c4..5a0696bc6 100644 --- a/src/app/api/cycle/day-logs/route.ts +++ b/src/app/api/cycle/day-logs/route.ts @@ -26,6 +26,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { @@ -94,11 +95,19 @@ async function postDayLog(request: NextRequest): Promise { // v1.37.0 — MANAGE. The upsert replaces the day, which is what edit means // here; the gate below resolves `user.gender` against the RECORD, so the // module follows the record rather than the caller. - const { user } = await requireRecordAuth("manage", "cycle"); + const { user, actor } = await requireRecordAuth("manage", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 0fc4a5fa7..80926a0c2 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -25,6 +25,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -154,7 +155,15 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postEncounter)); async function postEncounter(request: NextRequest): Promise { - const { user, grantId } = await requireRecordAuth("write", "profile"); + const { user, actor, grantId } = await requireRecordAuth("write", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/labs/__tests__/error-contract.test.ts b/src/app/api/labs/__tests__/error-contract.test.ts index 302325c71..fb2d8c4a6 100644 --- a/src/app/api/labs/__tests__/error-contract.test.ts +++ b/src/app/api/labs/__tests__/error-contract.test.ts @@ -1,6 +1,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { labResult: { diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 97fc344ce..81cbf8a80 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -10,6 +10,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { invalidateUserHealthScore } from "@/lib/cache/invalidate"; @@ -148,7 +149,15 @@ async function postLabResult(request: NextRequest) { // it mints it under `user.id`: the marker joins the record's catalogue, not // the caller's, which is the only reading of "add a result to your record" // that leaves the owner with a usable catalogue afterwards. - const { user } = await requireRecordAuth("write", "labs"); + const { user, actor } = await requireRecordAuth("write", "labs"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/measurements/__tests__/arrival-wiring.test.ts b/src/app/api/measurements/__tests__/arrival-wiring.test.ts index c8f39732e..ee77a756a 100644 --- a/src/app/api/measurements/__tests__/arrival-wiring.test.ts +++ b/src/app/api/measurements/__tests__/arrival-wiring.test.ts @@ -12,6 +12,17 @@ const SELF = { timezone: "Europe/Berlin", }; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/api-handler", () => ({ apiHandler: (fn: unknown) => fn, requireRecordAuth: vi.fn(async () => ({ user: SELF, actor: SELF })), diff --git a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts index 10cca436b..34c4b295e 100644 --- a/src/app/api/measurements/__tests__/zod-multi-issue.test.ts +++ b/src/app/api/measurements/__tests__/zod-multi-issue.test.ts @@ -9,6 +9,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { measurement: { diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6dcfe5456..385f6a640 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -16,6 +16,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMeasurementSchema, createBatchMeasurementSchema, @@ -801,6 +802,14 @@ async function postMeasurement(request: NextRequest) { const { user } = auth; const scoped = isScopedCredential(auth); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(auth.actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 60569dcb6..5e068be58 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -22,6 +22,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { diff --git a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts index 2d1b52967..b7a9a4519 100644 --- a/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/source-provenance.test.ts @@ -18,6 +18,17 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medication: { diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 635ee69f1..b5c3baebb 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -10,6 +10,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { intakeSchema, listIntakeEventsSchema, @@ -58,6 +59,14 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { "medications", ); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + // v1.36.1 follow-up — the sibling route `POST /api/medications/intake` // refuses a delegate changing a dose the owner already recorded. This route // reaches the same rows through the slot upsert, so without the same rule it diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts index 1b63f0ed8..cc1bf78d9 100644 --- a/src/app/api/medications/intake/__tests__/route.test.ts +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -1,6 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { NextRequest } from "next/server"; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { medicationIntakeEvent: { diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index fd0b5c2ca..e6c6e759d 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -21,6 +21,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; @@ -203,6 +204,14 @@ export const POST = apiHandler(async (request: NextRequest) => { // notification at the end of this handler. const { user, actor } = await requireRecordAuth("write", "medications"); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: body, error } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 383671464..3af7aca11 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -31,6 +31,17 @@ const txClient = { }, }; +vi.mock("@/lib/rate-limit", async (importOriginal) => ({ + ...(await importOriginal()), + // The shared single-record write ceiling. Allowed here: this file is about + // what the handler does with a body it accepted, not about the bucket. + checkRecordWriteRateLimit: vi.fn(async () => ({ + allowed: true, + limit: 300, + remaining: 299, + resetAt: Date.now() + 60_000, + })), +})); vi.mock("@/lib/db", () => ({ prisma: { moodEntry: { diff --git a/src/app/api/mood-entries/route.ts b/src/app/api/mood-entries/route.ts index c4faecd3b..279ba0256 100644 --- a/src/app/api/mood-entries/route.ts +++ b/src/app/api/mood-entries/route.ts @@ -8,6 +8,7 @@ import { safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { createMoodEntrySchema, listMoodEntriesSchema, @@ -105,6 +106,14 @@ async function postMoodEntry(request: NextRequest) { // the `externalId` refusal below. const { user, actor } = await requireRecordAuth("manage", "mind"); + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 16d2be8b7..62a60b12c 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -29,6 +29,7 @@ import { returnAllZodIssues, safeJson, } from "@/lib/api-response"; +import { checkRecordWriteRateLimit } from "@/lib/rate-limit"; import { withIdempotency } from "@/lib/idempotency"; import { encryptToBytes } from "@/lib/ai/coach/bytes-codec"; import { @@ -112,7 +113,15 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postVaccination)); async function postVaccination(request: NextRequest): Promise { - const { user } = await requireRecordAuth("write", "profile"); + const { user, actor } = await requireRecordAuth("write", "profile"); + + // Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The + // batch siblings have always been capped; the per-record creates a looping + // client hits were not. + const writeRl = await checkRecordWriteRateLimit(actor.id); + if (!writeRl.allowed) { + return apiError("Too many writes, try again later", 429); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts index 3f1629054..63a1f7a40 100644 --- a/src/lib/openapi/routes/allergies.ts +++ b/src/lib/openapi/routes/allergies.ts @@ -30,6 +30,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -125,6 +126,7 @@ export const allergyPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 230e0e47e..537bf4c07 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -25,6 +25,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -125,6 +126,7 @@ export const biomarkerPaths: NonNullable = { }, ...conflict, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index 77e9ac8fc..d091a0dd7 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -27,6 +27,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -314,6 +315,7 @@ export const customMetricPaths: NonNullable = { }, ...notFound, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 4db1ac99a..f251500e6 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -35,6 +35,7 @@ import { idempotentWrite, malformedJsonResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -710,6 +711,7 @@ export const cyclePaths: NonNullable = { }, ...cycleDisabledOnADelegableRoute, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/encounters.ts b/src/lib/openapi/routes/encounters.ts index f1e3f4a64..b5df36a31 100644 --- a/src/lib/openapi/routes/encounters.ts +++ b/src/lib/openapi/routes/encounters.ts @@ -38,6 +38,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -273,6 +274,7 @@ export const encounterPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index 7cddeae2a..81dd942af 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -22,6 +22,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -161,6 +162,7 @@ export const labsPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index f138557cb..ffd097081 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -21,6 +21,7 @@ import { measurementSourceEnum, measurementTypeEnum, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -885,6 +886,7 @@ export const measurementPaths: NonNullable = { // After the spread: `stdResponses` carries a generic 422 that would // otherwise overwrite this one. ...stdResponses, + ...recordWriteRateLimitResponse, "422": { description: "Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.", diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 5c240f3b9..ace53ad90 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -35,6 +35,7 @@ import { idempotentWrite, invalidBaseTokenResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "../shared"; @@ -289,7 +290,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "401": stdResponses["401"], - "429": stdResponses["429"], + ...recordWriteRateLimitResponse, }, }, }, @@ -1153,6 +1154,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, get: { diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 92cb9afe4..186331aa3 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -56,6 +56,7 @@ import { invalidBaseTokenResponse, moduleDisabledResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, updatedAtTokenField, } from "./shared"; @@ -1636,6 +1637,7 @@ export const moodPaths: NonNullable = { ...duplicateTimestampResponse, ...recordRefusal(), ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 051be4acb..9c68b30d0 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -289,6 +289,32 @@ export const stdResponses = { }, }; +/** + * The 429 the shared single-record write bucket answers with. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written and the per-record siblings were not capped at all, which is exactly + * backwards: the batch endpoint is the one a well-behaved client uses. The + * eleven single-record creates now share one generous per-account bucket, and + * this is the response that names it — a client that meets a ceiling should be + * able to read which one it met without a bug report. + * + * Spread AFTER `...stdResponses` so it replaces the generic 429 on those + * operations. The headers block is the one the standard 429 already declares. + * + * `record-write-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 recordWriteRateLimitResponse = { + "429": { + description: + "Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. 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. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.", + content: { "application/json": { schema: errorEnvelope } }, + headers: stdResponses["429"].headers, + }, +}; + // ── Idempotent writes ──────────────────────────────────────────────── /** diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index 193a7904a..de80ef54d 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -38,6 +38,7 @@ import { idempotencyKeyParameter, idempotentWrite, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -263,6 +264,7 @@ export const vaccinationPaths: NonNullable = { }, }, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d76113579..0bef620e2 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -117,6 +117,44 @@ export async function checkAnalyticsReadRateLimit( ); } +/** + * Shared per-account ceiling for the single-record writes. + * + * The batch endpoints have been capped at 60 calls a minute since they were + * written; the per-record siblings a naive or older client loops over were not + * capped at all, which is backwards — the batch endpoint is the one a + * well-behaved client uses. This is the floor under the other direction: + * `POST /api/measurements`, `/api/mood-entries`, both medication-intake writes, + * `/api/labs`, a custom-metric entry, a cycle day-log, a vaccination, an + * allergy, a biomarker and an encounter. + * + * One shared bucket, not one per route, for the reason + * `checkAnalyticsReadRateLimit` gives: a loop that rotates across ten creates + * should still meet a cap. Three hundred a minute is five writes a second + * sustained for a full minute — no hand-driven session and no sync burst + * reaches it, and every one of these routes is already wrapped in + * `withIdempotency`, so an honest retry storm is deduplicated before it is + * counted. It exists to cut off the pathological loop, not to shape traffic. + * + * Keyed on the ACTING account rather than the record, the frozen precedent from + * the delegable medication routes: a delegate burns their own allowance rather + * than locking the record's owner out of their own log, and cannot collect a + * fresh one by switching records. + */ +export const RECORD_WRITE_BUCKET_PREFIX = "record-write"; +export const RECORD_WRITE_LIMIT = 300; +export const RECORD_WRITE_WINDOW_MS = 60 * 1000; + +export async function checkRecordWriteRateLimit( + accountId: string, +): Promise { + return checkRateLimit( + `${RECORD_WRITE_BUCKET_PREFIX}:${accountId}`, + RECORD_WRITE_LIMIT, + RECORD_WRITE_WINDOW_MS, + ); +} + /** * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). * The bucket is keyed by the `:` binding the Bearer