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