diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0acb08f3d..1b7f356e5 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: @@ -152,6 +152,22 @@ paths: burns their own allowance rather than locking the owner out and cannot collect a fresh one by switching records. Audits as `cycle.symptom.custom.create` with the icon only — never the label. Delegable at MANAGE level over the `cycle` section: the record's own symptom vocabulary is what the day-log writes need in order to say anything." + parameters: + - &a6 + name: Idempotency-Key + in: header + required: false + schema: + type: string + pattern: ^[A-Za-z0-9_\-:.]{8,128}$ + minLength: 8 + maxLength: 128 + description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the + work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED + rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no + key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different + route is a different key. While a first request under this key is still in flight, a second is refused with + 409 rather than queued." requestBody: required: true content: @@ -167,6 +183,25 @@ paths: $ref: "#/components/schemas/CycleCustomSymptomCreatedEnvelope" "401": *a1 "403": *a2 + "409": &a7 + description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry + after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." + headers: + X-Idempotent-Replay: + required: true + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + schema: + type: string + enum: + - "true" + - "false" + description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that + refuses a duplicate still in flight. Absent when the request carried no usable key." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": description: Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` (an empty or over-40-character label, an icon outside the allow-list, a @@ -332,21 +367,7 @@ paths: description: "Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off." parameters: - - &a6 - name: Idempotency-Key - in: header - required: false - schema: - type: string - pattern: ^[A-Za-z0-9_\-:.]{8,128}$ - minLength: 8 - maxLength: 128 - description: "Makes this write safe to retry. Send the same key again and the first response is replayed rather than the - work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED - rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no - key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different - route is a different key. While a first request under this key is still in flight, a second is refused with - 409 rather than queued." + - *a6 requestBody: required: true content: @@ -368,27 +389,23 @@ paths: $ref: "#/components/schemas/CycleDayLogCreatedEnvelope" "401": *a1 "403": *a2 - "409": &a7 - description: "A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry - after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay." - headers: - X-Idempotent-Replay: - required: true - description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that - refuses a duplicate still in flight. Absent when the request carried no usable key." - schema: - type: string - enum: - - "true" - - "false" - description: "`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that - refuses a duplicate still in flight. Absent when the request carried no usable key." + "409": *a7 + "422": *a4 + "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. `meta.errorCode` is + `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when + both can answer 429. 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. Every request counts against + the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 + spends the same allowance as one that writes rows. 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" - "422": *a4 - "429": *a5 /api/cycle/day-logs/{id}: patch: tags: @@ -672,6 +689,14 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" + "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. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "422": *a4 "429": *a5 @@ -844,7 +869,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 @@ -3446,7 +3471,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements/series: @@ -3498,7 +3523,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/measurements: @@ -3695,7 +3720,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -3734,7 +3759,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. @@ -3750,7 +3775,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/measurements/{id}: get: tags: @@ -3771,7 +3796,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3819,7 +3844,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3855,7 +3880,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Measurement not found (or owned by another user). content: @@ -3957,7 +3982,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -3986,7 +4011,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -4017,7 +4042,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 @@ -4058,7 +4083,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/personal-records: @@ -4105,7 +4130,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`." @@ -4160,7 +4185,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -4196,7 +4221,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a12 + "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.' @@ -4365,7 +4390,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 @@ -4490,7 +4515,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": *a10 /api/medications/{id}/glp1: get: tags: @@ -4515,7 +4540,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4542,6 +4567,7 @@ paths: schema: type: string required: true + - *a6 requestBody: required: true content: @@ -4598,13 +4624,14 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + "409": *a7 "422": *a4 "429": *a5 /api/medications/{id}/api-endpoint: @@ -4732,7 +4759,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4848,7 +4875,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4887,7 +4914,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -4932,7 +4959,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. @@ -4980,7 +5007,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. @@ -5028,7 +5055,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: @@ -5071,7 +5098,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5113,7 +5140,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5158,7 +5185,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another account). content: @@ -5238,7 +5265,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -5269,7 +5296,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -5289,7 +5316,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -5323,7 +5350,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a10 + "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. @@ -5368,7 +5395,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5405,7 +5432,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5478,7 +5505,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5487,7 +5514,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 get: tags: - Medications @@ -5552,7 +5579,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5585,7 +5612,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5621,7 +5648,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5665,7 +5692,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: @@ -5700,7 +5727,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: @@ -5778,7 +5805,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -5806,7 +5833,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -5981,7 +6008,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6019,7 +6046,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a8 + "403": *a9 "404": description: Medication not found (or owned by another user). content: @@ -6063,7 +6090,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: @@ -6104,7 +6131,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: @@ -6167,7 +6194,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": description: A dose-history import is already in progress. content: @@ -6229,7 +6256,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: @@ -6263,7 +6290,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: @@ -6293,7 +6320,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/ai/test: @@ -6483,6 +6510,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" + "400": *a12 "401": *a1 "409": description: Coach preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read @@ -6492,7 +6520,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/auth/me/disable-coach: get: @@ -6530,6 +6558,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -6684,6 +6713,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" + "400": *a12 "401": *a1 "409": description: Module preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -6693,7 +6723,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/auth/me/source-priority: get: @@ -7524,6 +7554,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -7710,6 +7741,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" + "400": *a12 "401": *a1 "409": description: Notification preferences changed since it was loaded (optimistic-concurrency conflict). No write happened. @@ -7719,7 +7751,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/auth/me/report-selection: get: @@ -7744,7 +7776,8 @@ paths: - Auth summary: Replace the owner's saved doctor-report selection description: Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is - a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved + a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` + `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer. requestBody: @@ -7760,6 +7793,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" + "400": *a12 "401": *a1 "422": *a4 "429": *a5 @@ -8734,7 +8768,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 delete: tags: @@ -8823,7 +8857,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -8938,7 +8972,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/anamnesis/facts: get: @@ -8956,7 +8990,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -9066,7 +9100,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a11 + "404": &a14 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9102,7 +9136,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a11 + "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. @@ -9834,7 +9868,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -9892,7 +9926,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -10203,7 +10237,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/coach/seeded-question: @@ -10226,7 +10260,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/insights/narrative: @@ -10272,7 +10306,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -10309,7 +10343,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -10344,7 +10378,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/settings: @@ -10418,7 +10452,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/analytics/range: @@ -10488,7 +10522,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -10517,7 +10551,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -10546,7 +10580,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -10575,7 +10609,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -10604,7 +10638,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -10633,7 +10667,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -10725,7 +10759,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -10767,7 +10801,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived: @@ -10876,7 +10910,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -10915,7 +10949,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -10935,7 +10969,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/ecg: @@ -10958,7 +10992,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 post: @@ -10996,7 +11030,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11036,7 +11070,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). @@ -11068,7 +11102,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/correlations: @@ -11089,7 +11123,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/patterns: @@ -11107,7 +11141,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -11137,7 +11171,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: @@ -11162,7 +11196,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -11181,7 +11215,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -11200,7 +11234,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/mood/analytics: @@ -11223,7 +11257,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/mood/insights: @@ -11245,7 +11279,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11268,7 +11302,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -11378,7 +11412,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a13 + "404": &a16 description: Not found / not owned by the caller. content: application/json: @@ -11413,7 +11447,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11449,7 +11483,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11502,7 +11536,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 delete: @@ -11533,7 +11567,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11581,7 +11615,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 /api/mood-entries: get: @@ -11664,7 +11698,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -11697,8 +11731,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "409": &a14 + "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. @@ -11707,7 +11741,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a10 /api/mood-entries/{id}: get: tags: @@ -11729,8 +11763,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a13 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 put: @@ -11764,9 +11798,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a8 - "404": *a13 - "409": *a14 + "403": *a9 + "404": *a16 + "409": *a17 "422": *a4 "429": *a5 delete: @@ -11791,8 +11825,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a13 + "403": *a9 + "404": *a16 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11821,7 +11855,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11850,7 +11884,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -11894,7 +11928,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -11942,7 +11976,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a15 + "400": &a18 description: Body is not parseable JSON. content: application/json: @@ -11955,13 +11989,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a16 + "413": &a19 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a17 + "415": &a20 description: Content-Type is not `application/json`. content: application/json: @@ -12019,7 +12053,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a15 + "400": *a18 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12034,8 +12068,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a16 - "415": *a17 + "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 @@ -12089,10 +12123,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a15 + "400": *a18 "401": *a1 - "413": *a16 - "415": *a17 + "413": *a19 + "415": *a20 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored. content: @@ -12116,7 +12150,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/settings/global-services: @@ -12556,7 +12590,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a18 + "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: @@ -12605,7 +12639,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a18 + "400": *a21 "401": *a1 "422": *a4 "429": *a5 @@ -12841,7 +12875,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -12866,7 +12900,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -12890,8 +12924,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a8 - "404": &a19 + "403": *a9 + "404": &a22 description: Measurement reminder not found / not owned. content: application/json: @@ -12925,8 +12959,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 delete: @@ -12948,8 +12982,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -12973,8 +13007,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13001,8 +13035,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13031,8 +13065,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13065,8 +13099,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13106,8 +13140,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a19 + "403": *a9 + "404": *a22 "422": *a4 "429": *a5 /api/labs: @@ -13179,7 +13213,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13206,10 +13240,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: @@ -13230,8 +13264,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a8 - "404": &a20 + "403": *a9 + "404": &a23 description: Lab result not found (or owned by another user). content: application/json: @@ -13265,8 +13299,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 delete: @@ -13288,8 +13322,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a8 - "404": *a20 + "403": *a9 + "404": *a23 "422": *a4 "429": *a5 /api/labs/restore: @@ -13315,7 +13349,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13419,7 +13453,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13444,15 +13478,15 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a8 - "409": &a22 + "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: @@ -13473,8 +13507,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a8 - "404": &a21 + "403": *a9 + "404": &a24 description: Biomarker not found (or owned by another user). content: application/json: @@ -13508,9 +13542,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a8 - "404": *a21 - "409": *a22 + "403": *a9 + "404": *a24 + "409": *a25 "422": *a4 "429": *a5 delete: @@ -13533,7 +13567,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a21 + "404": *a24 "422": *a4 "429": *a5 /api/encounters: @@ -13591,7 +13625,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13617,10 +13651,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: @@ -13648,7 +13682,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -13671,8 +13705,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a8 - "404": &a23 + "403": *a9 + "404": &a26 description: Visit not found (or owned by another user). content: application/json: @@ -13705,8 +13739,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a23 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13730,8 +13764,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a23 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13756,8 +13790,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a8 - "404": *a23 + "403": *a9 + "404": *a26 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13787,7 +13821,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a23 + "404": *a26 "422": *a4 "429": *a5 delete: @@ -13816,7 +13850,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a23 + "404": *a26 "422": *a4 "429": *a5 /api/practitioners: @@ -13846,7 +13880,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -13870,7 +13904,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -13894,8 +13928,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a8 - "404": &a24 + "403": *a9 + "404": &a27 description: Practitioner not found (or owned by another user). content: application/json: @@ -13928,8 +13962,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13952,8 +13986,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -13978,8 +14012,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a8 - "404": *a24 + "403": *a9 + "404": *a27 "422": *a4 "429": *a5 /api/vaccinations: @@ -14022,7 +14056,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14049,10 +14083,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: @@ -14073,8 +14107,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": &a25 + "403": *a9 + "404": &a28 description: Vaccination not found (or owned by another user). content: application/json: @@ -14108,8 +14142,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14133,8 +14167,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14158,8 +14192,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14199,8 +14233,8 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a8 - "404": *a25 + "403": *a9 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14228,7 +14262,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -14258,7 +14292,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a25 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14287,7 +14321,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a25 + "404": *a28 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14320,7 +14354,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14346,7 +14380,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14370,8 +14404,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": &a26 + "403": *a9 + "404": &a29 description: Illness episode not found (or owned by another user). content: application/json: @@ -14405,8 +14439,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14429,8 +14463,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14457,8 +14491,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "409": *a7 "422": *a4 "429": *a5 @@ -14490,8 +14524,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14541,8 +14575,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 post: @@ -14578,8 +14612,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a8 - "404": *a26 + "403": *a9 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14606,7 +14640,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a26 + "404": *a29 "422": *a4 "429": *a5 /api/illness/insights: @@ -14679,7 +14713,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14705,7 +14739,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14732,7 +14766,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/allergies: @@ -14765,7 +14799,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14790,10 +14824,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: @@ -14814,8 +14848,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a8 - "404": &a27 + "403": *a9 + "404": &a30 description: Allergy not found (or owned by another user). content: application/json: @@ -14848,8 +14882,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14872,8 +14906,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a8 - "404": *a27 + "403": *a9 + "404": *a30 "422": *a4 "429": *a5 /api/family-history: @@ -14897,7 +14931,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -14922,7 +14956,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "409": *a7 "422": *a4 "429": *a5 @@ -14946,8 +14980,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": &a28 + "403": *a9 + "404": &a31 description: Family history entry not found (or owned by another user). content: application/json: @@ -14980,8 +15014,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -15004,8 +15038,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a8 - "404": *a28 + "403": *a9 + "404": *a31 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15118,7 +15152,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15253,7 +15287,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/bulk: @@ -15300,7 +15334,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 patch: @@ -15419,7 +15453,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -15448,7 +15482,7 @@ paths: type: string format: binary "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -15875,7 +15909,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 post: @@ -15900,7 +15934,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a30 + "409": &a33 description: A custom metric with this name already exists for the caller. content: application/json: @@ -15928,8 +15962,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a8 - "404": &a29 + "403": *a9 + "404": &a32 description: Custom metric not found (or owned by another user). content: application/json: @@ -15963,8 +15997,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a29 - "409": *a30 + "404": *a32 + "409": *a33 "422": *a4 "429": *a5 delete: @@ -15987,7 +16021,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a29 + "404": *a32 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16030,8 +16064,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a8 - "404": *a29 + "403": *a9 + "404": *a32 "422": *a4 "429": *a5 post: @@ -16060,10 +16094,10 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a29 + "404": *a32 "409": *a7 "422": *a4 - "429": *a5 + "429": *a10 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -16096,7 +16130,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a31 + "404": &a34 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16129,7 +16163,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a31 + "404": *a34 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16467,7 +16501,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a32 + "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 @@ -16522,7 +16556,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a32 + "403": *a35 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16551,7 +16585,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a32 + "403": *a35 "409": *a7 "422": *a4 "429": *a5 @@ -17053,26 +17087,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a33 + "400": &a36 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a34 + "413": &a37 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a35 + "415": &a38 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a36 + "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: @@ -17258,11 +17292,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17405,11 +17439,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17562,11 +17596,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17642,21 +17676,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a37 + "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": &a38 + "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": &a39 + "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` @@ -17701,11 +17735,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17781,9 +17815,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/strava/credentials: get: tags: @@ -17819,11 +17853,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a33 + "400": *a36 "401": *a1 - "413": *a34 - "415": *a35 - "422": *a36 + "413": *a37 + "415": *a38 + "422": *a39 "429": *a5 delete: tags: @@ -17901,9 +17935,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/status: get: tags: @@ -17939,7 +17973,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/fitbit/test: post: tags: @@ -17957,9 +17991,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/google-health/resume: post: tags: @@ -17976,7 +18010,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/google-health/test: post: tags: @@ -18009,9 +18043,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/whoop/resume: post: tags: @@ -18028,7 +18062,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/whoop/test: post: tags: @@ -18045,9 +18079,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 - "502": *a39 + "422": *a40 + "429": *a41 + "502": *a42 /api/integrations/withings/resume: post: tags: @@ -18065,7 +18099,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a38 + "429": *a41 /api/integrations/withings/test: post: tags: @@ -18083,8 +18117,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a37 - "429": *a38 + "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: @@ -18112,7 +18146,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -18148,7 +18182,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18205,7 +18239,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -18224,7 +18258,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 put: @@ -18258,7 +18292,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a10 + "422": *a13 "429": *a5 delete: tags: @@ -18321,7 +18355,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a40 + "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 @@ -18346,7 +18380,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "422": *a4 "429": *a5 post: @@ -18441,7 +18475,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "404": description: No such invitation for this account. content: @@ -18485,7 +18519,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "404": description: No such grant on this account's record. content: @@ -18526,7 +18560,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a40 + "403": *a43 "404": description: No such grant held by this account. content: @@ -18908,7 +18942,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a40 + "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 — @@ -19238,7 +19272,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a41 + "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. @@ -19271,7 +19305,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a41 + "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. @@ -19298,7 +19332,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a41 + "403": *a44 "422": *a4 "429": *a5 post: @@ -19328,7 +19362,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a41 + "403": *a44 "413": description: Body exceeds 16 KiB. content: @@ -19371,7 +19405,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a41 + "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. @@ -19398,7 +19432,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -19418,7 +19452,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a8 + "403": *a9 "422": *a4 "429": *a5 /api/record-settings/{family}: @@ -19429,7 +19463,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: - - &a42 + - &a45 name: family in: path required: true @@ -19453,7 +19487,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. @@ -19473,7 +19507,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: - - *a42 + - *a45 requestBody: required: true content: @@ -19494,7 +19528,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: @@ -19607,7 +19641,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a9 + "403": *a11 "422": *a4 "429": *a5 /api/environment: @@ -19625,7 +19659,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": *a4 "429": *a5 /api/environment/home: @@ -19662,7 +19696,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19716,7 +19750,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19775,7 +19809,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -19814,7 +19848,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a12 + "403": *a15 "413": description: Body exceeds 4 KiB. content: @@ -19867,7 +19901,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a12 + "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. @@ -20008,13 +20042,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a43 + "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": &a44 + "404": &a47 description: No stored copy with this id. content: application/json: @@ -20073,8 +20107,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a43 - "404": *a44 + "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. @@ -29471,8 +29505,100 @@ components: requestId: type: string errorCode: - description: Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be - reworded. + description: "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be + reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four + naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped + client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a + refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. + Three families are outside it on purpose: `assistant.disabled.` is built from a template so the + last segment is open (`assistant.disabled.coach` is the one the native client names); the + integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, + `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, + where the differences can be stated; and a 401 raised by a route checking a credential of its own may + carry no code at all. ai_provider — ai_provider.invalid, ai_provider.no_fields; allergy — + allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.fact.conflict, + anamnesis.fact.currentExists, anamnesis.fact.invalidValue; auth — auth.admin.required, + auth.mfa.code_invalid, auth.missing, auth.refresh.invalid, auth.refresh.reuse, auth.refresh.revoked, + auth.scope.insufficient, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — + backup.payload.undecryptable, backup.section.missing; coach — coach.conversation.invalidTitle, + coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, + coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, + coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — + coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.required; cycle — + cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, + cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, + cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — + cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; + documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout; encounter — encounter.invalid, encounter.link.invalid, + encounter.practitioner-not-found, encounter.reminder-conflict, encounter.reminder-not-found; environment + — environment.invalid, environment.no_home, environment.range_too_large, environment.travel.not_found; + export — export.selection.empty, export.selection.unknown_leaf; familyHistory — familyHistory.invalid; + feedback — feedback.body.invalid; health_score_config — health_score_config.invalid, + health_score_config.too_narrow; illness — illness.day-log.invalid, illness.disabled, + illness.episode.chronic-no-resolve, illness.episode.invalid, illness.episode.invalid-window, + illness.episode.parent-not-found, illness.insights.invalid; import — import.write_failed; ingest — + ingest.rate_limited; insights — insights.generate.budgetExceeded, insights.generate.outboundScreened, + insights.generate.profileScopeChanged, insights.generate.scopeChanged; labs — + labs.biomarker.create.invalid, labs.biomarker.duplicate, labs.biomarker.notFound, + labs.biomarker.update.invalid, labs.biomarker.update.referenceRangeInvalid, labs.create.invalid, + labs.ocr.budgetExceeded, labs.ocr.extractFailed, labs.ocr.fileTooLarge, labs.ocr.fileType, + labs.ocr.localOcrDisabled, labs.ocr.pdfNeedsAnthropic, labs.ocr.providerUnsupported, + labs.ocr.rateLimited, labs.restore.invalid, labs.result.notFound, labs.update.invalid, + labs.update.linkedFieldsImmutable, labs.update.numericExpected, labs.update.qualitativeExpected, + labs.update.referenceRangeInvalid, labs.update.sourceReferenceRangeInvalid; managed_profile — + managed_profile.guardian.duplicate, managed_profile.guardian.managed_invitee, + managed_profile.guardian.required, managed_profile.not_found; measurement — + measurement.batch.source_not_permitted, measurement.batch.too_large, measurement.bulk-delete.invalid, + measurement.create.invalid, measurement.create.source_not_permitted, measurement.delete.too_large, + measurement.duplicate_timestamp, measurement.restore.invalid, measurement.update.server_owned_source; + medication — medication.intake.import.invalid_format; medications — + medications.intake.bulk.apple_health_not_mirrored, medications.intake.bulk.invalid, + medications.intake.bulk.too_large, medications.intake.force_slot.invalid, + medications.intake.force_slot.occupied, medications.intake.injection_site.disallowed, + medications.intake.taken_at.before_start, medications.mirror.limit_exceeded; mentalHealth — + mentalHealth.rateLimited; module — module.disabled; modules — modules.body.invalid_json, + modules.invalid; mood — mood.bulk-delete.invalid, mood.bulk.invalid, mood.bulk.too_large, + mood.create.external_id_not_delegable, mood.create.invalid, mood.duplicate_timestamp, + mood.linkedContext.invalid, mood.list.invalid, mood.not_found, mood.ratedFactor.out_of_range, + mood.restore.invalid, mood.update.invalid; notification-prefs — notification-prefs.body.invalid_json; + nutrient — nutrient.batch.invalid, nutrient.batch.too_large, nutrient.daily.invalid, + nutrient.read.invalid, nutrient.water.invalid, nutrient.water.invalid_day; onboarding — + onboarding.complete.invalid, onboarding.disclaimer.invalid, onboarding.step.completed, + onboarding.step.concurrent, onboarding.step.invalid, onboarding.step.outOfOrder, + onboarding.step.rateLimited, onboarding.tour.invalid, onboarding.user.notFound; personal_records — + personal_records.invalid_query; practitioner — practitioner.invalid; profile — + profile.update.emailInUse, profile.update.invalidBody, profile.update.nothingSaved; record_write — + record_write.rate_limited; report-selection — report-selection.body.invalid_json, + report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link + — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, + share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, + share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing — + sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked, + sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope, + sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted, + sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended, + sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid, + sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, + source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, + workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, + dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, + invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict, + missing_required_columns, modules_conflict, mood_tag_layout_conflict, not_configured, + notification_prefs_conflict, oidc_only, private_origin_not_approved, rate_limited_self, too_many_rows, + unreadable_json, upstream_invalid_json, vapid_not_configured" type: string additionalProperties: {} required: diff --git a/src/__tests__/error-code-catalogue.test.ts b/src/__tests__/error-code-catalogue.test.ts new file mode 100644 index 000000000..58f5e2ec3 --- /dev/null +++ b/src/__tests__/error-code-catalogue.test.ts @@ -0,0 +1,271 @@ +/** + * The published `meta.errorCode` list is the list the server actually emits. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * and the envelope tells a client to branch on it — but of the two hundred and + * forty-two codes in the tree, sixteen appeared anywhere in the contract. A + * client had to discover each one by triggering the error, and had no way at + * all to learn a new one had appeared. The catalogue publishes them; this holds + * the catalogue to the code, in both directions: + * + * - A code emitted and not listed is today's defect repeating itself, and it + * is also how a fifth naming convention would get in unnoticed. + * - A code listed and no longer emitted is a promise the server stopped + * keeping, which is worse than an omission: a client can write a branch for + * it and wait forever. + * + * What it reads: every `.ts` under `src/app/api` outside a `__tests__` + * directory, and then every module those files import, transitively, so a code + * built one or five hops away from the route that answers with it is read + * where it is written. Import specifiers are resolved the way the app resolves + * them (`@/…` and relative paths, `.ts` / `.tsx` / `index.*`); anything that + * leaves `src` is not followed, and the generated Prisma client is never + * opened. Comment lines are stripped first, so a code quoted in prose is not + * mistaken for one that ships. + * + * This used to be the route tree plus a hand-kept list of library modules, and + * the hand-kept list is exactly what it sounds like: the dose-history import + * built five codes in a module nobody had thought to add, so the guard read + * neither the codes nor the file and agreed the catalogue was complete. The + * import graph is not hand-kept — a new module reached by a route is swept the + * first time a route imports it. + * + * Its limits, stated rather than implied. A code assembled at runtime from a + * template cannot be enumerated at all, and the one family that does so is + * named in the catalogue's own doc comment. A code that reaches the envelope + * through a variable is found where the values are declared, which means a + * literal in a constant the sweep's shapes do not cover would still be missed: + * a constant that holds wire codes is named `*_CODE` / `*_CODES` for that + * reason. And `IntegrationStatus.errorCode` is a different field with the same + * name — it records an upstream HTTP status in the sync ledger and never + * reaches a response — so the two ledger writers that take it are cut out of + * the text before it is matched. + */ +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; + +const SRC_ROOT = join(process.cwd(), "src"); +const APP_ROOT = join(SRC_ROOT, "app"); +/** Never opened: the generated Prisma client is megabytes of machine output. */ +const GENERATED_ROOT = join(SRC_ROOT, "generated"); + +/** + * The sync ledger's writers. `IntegrationStatus.errorCode` records an upstream + * HTTP status and never reaches a response envelope, so their arguments are cut + * out before a code is looked for. A third writer would surface as an + * undocumented code rather than as silence, which is the direction to fail in. + */ +const LEDGER_WRITERS = ["recordSyncFailure", "parkIntegrationAtReauth"]; + +function tsFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...tsFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Resolve one import specifier the way the app's path alias resolves it. */ +function resolveSpecifier(specifier: string, fromFile: string): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC_ROOT, specifier.slice(2)); + else if (specifier.startsWith(".")) + base = resolve(dirname(fromFile), specifier); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + join(base, "index.tsx"), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +/** Every module a route reaches, however many hops away it is written. */ +function modulesUnderTheRoutes(): string[] { + const queue = tsFiles(APP_ROOT); + const seen = new Set(queue); + for (let i = 0; i < queue.length; i++) { + const file = queue[i]; + const source = readFileSync(file, "utf8"); + const specifiers = [ + ...source.matchAll( + /(?:^|\n)\s*(?:import|export)[\s\S]{0,400}?from\s*"([^"]+)"/g, + ), + ...source.matchAll(/import\(\s*"([^"]+)"\s*\)/g), + ]; + for (const match of specifiers) { + const resolved = resolveSpecifier(match[1], file); + if (!resolved) continue; + if (resolved.startsWith(GENERATED_ROOT)) continue; + if (resolved.includes(`${sep}__tests__${sep}`)) continue; + if (seen.has(resolved)) continue; + seen.add(resolved); + queue.push(resolved); + } + } + return [...seen]; +} + +/** Cut the sync-ledger writers' arguments out, parenthesis-balanced. */ +function withoutLedgerWrites(source: string): string { + let text = source; + for (const writer of LEDGER_WRITERS) { + for (;;) { + const call = text.indexOf(`${writer}(`); + if (call === -1) break; + const open = call + writer.length; + let depth = 0; + let close = text.length; + for (let j = open; j < text.length; j++) { + if (text[j] === "(") depth++; + else if (text[j] === ")") { + depth--; + if (depth === 0) { + close = j + 1; + break; + } + } + } + text = text.slice(0, call) + text.slice(close); + } + } + return text; +} + +/** Drop comment lines so a code quoted in prose is not read as one that ships. */ +function withoutComments(source: string): string { + return source + .split("\n") + .filter((line) => { + const trimmed = line.trim(); + return !( + trimmed.startsWith("*") || + trimmed.startsWith("//") || + trimmed.startsWith("/*") + ); + }) + .join("\n"); +} + +/** The shapes a code is written in across the tree. */ +function codesIn(source: string): string[] { + const found: string[] = []; + // `errorCode: "x"`, `errorCode = "x"`, `readonly errorCode = "x"`, and the + // annotated `errorCode: string = "x"` on the step-up error's constructor. + for (const m of source.matchAll( + /errorCode\s*(?::\s*[A-Za-z.[\]|" ]+)?\s*[:=]\s*"([^"]+)"/g, + )) { + found.push(m[1]); + } + // A single shared constant: `export const MODULE_DISABLED_ERROR_CODE = "…"`. + for (const m of source.matchAll(/\b[A-Z][A-Z0-9_]*_CODE\s*=\s*"([^"]+)"/g)) { + found.push(m[1]); + } + // A family in one object: `export const AUTH_ERROR_CODES = { … }`, or in one + // tuple: `export const AUTO_EXPORT_FATAL_ERROR_CODES = [ … ] as const`. The + // tuple is how a union type of codes is declared, and that union is what an + // envelope relays when it writes `errorCode: parsed.fatal.reason`. Only the + // `_ERROR_CODES` spelling is read in tuple form: plenty of unrelated + // catalogues (nutrients, say) are lists of codes that are not error codes. + for (const m of source.matchAll(/\b([A-Z][A-Z0-9_]*_CODES)\s*=\s*([{[])/g)) { + const [, name, opener] = m; + if (opener === "[" && !name.endsWith("_ERROR_CODES")) continue; + const closer = opener === "{" ? "}" : "]"; + const start = source.indexOf(opener, m.index); + let depth = 0; + let end = start; + for (let j = start; j < source.length; j++) { + if (source[j] === opener) depth++; + else if (source[j] === closer) { + depth--; + if (depth === 0) { + end = j; + break; + } + } + } + const body = source.slice(start, end); + const values = + opener === "{" + ? body.matchAll(/:\s*"([^"]+)"/g) + : body.matchAll(/"([^"]+)"/g); + for (const value of values) found.push(value[1]); + } + return found; +} + +/** Every code the tree emits, mapped to the files that emit it. */ +function emittedCodes(): Map { + const emitted = new Map(); + for (const file of modulesUnderTheRoutes()) { + const source = withoutLedgerWrites( + withoutComments(readFileSync(file, "utf8")), + ); + for (const code of codesIn(source)) { + const where = relative(process.cwd(), file).split(sep).join("/"); + const seen = emitted.get(code); + if (seen) { + if (!seen.includes(where)) seen.push(where); + } else { + emitted.set(code, [where]); + } + } + } + return emitted; +} + +const emitted = emittedCodes(); +const published = new Set(Object.values(ERROR_CODE_CATALOGUE).flat()); + +describe("the published errorCode catalogue", () => { + it("finds the codes it is meant to judge", () => { + // A matcher that stopped matching would otherwise agree with an empty + // catalogue. Two hundred and twenty-nine when this was written, read out + // of the fourteen hundred modules the route tree reaches. + expect(emitted.size).toBeGreaterThan(200); + }); + + it("lists every code the server emits", () => { + const undocumented = [...emitted.entries()] + .filter(([code]) => !published.has(code)) + .map(([code, files]) => `${code} (${files[0]})`) + .sort(); + expect(undocumented).toEqual([]); + }); + + it("emits every code it lists", () => { + const stale = [...published].filter((code) => !emitted.has(code)).sort(); + expect(stale).toEqual([]); + }); + + it("files every code under the surface its own prefix names", () => { + const misfiled: string[] = []; + for (const [surface, codes] of Object.entries(ERROR_CODE_CATALOGUE)) { + for (const code of codes) { + const expected = code.includes(".") + ? code.slice(0, code.indexOf(".")) + : "(unprefixed)"; + if (expected !== surface) misfiled.push(`${code} under ${surface}`); + } + } + expect(misfiled).toEqual([]); + }); + + it("keeps no duplicate between two surfaces", () => { + const all = Object.values(ERROR_CODE_CATALOGUE).flat(); + expect(all.length).toBe(new Set(all).size); + }); +}); diff --git a/src/__tests__/malformed-json-status-guard.test.ts b/src/__tests__/malformed-json-status-guard.test.ts new file mode 100644 index 000000000..b26fae67a --- /dev/null +++ b/src/__tests__/malformed-json-status-guard.test.ts @@ -0,0 +1,158 @@ +/** + * A body that will not parse is always a 400. + * + * `safeJson` has answered 400 since it was written and roughly two hundred and + * twenty routes go through it; `apiHandler`'s own `SyntaxError` branch answers + * 400 as well. Seven `/api/auth/me/*` writes hand-rolled `await req.json()` in + * a try/catch and threw 422 for the same condition, so a client's "my + * serializer produced garbage" branch had to accept two statuses on a subset of + * routes it could not predict — and the subset was invisible from the outside. + * + * The guard reads every hand-rolled parse out of the route tree rather than + * listing them, so a route that grows one later is judged too. The `catch` is + * read on its own: a `try` that parses a body and then refuses with anything + * other than 400 fails here. + * + * Its limit: a route that parses a body somewhere other than inside a `try` + * whose `catch` builds the refusal — a shared helper, a promise chain — is + * invisible to it. `safeJson` remains the documented entry, and the assertion + * on the helper below is what pins the status the rest of the tree inherits. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { safeJson } from "@/lib/api-response"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Hand-rolled parses that must keep a different status, with the reason. + * + * `/api/auth/me/health-score-config` is here because the published contract + * names its answer: the operation's own 422 description spells out that a body + * which is not JSON at all comes back with the bare message + * `health-score-config.body.invalid_json`. Moving it to 400 would change a + * promise a shipped client was generated against, so the status stays and the + * change is a contract decision of its own rather than a cleanup. + */ +const ALLOWLIST: Record = { + "src/app/api/auth/me/health-score-config/route.ts": { + status: 422, + reason: + "The 422 and its exact message are written into the operation's published description.", + }, +}; + +/** Every `route.ts` under `src/app/api`, tests excluded. */ +function routeFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...routeFiles(full)); + continue; + } + if (entry.name === "route.ts") found.push(full); + } + return found; +} + +/** The brace-balanced block starting at the `{` at or after `from`. */ +function block(src: string, from: number): { text: string; end: number } { + let i = from; + while (i < src.length && src[i] !== "{") i++; + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return { text: src.slice(i, j + 1), end: j + 1 }; + } + } + return { text: src.slice(i), end: src.length }; +} + +interface ParseRefusal { + route: string; + line: number; + status: number | null; +} + +/** Every `try { … .json() … } catch { … }` under the route tree. */ +function scanHandRolledParses(): ParseRefusal[] { + const found: ParseRefusal[] = []; + for (const file of routeFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + for (const m of src.matchAll(/\btry\s*\{/g)) { + const start = m.index ?? 0; + const tryBlock = block(src, start); + if (!/(?:req|request)\w*\.json\(\)/.test(tryBlock.text)) continue; + const after = src.slice(tryBlock.end); + const catchAt = after.match(/^\s*catch\s*(?:\([^)]*\)\s*)?\{/); + if (!catchAt) continue; + const catchBlock = block(after, catchAt.index ?? 0); + const status = catchBlock.text.match(/\b(4\d\d|5\d\d)\b/); + found.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: src.slice(0, start).split("\n").length, + status: status ? Number(status[1]) : null, + }); + } + } + return found; +} + +describe("malformed JSON answers one status", () => { + const parses = scanHandRolledParses(); + + it("finds the hand-rolled parses it is meant to judge", () => { + // Eight when the guard was written. A matcher that stops matching would + // otherwise report a clean pass it did not earn. + expect(parses.length).toBeGreaterThanOrEqual(8); + }); + + it("refuses an unparseable body with 400", () => { + const wrong = parses + .filter((parse) => { + const allowed = ALLOWLIST[parse.route]; + if (allowed) return parse.status !== allowed.status; + return parse.status !== 400; + }) + .map((parse) => `${parse.route}:${parse.line} -> ${parse.status}`); + expect(wrong).toEqual([]); + }); + + it("keeps no stale allowlist entries", () => { + const stale = Object.keys(ALLOWLIST).filter( + (route) => !parses.some((parse) => parse.route === route), + ); + expect(stale).toEqual([]); + }); + + it("is the status the shared helper already answers with", async () => { + const request = new Request("https://example.test/api/anything", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ not json", + }); + const result = await safeJson(request); + expect(result.error?.status).toBe(400); + expect(await result.error?.clone().json()).toMatchObject({ + data: null, + error: "Invalid JSON body", + }); + }); + + it("keeps the wrong-content-type refusal separate at 415", async () => { + const request = new Request("https://example.test/api/anything", { + method: "POST", + headers: { "content-type": "text/plain" }, + body: "{}", + }); + const result = await safeJson(request); + expect(result.error?.status).toBe(415); + }); +}); 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..55c327147 --- /dev/null +++ b/src/__tests__/record-write-rate-limit-contract.test.ts @@ -0,0 +1,156 @@ +/** + * 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("names the code it publishes at every site", () => { + // The prose is not the discriminator: two of these routes consult a second + // bucket of their own, and a client that can only read `error` cannot tell + // which one refused it. + const silent = ON_THE_SHARED_BUCKET.filter(([route]) => { + const src = readFileSync(join(process.cwd(), route), "utf8"); + return !src.includes('errorCode: "record_write.rate_limited"'); + }).map(([route]) => route); + expect(silent).toEqual([]); + expect(published).toContain("record_write.rate_limited"); + }); + + 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/__tests__/zod-refusal-issue-list-guard.test.ts b/src/__tests__/zod-refusal-issue-list-guard.test.ts new file mode 100644 index 000000000..57426b41b --- /dev/null +++ b/src/__tests__/zod-refusal-issue-list-guard.test.ts @@ -0,0 +1,265 @@ +/** + * Every 422 raised by a Zod `safeParse` names the fields it refused. + * + * `returnAllZodIssues` / `apiValidationError` emit `details.issues` — one entry + * per rejected field — so a client with three bad fields fixes all three in one + * round-trip instead of three. Most routes use them. Fifty-nine refusal sites + * did not: they returned the correct 422 with a single opaque sentence, and a + * client could not map the failure onto a field. Twelve of those put a dotted + * machine token in `error` instead of a sentence, so `error` was sometimes text + * to show a person and sometimes a token that must not be shown, with nothing + * in the payload to tell them apart. + * + * The matcher is per-site, not per-file, because per-file is what let this + * grow: a route that used the helper once and a bare `apiError(..., 422)` + * somewhere else in the same file looked compliant to a `grep -l` sweep. So + * every `if (!.success)` block whose identifier came from a `safeParse` + * in that file is read on its own, and only the blocks that answer 422 are + * judged — a 400, a 401 or a 403 raised off a parse result is a different + * refusal and is left alone. + * + * The second half of the same audit item is the dotted token in `error`, and + * it is checked by its own sweep at the bottom of this file rather than by the + * per-site matcher above: `report-selection.leaves.unknown` was decided by + * `if (!minted.ok)` rather than by a parse result, so the block matcher could + * not see it while the string it answered with was the defect itself. The + * token sweep reads the refused string, not the condition that chose it, so a + * site cannot hide behind the shape of its own `if`. + * + * Its limits, stated so nobody reads more into a green run than is there: + * a refusal built somewhere other than the `if (!x.success)` block that decided + * it — a helper called with the error, a status assembled from a variable — is + * invisible to the issue-list half, and so is a route that hands the `ZodError` + * to shared code outside `src/app/api`. It catches the shape this defect + * actually took, and the count assertions below are what keep it from passing + * on a matcher that silently stopped matching anything. + */ +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative, sep } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const API_ROOT = join(process.cwd(), "src", "app", "api"); + +/** + * Routes that answer a shape refusal without the issue list, each with the + * reason it must differ. Empty today: every 422 raised off a `safeParse` under + * `src/app/api` carries `details.issues`. An entry here is a decision, not a + * backlog item — write why the list cannot ship, not when it will. + */ +const ALLOWLIST: Record = {}; + +/** Every `route.ts` under `src/app/api`, tests excluded. */ +function routeFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...routeFiles(full)); + continue; + } + if (entry.name === "route.ts") found.push(full); + } + return found; +} + +/** + * The `{ … }` (or single statement) that follows an `if (…)`, brace-balanced. + * + * A refusal is often one statement with no braces — `if (!parsed.success) + * return apiError("Invalid data", 422);` — so both forms are read. + */ +function refusalBlock(src: string, afterCondition: number): string { + let i = afterCondition; + while (i < src.length && /\s/.test(src[i])) i++; + if (src[i] === "{") { + let depth = 0; + for (let j = i; j < src.length; j++) { + if (src[j] === "{") depth++; + else if (src[j] === "}") { + depth--; + if (depth === 0) return src.slice(i, j + 1); + } + } + return src.slice(i); + } + const end = src.indexOf(";", i); + return src.slice(i, end === -1 ? src.length : end + 1); +} + +interface RefusalSite { + route: string; + line: number; + carriesIssues: boolean; +} + +/** Every 422 refusal decided by a `safeParse` result, across the route tree. */ +function scanRefusalSites(): RefusalSite[] { + const sites: RefusalSite[] = []; + for (const file of routeFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + // Identifiers bound to a parse result. Both call styles are in the tree: + // `schema.safeParse(body)` and the standalone `z.safeParse(schema, body)`. + const parsed = new Set(); + for (const m of src.matchAll( + /(?:const|let)\s+([A-Za-z0-9_$]+)\s*=\s*(?:await\s+)?[^;]*?safeParse\s*\(/g, + )) { + parsed.add(m[1]); + } + if (parsed.size === 0) continue; + + for (const m of src.matchAll( + /if\s*\(\s*!\s*([A-Za-z0-9_$]+)\.success\s*\)/g, + )) { + if (!parsed.has(m[1])) continue; + const block = refusalBlock(src, (m.index ?? 0) + m[0].length); + // `returnAllZodIssues` and `apiValidationError` answer 422 unless told + // otherwise, so a block that calls one is a 422 site whether or not the + // number is spelled: a future `refuse422(parsed.error)` helper would + // otherwise be invisible for the same reason. + const carriesIssues = /returnAllZodIssues|apiValidationError/.test(block); + if (!/\b422\b/.test(block) && !carriesIssues) continue; + if ( + /\b(?:400|401|403|409|413|415|429|500|502)\b/.test(block) && + !/\b422\b/.test(block) + ) + continue; + sites.push({ + route: relative(process.cwd(), file).split(sep).join("/"), + line: src.slice(0, m.index).split("\n").length, + carriesIssues, + }); + } + } + return sites; +} + +describe("Zod shape refusals carry the issue list", () => { + const sites = scanRefusalSites(); + + it("finds the refusal sites it is meant to judge", () => { + // A matcher that stops matching would otherwise report a clean zero it did + // not earn. Two hundred and forty-eight sites when this was written, so + // the old floor of 40 would have survived losing five matches in six. + expect(sites.length).toBeGreaterThan(200); + }); + + it("answers every 422 with details.issues", () => { + const missing = sites + .filter((site) => !site.carriesIssues && !(site.route in ALLOWLIST)) + .map((site) => `${site.route}:${site.line}`); + expect(missing).toEqual([]); + }); + + it("keeps no stale allowlist entries", () => { + const stale = Object.keys(ALLOWLIST).filter( + (route) => !sites.some((site) => site.route === route), + ); + expect(stale).toEqual([]); + }); +}); + +/** + * The dotted token in `error`, wherever it is refused from. + * + * `error` is the field a client shows a person. A 422 that puts + * `report-selection.leaves.unknown` there hands the UI a string it must not + * render and gives it nothing else to branch on. That site was decided by + * `if (!minted.ok)`, not by a parse result, so the per-site matcher above ran + * past it — which is the argument for reading the refused string directly: + * whatever chose the refusal, the string is the defect. + * + * The inventory below is frozen and may only shrink. Each entry says why the + * token is still the whole `error` string; a new one fails until somebody + * either writes a sentence or writes the reason. + */ +const TOKEN_IN_ERROR_422: Record = { + "src/app/api/auth/me/coach-prefs/route.ts coach-prefs.body.invalid_shape": + "The status does not move for a shape refusal, so moving the string too would be an unforced second wire change; `meta.errorCode` now carries the same token.", + "src/app/api/auth/me/report-selection/route.ts report-selection.body.invalid_shape": + "Same shape refusal, same reason.", + "src/app/api/auth/me/source-priority/route.ts source-priority.body.invalid_shape": + "Same shape refusal, same reason.", + "src/app/api/insights/chat/messages/[id]/feedback/route.ts feedback.body.invalid": + "Same shape refusal, same reason.", + "src/app/api/insights/chat/route.ts coach.request.invalid": + "Same shape refusal, same reason.", + "src/app/api/auth/me/health-score-config/route.ts health-score-config.body.invalid_json": + "The published 422 description spells this exact body out as what a non-JSON request earns. It is the one operation whose contract promises the token, so the token is what it answers with.", + "src/app/api/coach/reminder-suggestions/route.ts coach.suggestion.unknownCadence": + "Predates this sweep and carries no `meta.errorCode` yet; the Coach reminder surfaces move as one piece or not at all.", + "src/app/api/coach/reminders/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", + "src/app/api/coach/reminders/[id]/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", + "src/app/api/coach/suggested-actions/route.ts coach.action.unknownInterval": + "Predates this sweep, same surface.", + "src/app/api/coach/suggested-actions/route.ts coach.reminder.invalidWhen": + "Predates this sweep, same surface.", +}; + +/** A dotted machine token: no spaces, at least one dot, no sentence in sight. */ +const DOTTED_TOKEN = /^[a-z][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)+$/; + +/** Every `.ts` under `src/app/api`, tests excluded — not only `route.ts`. */ +function apiFiles(dir: string): string[] { + const found: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name === "__tests__") continue; + found.push(...apiFiles(full)); + continue; + } + if (entry.name.endsWith(".ts")) found.push(full); + } + return found; +} + +/** Every 422 whose `error` string is a dotted token, keyed ` `. */ +function tokenRefusals(): string[] { + const found: string[] = []; + for (const file of apiFiles(API_ROOT)) { + const src = readFileSync(file, "utf8"); + const route = relative(process.cwd(), file).split(sep).join("/"); + const shapes = [ + /apiError\(\s*"([^"]+)"\s*,\s*422/g, + /apiValidationError\(\s*"([^"]+)"/g, + /new HttpError\(\s*422\s*,\s*"([^"]+)"/g, + ]; + for (const shape of shapes) { + for (const m of src.matchAll(shape)) { + if (!DOTTED_TOKEN.test(m[1])) continue; + const key = `${route} ${m[1]}`; + if (!found.includes(key)) found.push(key); + } + } + } + return found; +} + +describe("a 422 does not answer with a machine token where the sentence goes", () => { + const refusals = tokenRefusals(); + + it("finds the sites it is meant to judge", () => { + // Eleven when this was written, twelve before the report-selection leaf + // refusal was rewritten. A matcher that stopped matching would agree with + // an empty inventory. + expect(refusals.length).toBeGreaterThan(8); + }); + + it("keeps the token in `error` only where a reason is written down", () => { + const unexplained = refusals + .filter((key) => !(key in TOKEN_IN_ERROR_422)) + .sort(); + expect(unexplained).toEqual([]); + }); + + it("keeps no stale inventory entry", () => { + const stale = Object.keys(TOKEN_IN_ERROR_422) + .filter((key) => !refusals.includes(key)) + .sort(); + expect(stale).toEqual([]); + }); +}); diff --git a/src/app/api/admin/settings/web-push-vapid/generate/route.ts b/src/app/api/admin/settings/web-push-vapid/generate/route.ts index db647d982..27ce847d4 100644 --- a/src/app/api/admin/settings/web-push-vapid/generate/route.ts +++ b/src/app/api/admin/settings/web-push-vapid/generate/route.ts @@ -2,10 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAdmin } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { - apiSuccess, apiError, + apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; @@ -58,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = generateSchema.safeParse(body ?? {}); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const existing = await prisma.appSettings.findUnique({ diff --git a/src/app/api/ai/test/route.ts b/src/app/api/ai/test/route.ts index 6c3abfaae..56440ce29 100644 --- a/src/app/api/ai/test/route.ts +++ b/src/app/api/ai/test/route.ts @@ -6,7 +6,13 @@ import { type AITestOverride, } from "@/lib/ai/provider"; import { singleUserTurn } from "@/lib/ai/types"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { @@ -69,7 +75,11 @@ export const POST = apiHandler(async (request: NextRequest) => { if (error) return error; const parsed = aiTestOverrideSchema.safeParse(data); if (!parsed.success) { - return apiError("Invalid override payload", 422); + return apiValidationError( + "Invalid override payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } override = parsed.data; } diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index f4e69e40c..2f9d4d360 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,17 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts index cae5d1db0..1aaf77170 100644 --- a/src/app/api/auth/check-user/route.ts +++ b/src/app/api/auth/check-user/route.ts @@ -37,7 +37,12 @@ */ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -80,7 +85,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = bodySchema.safeParse(body); if (!parsed.success) { - return apiError("identifier required", 422); + return apiValidationError( + "identifier required", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const identifier = parsed.data.identifier; diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 6d48a15b5..eb3272f8f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -3,7 +3,12 @@ import { loginPasswordSchema } from "@/lib/validations/auth"; import { verifyPassword } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { NextRequest, NextResponse } from "next/server"; @@ -55,7 +60,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = loginPasswordSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid credentials", 422); + return apiValidationError( + "Invalid credentials", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { email, password } = parsed.data; diff --git a/src/app/api/auth/me/coach-prefs/route.ts b/src/app/api/auth/me/coach-prefs/route.ts index d76c3fdb2..4e1bf8593 100644 --- a/src/app/api/auth/me/coach-prefs/route.ts +++ b/src/app/api/auth/me/coach-prefs/route.ts @@ -15,8 +15,13 @@ * helper. The Coach prompt builder + snapshot builder both read this * row on every turn — there's no caching layer to invalidate. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { @@ -57,7 +62,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "coach-prefs.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "coach-prefs.body.invalid_json", + }); } // v1.32.22 (M4) — pull the optimistic-concurrency base token off before the @@ -74,7 +81,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.coach-prefs.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - throw new HttpError(422, "coach-prefs.body.invalid_shape"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "coach-prefs.body.invalid_shape", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "coach-prefs.body.invalid_shape" }, + ); } // Persist the canonical defaulted form so the column shape stays diff --git a/src/app/api/auth/me/cycle-prefs/route.ts b/src/app/api/auth/me/cycle-prefs/route.ts index ec6d97132..81f145a57 100644 --- a/src/app/api/auth/me/cycle-prefs/route.ts +++ b/src/app/api/auth/me/cycle-prefs/route.ts @@ -65,7 +65,7 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - return apiError("Invalid JSON body", 422, { + return apiError("Invalid JSON body", 400, { errorCode: "cycle-prefs.body.invalid_json", }); } diff --git a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts index 5ed9ad5b2..0726fb835 100644 --- a/src/app/api/auth/me/disable-coach/__tests__/route.test.ts +++ b/src/app/api/auth/me/disable-coach/__tests__/route.test.ts @@ -212,7 +212,11 @@ describe("PATCH /api/auth/me/disable-coach", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/disable-coach", { @@ -222,7 +226,11 @@ describe("PATCH /api/auth/me/disable-coach", () => { }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "disable-coach.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/disable-coach/route.ts b/src/app/api/auth/me/disable-coach/route.ts index a5cb1bd7f..7dddc8624 100644 --- a/src/app/api/auth/me/disable-coach/route.ts +++ b/src/app/api/auth/me/disable-coach/route.ts @@ -21,7 +21,7 @@ */ import { z } from "zod"; -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -78,7 +78,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "disable-coach.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "disable-coach.body.invalid_json", + }); } const parsed = patchBodySchema.safeParse(body); diff --git a/src/app/api/auth/me/mfa/disable/route.ts b/src/app/api/auth/me/mfa/disable/route.ts index 8e9718220..d1ec12e0f 100644 --- a/src/app/api/auth/me/mfa/disable/route.ts +++ b/src/app/api/auth/me/mfa/disable/route.ts @@ -20,8 +20,10 @@ import { import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -63,7 +65,11 @@ export const POST = apiHandler(async (req: Request) => { } const parsed = mfaDisableSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const factor = await verifyMfaFactor( diff --git a/src/app/api/auth/me/mfa/totp/confirm/route.ts b/src/app/api/auth/me/mfa/totp/confirm/route.ts index 1251829d0..2aac38635 100644 --- a/src/app/api/auth/me/mfa/totp/confirm/route.ts +++ b/src/app/api/auth/me/mfa/totp/confirm/route.ts @@ -25,8 +25,10 @@ import { import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -78,7 +80,11 @@ export const POST = apiHandler(async (req: Request) => { } const parsed = totpConfirmSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid code", 422); + return apiValidationError( + "Invalid code", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // Decrypt is fail-closed — a missing/rotated key throws rather than diff --git a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts index 61af3c671..cff4259d6 100644 --- a/src/app/api/auth/me/mfa/webauthn/[id]/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/[id]/route.ts @@ -13,8 +13,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -39,7 +41,11 @@ export const PATCH = apiHandler( const parsed = mfaWebauthnRenameSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const existing = await prisma.webauthnMfaCredential.findUnique({ diff --git a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts index 371e91174..e96388a1f 100644 --- a/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts +++ b/src/app/api/auth/me/mfa/webauthn/register/verify/route.ts @@ -11,8 +11,10 @@ import { apiHandler, requireMfaManagementAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -34,7 +36,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaWebauthnRegisterVerifySchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { challengeId, credential, name } = parsed.data; diff --git a/src/app/api/auth/me/modules/__tests__/route.test.ts b/src/app/api/auth/me/modules/__tests__/route.test.ts index 7a14aef25..bb675fc73 100644 --- a/src/app/api/auth/me/modules/__tests__/route.test.ts +++ b/src/app/api/auth/me/modules/__tests__/route.test.ts @@ -285,7 +285,11 @@ describe("PATCH /api/auth/me/modules", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/modules", { method: "PATCH", @@ -293,7 +297,11 @@ describe("PATCH /api/auth/me/modules", () => { headers: { "Content-Type": "application/json" }, }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "modules.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/modules/route.ts b/src/app/api/auth/me/modules/route.ts index 05060be90..5c10e8d93 100644 --- a/src/app/api/auth/me/modules/route.ts +++ b/src/app/api/auth/me/modules/route.ts @@ -25,7 +25,7 @@ * carries it. The semantics are a DISABLED allowlist: a key set to * `false` disables that module; `true` (or absence) leaves it enabled. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -84,7 +84,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "modules.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "modules.body.invalid_json", + }); } // v1.32.22 (M3) — strip the optimistic-concurrency base token BEFORE the diff --git a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts index c8d503649..dc485fe69 100644 --- a/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts +++ b/src/app/api/auth/me/notification-prefs/__tests__/route.test.ts @@ -324,7 +324,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { ); }); - it("rejects malformed JSON with 422", async () => { + // 400, not the 422 this used to pin. A body that will not parse is a + // client-side serialisation fault, and `safeJson` plus roughly two hundred + // and twenty other routes have always answered 400 for it; the dotted token + // moved to `meta.errorCode`, which is where a machine code belongs. + it("rejects malformed JSON with 400", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const req = new Request("http://localhost/api/auth/me/notification-prefs", { @@ -334,7 +338,11 @@ describe("PATCH /api/auth/me/notification-prefs", () => { }); const res = await (PATCH as (r: Request) => Promise)(req); - expect(res.status).toBe(422); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + error: "Invalid JSON body", + meta: { errorCode: "notification-prefs.body.invalid_json" }, + }); expect(prisma.user.update).not.toHaveBeenCalled(); }); diff --git a/src/app/api/auth/me/notification-prefs/route.ts b/src/app/api/auth/me/notification-prefs/route.ts index b7e64cfd3..3770a380e 100644 --- a/src/app/api/auth/me/notification-prefs/route.ts +++ b/src/app/api/auth/me/notification-prefs/route.ts @@ -23,7 +23,7 @@ * extra round-trip. Rate-limit is intentionally generous (60/min) — * matches the disable-coach route which is the closest analogue. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -86,7 +86,9 @@ export const PATCH = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "notification-prefs.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "notification-prefs.body.invalid_json", + }); } // v1.32.22 (M1) — pull the optimistic-concurrency base token off the body diff --git a/src/app/api/auth/me/report-selection/route.ts b/src/app/api/auth/me/report-selection/route.ts index dd9c4b6a9..bbb85b9c2 100644 --- a/src/app/api/auth/me/report-selection/route.ts +++ b/src/app/api/auth/me/report-selection/route.ts @@ -16,8 +16,14 @@ * Replaces `GET`/`PUT /api/auth/me/doctor-report-prefs`, whose column nothing * outside that route ever read. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma, toJson } from "@/lib/db"; @@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "report-selection.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "report-selection.body.invalid_json", + }); } const parsed = savedReportProfileSchema.safeParse(body ?? {}); @@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.report-selection.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - throw new HttpError(422, "report-selection.body.invalid_shape"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "report-selection.body.invalid_shape", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "report-selection.body.invalid_shape" }, + ); } const minted = selectionFromRequest(parsed.data); @@ -66,7 +84,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.report-selection.put.invalid" }, meta: { unknownLeaves: minted.error.unknownLeaves }, }); - throw new HttpError(422, "report-selection.leaves.unknown"); + // Not a Zod refusal, so there is no issue list to carry — but the same + // rule applies: a sentence in `error`, the machine token in + // `meta.errorCode`, and the leaves that were refused named beside it. + return apiError( + "Report selection names leaves this build does not know", + 422, + { + errorCode: "report-selection.leaves.unknown", + unknownLeaves: minted.error.unknownLeaves, + }, + ); } // Persist the canonical ordering rather than the caller's, so two clients diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 2deba909d..d5897cd1d 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -18,8 +18,14 @@ * decides which source's rows the cached targets / derived / analytics * payloads were built from. */ -import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -48,7 +54,9 @@ export const PUT = apiHandler(async (req: Request) => { try { body = await req.json(); } catch { - throw new HttpError(422, "source-priority.body.invalid_json"); + return apiError("Invalid JSON body", 400, { + errorCode: "source-priority.body.invalid_json", + }); } const parsed = sourcePrioritySchema.safeParse(body ?? {}); @@ -57,7 +65,17 @@ export const PUT = apiHandler(async (req: Request) => { action: { name: "auth.me.source-priority.put.invalid" }, meta: { issues: parsed.error.issues.length }, }); - throw new HttpError(422, "source-priority.body.invalid_shape"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "source-priority.body.invalid_shape", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "source-priority.body.invalid_shape" }, + ); } // v1.4.25 W10 reconcile (security M-3): capture the previous shape diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts index 67a4028d9..cdd7b03af 100644 --- a/src/app/api/auth/mfa/verify/route.ts +++ b/src/app/api/auth/mfa/verify/route.ts @@ -17,7 +17,12 @@ * limited per IP on top of the per-ticket cap. */ import { NextRequest } from "next/server"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -61,7 +66,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaVerifySchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { mfaTicket, method, code, rememberDevice } = parsed.data; diff --git a/src/app/api/auth/mfa/webauthn/verify/options/route.ts b/src/app/api/auth/mfa/webauthn/verify/options/route.ts index aeff3e827..36992dd24 100644 --- a/src/app/api/auth/mfa/webauthn/verify/options/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/options/route.ts @@ -13,7 +13,13 @@ */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { ensureDbCompatibility } from "@/lib/db-compat"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -45,7 +51,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaWebauthnLoginOptionsSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const challenge = await loadActiveChallenge(parsed.data.mfaTicket); diff --git a/src/app/api/auth/mfa/webauthn/verify/route.ts b/src/app/api/auth/mfa/webauthn/verify/route.ts index 3ad7c0b57..00df78104 100644 --- a/src/app/api/auth/mfa/webauthn/verify/route.ts +++ b/src/app/api/auth/mfa/webauthn/verify/route.ts @@ -16,7 +16,12 @@ */ import { NextRequest } from "next/server"; import { apiHandler } from "@/lib/api-handler"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -59,7 +64,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = mfaWebauthnLoginVerifySchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { mfaTicket, challengeId, credential, rememberDevice } = parsed.data; diff --git a/src/app/api/auth/native/token/route.ts b/src/app/api/auth/native/token/route.ts index 5055a2024..0d74291b3 100644 --- a/src/app/api/auth/native/token/route.ts +++ b/src/app/api/auth/native/token/route.ts @@ -20,7 +20,12 @@ * `requireFreshMfa` step-up. */ import { NextRequest } from "next/server"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -83,7 +88,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = nativeHandoffTokenSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { code, codeVerifier } = parsed.data; diff --git a/src/app/api/auth/oidc/native/token/route.ts b/src/app/api/auth/oidc/native/token/route.ts index d0bd4674e..bc28bed5e 100644 --- a/src/app/api/auth/oidc/native/token/route.ts +++ b/src/app/api/auth/oidc/native/token/route.ts @@ -23,7 +23,12 @@ * `requireFreshMfa` step-up, so an SSO login can never satisfy step-up. */ import { NextRequest } from "next/server"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { apiHandler } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -89,7 +94,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = oidcNativeTokenSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { code, codeVerifier } = parsed.data; diff --git a/src/app/api/auth/step-up/options/route.ts b/src/app/api/auth/step-up/options/route.ts index 30392af0c..6ec2f67ac 100644 --- a/src/app/api/auth/step-up/options/route.ts +++ b/src/app/api/auth/step-up/options/route.ts @@ -18,7 +18,13 @@ */ import { NextRequest } from "next/server"; import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = stepUpOptionsSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } if (parsed.data.method === "webauthn") { diff --git a/src/app/api/auth/step-up/route.ts b/src/app/api/auth/step-up/route.ts index 7744cc320..2514d777a 100644 --- a/src/app/api/auth/step-up/route.ts +++ b/src/app/api/auth/step-up/route.ts @@ -33,8 +33,10 @@ import { apiHandler, requireBearerAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -112,7 +114,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = stepUpMintSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid request", 422); + return apiValidationError( + "Invalid request", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const method: StepUpMethod = parsed.data.method; 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..8de993f70 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,17 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } 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..166304e3f 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,16 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } 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/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 2c69eb869..9906a35c5 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -18,7 +18,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleCalendarQuerySchema } from "@/lib/validations/cycle"; import { @@ -55,9 +60,14 @@ export const GET = apiHandler(async (request: NextRequest) => { to: url.searchParams.get("to") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid calendar query", 422, { - errorCode: "cycle.calendar.invalid", - }); + return apiValidationError( + "Invalid calendar query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "cycle.calendar.invalid", + }, + ); } const tz = user.timezone ?? DEFAULT_TIMEZONE; diff --git a/src/app/api/cycle/cycles/route.ts b/src/app/api/cycle/cycles/route.ts index 6e1c65fb3..0e4a90692 100644 --- a/src/app/api/cycle/cycles/route.ts +++ b/src/app/api/cycle/cycles/route.ts @@ -12,7 +12,11 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { cycleHistoryQuerySchema } from "@/lib/validations/cycle"; import { toMenstrualCycleDTO } from "@/lib/cycle/dto"; @@ -39,9 +43,14 @@ export const GET = apiHandler(async (request: NextRequest) => { limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid history query", 422, { - errorCode: "cycle.cycles.invalid", - }); + return apiValidationError( + "Invalid history query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "cycle.cycles.invalid", + }, + ); } const limit = parsed.data.limit ?? DEFAULT_LIMIT; diff --git a/src/app/api/cycle/day-logs/route.ts b/src/app/api/cycle/day-logs/route.ts index 28e91b0c4..cacf927fd 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,21 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/cycle/symptoms/custom/route.ts b/src/app/api/cycle/symptoms/custom/route.ts index 798a4a7e3..ac4d56572 100644 --- a/src/app/api/cycle/symptoms/custom/route.ts +++ b/src/app/api/cycle/symptoms/custom/route.ts @@ -26,6 +26,7 @@ import { } from "@/lib/api-response"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { withIdempotency } from "@/lib/idempotency"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -67,7 +68,23 @@ export const GET = apiHandler(async () => { return apiSuccess({ symptoms }); }); -export const POST = apiHandler(async (request: NextRequest) => { +/** + * Wrapped in `withIdempotency`: a custom symptom is minted from the client's + * offline outbox under the same `Idempotency-Key`, and nothing else here would + * catch the replay. The row's key is minted fresh per request + * (`custom:`), and the label it would duplicate lives in + * `labelEncrypted` — AES-GCM with a per-write IV, so no unique index can be + * put on it. A replay after a lost success response therefore wrote a SECOND + * row carrying the same label, up to the fifty-row cap: a silent duplicate in + * the person's own symptom vocabulary rather than a conflict anything refused. + * The wrapper answers the first attempt's 201 instead, and the in-flight 409 it + * defines is the only 409 this route can produce. + */ +export const POST = apiHandler( + withIdempotency<[NextRequest]>(postCustomSymptom), +); + +async function postCustomSymptom(request: NextRequest): Promise { // v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log // writes the level admits need in order to say anything. const { user, actor } = await requireRecordAuth("manage", "cycle"); @@ -151,4 +168,4 @@ export const POST = apiHandler(async (request: NextRequest) => { }, 201, ); -}); +} diff --git a/src/app/api/documents/inbound/[id]/chat/route.ts b/src/app/api/documents/inbound/[id]/chat/route.ts index 4a001abad..b0559de97 100644 --- a/src/app/api/documents/inbound/[id]/chat/route.ts +++ b/src/app/api/documents/inbound/[id]/chat/route.ts @@ -24,7 +24,13 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -93,9 +99,14 @@ export const POST = apiHandler( if (jsonError) return jsonError; const parsed = documentChatRequestSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid document chat request", 422, { - errorCode: "documents.chat.invalid", - }); + return apiValidationError( + "Invalid document chat request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.chat.invalid", + }, + ); } const { conversationId, message, locale: bodyLocale } = parsed.data; @@ -231,9 +242,14 @@ export const GET = apiHandler( limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid chat history query", 422, { - errorCode: "documents.chat.invalid", - }); + return apiValidationError( + "Invalid chat history query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.chat.invalid", + }, + ); } // With a conversationId → that one thread's messages (must hold this document). diff --git a/src/app/api/documents/inbound/[id]/extract/route.ts b/src/app/api/documents/inbound/[id]/extract/route.ts index 93de5f7e9..757c012a7 100644 --- a/src/app/api/documents/inbound/[id]/extract/route.ts +++ b/src/app/api/documents/inbound/[id]/extract/route.ts @@ -30,8 +30,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -236,9 +238,14 @@ async function handleTextExtract( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const dateKey = buildDateKey(); diff --git a/src/app/api/documents/inbound/[id]/index/route.ts b/src/app/api/documents/inbound/[id]/index/route.ts index d64166fe1..9a3cd6756 100644 --- a/src/app/api/documents/inbound/[id]/index/route.ts +++ b/src/app/api/documents/inbound/[id]/index/route.ts @@ -26,8 +26,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -154,9 +156,14 @@ async function handleTextIndex( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const { tokenCount } = await upsertContentIndex({ diff --git a/src/app/api/documents/inbound/[id]/route.ts b/src/app/api/documents/inbound/[id]/route.ts index 22fef5f43..ee87db643 100644 --- a/src/app/api/documents/inbound/[id]/route.ts +++ b/src/app/api/documents/inbound/[id]/route.ts @@ -13,8 +13,10 @@ import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -143,9 +145,14 @@ export const PATCH = apiHandler( const parsed = documentUpdateSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid document update", 422, { - errorCode: "documents.inbound.invalidUpdate", - }); + return apiValidationError( + "Invalid document update", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidUpdate", + }, + ); } const existing = await prisma.inboundDocument.findFirst({ diff --git a/src/app/api/documents/inbound/[id]/suggest/route.ts b/src/app/api/documents/inbound/[id]/suggest/route.ts index 94f435641..b7feb1f51 100644 --- a/src/app/api/documents/inbound/[id]/suggest/route.ts +++ b/src/app/api/documents/inbound/[id]/suggest/route.ts @@ -18,8 +18,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -148,9 +150,14 @@ async function handleTextSuggest( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } const dateKey = buildDateKey(); diff --git a/src/app/api/documents/inbound/[id]/summary/route.ts b/src/app/api/documents/inbound/[id]/summary/route.ts index 55fbea7ec..186e842d7 100644 --- a/src/app/api/documents/inbound/[id]/summary/route.ts +++ b/src/app/api/documents/inbound/[id]/summary/route.ts @@ -25,8 +25,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertDocumentEgressConsent } from "@/lib/ai/consent-guard"; @@ -297,9 +299,14 @@ async function handleTextSummary( const parsed = inboundTextExtractSchema.safeParse(body); if (!parsed.success) { await refundDocumentAiSlot(userId); - return apiError("Invalid document text payload", 422, { - errorCode: "documents.inbound.extractFailed", - }); + return apiValidationError( + "Invalid document text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.extractFailed", + }, + ); } // `mode=text` over posted OCR text is a pure echo — the text IS the diff --git a/src/app/api/documents/inbound/route.ts b/src/app/api/documents/inbound/route.ts index f4ebcbe0a..15e79f51b 100644 --- a/src/app/api/documents/inbound/route.ts +++ b/src/app/api/documents/inbound/route.ts @@ -33,7 +33,13 @@ import { requireRecordAuth, type AuthContext, } from "@/lib/api-handler"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { hashQueryTokens } from "@/lib/documents/content-index"; @@ -236,9 +242,14 @@ async function processUpload( encounterIds: rawEncounterIds.length > 0 ? rawEncounterIds : undefined, }); if (!parsed.success) { - return apiError("Invalid document metadata", 422, { - errorCode: "documents.inbound.invalidMetadata", - }); + return apiValidationError( + "Invalid document metadata", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidMetadata", + }, + ); } let buffer: Buffer; @@ -518,9 +529,14 @@ export const GET = apiHandler(async (request: Request) => { kind: kinds.length > 0 ? kinds : undefined, }); if (!parsed.success) { - return apiError("Invalid list query", 422, { - errorCode: "documents.inbound.invalidQuery", - }); + return apiValidationError( + "Invalid list query", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "documents.inbound.invalidQuery", + }, + ); } const { q, diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 0fc4a5fa7..5abab7694 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,17 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts index bb694f3ed..e8a5f6444 100644 --- a/src/app/api/fitbit/credentials/__tests__/route.test.ts +++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts @@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: async (req: NextRequest) => { diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index bef303a15..d313e401d 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(fitbitCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/api/google-health/credentials/route.ts b/src/app/api/google-health/credentials/route.ts index d7edaa328..f8f2a271d 100644 --- a/src/app/api/google-health/credentials/route.ts +++ b/src/app/api/google-health/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -47,7 +52,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(googleHealthCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } // Field-by-field assignment — never spread the parsed object, so no future diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index e81f51a54..02bad9389 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -3,10 +3,12 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { - apiSuccess, apiError, + apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { NextRequest } from "next/server"; @@ -175,8 +177,9 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = importSchema.safeParse(body); if (!parsed.success) { - return apiError( + return apiValidationError( parsed.error.issues[0]?.message ?? "Invalid import payload", + sanitiseZodIssues(parsed.error.issues), 422, ); } diff --git a/src/app/api/insights/chat/[id]/attachments/route.ts b/src/app/api/insights/chat/[id]/attachments/route.ts index 2b1dcd488..a6c66fb75 100644 --- a/src/app/api/insights/chat/[id]/attachments/route.ts +++ b/src/app/api/insights/chat/[id]/attachments/route.ts @@ -10,7 +10,13 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -65,9 +71,14 @@ export const POST = apiHandler( if (jsonError) return jsonError; const parsed = coachAttachmentCreateSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid attachment request", 422, { - errorCode: "coach.fenced.invalid", - }); + return apiValidationError( + "Invalid attachment request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "coach.fenced.invalid", + }, + ); } const { documentId } = parsed.data; diff --git a/src/app/api/insights/chat/fenced/route.ts b/src/app/api/insights/chat/fenced/route.ts index 338c938c1..1c7da325a 100644 --- a/src/app/api/insights/chat/fenced/route.ts +++ b/src/app/api/insights/chat/fenced/route.ts @@ -15,7 +15,12 @@ import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; @@ -57,9 +62,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const parsed = fencedChatRequestSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid fenced chat request", 422, { - errorCode: "coach.fenced.invalid", - }); + return apiValidationError( + "Invalid fenced chat request", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "coach.fenced.invalid", + }, + ); } const { conversationId, diff --git a/src/app/api/insights/chat/messages/[id]/feedback/route.ts b/src/app/api/insights/chat/messages/[id]/feedback/route.ts index 973f9a264..4056d7eb9 100644 --- a/src/app/api/insights/chat/messages/[id]/feedback/route.ts +++ b/src/app/api/insights/chat/messages/[id]/feedback/route.ts @@ -34,8 +34,10 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -91,7 +93,17 @@ async function handlePost(request: NextRequest, ctx: RouteContext) { action: { name: "insights.coach.message.feedback" }, meta: { outcome: "validation_failed" }, }); - throw new HttpError(422, "feedback.body.invalid"); + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "feedback.body.invalid", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "feedback.body.invalid" }, + ); } const body = parsed.data; diff --git a/src/app/api/insights/chat/route.ts b/src/app/api/insights/chat/route.ts index a5c0e3191..9dc19d79d 100644 --- a/src/app/api/insights/chat/route.ts +++ b/src/app/api/insights/chat/route.ts @@ -27,10 +27,15 @@ * 7. Persist user message + assistant message (encrypted) and bump * the day's CoachUsage token ledger. */ -import { NextResponse, type NextRequest } from "next/server"; +import { type NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; -import { apiError, apiSuccess } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { redactOptional, redactSecrets } from "@/lib/logging/redact"; import { auditLog } from "@/lib/auth/audit"; @@ -209,9 +214,16 @@ async function handleChatRequest(request: NextRequest): Promise { action: { name: "insights.coach.invalid" }, meta: { issues: parsed.error.issues.length }, }); - return NextResponse.json( - { data: null, error: "coach.request.invalid" }, - { status: 422 }, + // The dotted token keeps its place in `error`: this refusal keeps its 422, + // so moving the string would be a wire change with nothing forcing it — + // the `invalid_json` siblings moved only because their status moved to 400 + // anyway. `meta.errorCode` publishes the same token in the field a machine + // code belongs in, and `details.issues` names the fields that were refused. + return apiValidationError( + "coach.request.invalid", + sanitiseZodIssues(parsed.error.issues), + 422, + { errorCode: "coach.request.invalid" }, ); } const { diff --git a/src/app/api/insights/feedback/route.ts b/src/app/api/insights/feedback/route.ts index a8b30b5e7..6d1e22b36 100644 --- a/src/app/api/insights/feedback/route.ts +++ b/src/app/api/insights/feedback/route.ts @@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; @@ -66,7 +68,11 @@ async function handlePost(request: NextRequest) { action: { name: "insights.recommendation.feedback" }, meta: { outcome: "validation_failed" }, }); - return apiError("Invalid feedback payload", 422); + return apiValidationError( + "Invalid feedback payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const body = parsed.data; diff --git a/src/app/api/insights/provider-chain/route.ts b/src/app/api/insights/provider-chain/route.ts index 82216c462..b5afc4379 100644 --- a/src/app/api/insights/provider-chain/route.ts +++ b/src/app/api/insights/provider-chain/route.ts @@ -2,7 +2,12 @@ import type { NextRequest } from "next/server"; import { z } from "zod/v4"; import { apiHandler, HttpError, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { resolveProviderChain } from "@/lib/ai/provider"; import { getLastWorkingProvider } from "@/lib/ai/provider-runner"; @@ -111,7 +116,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const parsed = chainBodySchema.safeParse(body); if (!parsed.success) { - throw new HttpError(422, "Invalid provider chain payload"); + return apiValidationError( + "Invalid provider chain payload", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // Defence-in-depth: reject duplicate provider types so a stale-tab 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/ocr/extract/route.ts b/src/app/api/labs/ocr/extract/route.ts index d6219657e..125af81dd 100644 --- a/src/app/api/labs/ocr/extract/route.ts +++ b/src/app/api/labs/ocr/extract/route.ts @@ -28,7 +28,13 @@ import { Buffer } from "node:buffer"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; import { assertConsentForChain } from "@/lib/ai/consent-guard"; import { @@ -154,9 +160,14 @@ async function handleTextExtract( const parsed = ocrTextExtractSchema.safeParse(body); if (!parsed.success) { - return apiError("Invalid OCR text payload", 422, { - errorCode: "labs.ocr.extractFailed", - }); + return apiValidationError( + "Invalid OCR text payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "labs.ocr.extractFailed", + }, + ); } // Budget — text mode is a plain text→JSON structuring pass, far cheaper than diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 97fc344ce..d4fbf47d9 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,17 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } 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/batch/route.ts b/src/app/api/measurements/batch/route.ts index 52672d2ae..776e870b7 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -45,8 +45,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { withIdempotency } from "@/lib/idempotency"; import { @@ -274,7 +276,11 @@ async function postBatch(request: NextRequest): Promise { const parsed = batchPayloadSchema.safeParse(rawBody); if (!parsed.success) { - return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid batch", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } // A scoped credential may attribute MANUAL and nothing else. Refused loudly diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 8ede3dcb1..387db7ed4 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -38,8 +38,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; @@ -83,7 +85,11 @@ async function deleteByExternalIds(request: NextRequest): Promise { const parsed = payloadSchema.safeParse(rawBody); if (!parsed.success) { - return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422); + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid batch", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const { externalIds } = parsed.data; diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 6dcfe5456..ca5e59685 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,16 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 180badac9..46a18ac2e 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -28,6 +28,7 @@ import { import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { glp1PostBodySchema } from "@/lib/validations/medication"; import { assertMedicationOwnership } from "@/lib/medications/route-guards"; +import { withIdempotency } from "@/lib/idempotency"; import { estimateDailyDoseCount, estimateRunwayDays, @@ -236,103 +237,71 @@ export const GET = apiHandler( * Rate-limit: 30/min/user (same as sibling POST routes). * Audit: `medication.glp1.update` for every successful mutation. */ +/** + * Wrapped in `withIdempotency`: a titration step is replayed out of the + * client's offline outbox under the same `Idempotency-Key`, and a replay after + * a lost success response would otherwise write a second dose change or a + * second supply delta. Neither row carries a natural key, so the wrapper is the + * only thing that can collapse the retry. + */ export const POST = apiHandler( - async (request: NextRequest, { params }: RouteParams) => { - // v1.37.0 — MANAGE. A titration step is record data and the dose is what - // the server acts on; administering one is the case the level names. - const { user, actor } = await requireRecordAuth("manage", "medications"); - const { id } = await params; + withIdempotency<[NextRequest, RouteParams]>(postGlp1Update), +); - const guard = await assertMedicationOwnership(id, user.id); - if (guard) return guard; +async function postGlp1Update( + request: NextRequest, + { params }: RouteParams, +): Promise { + // v1.37.0 — MANAGE. A titration step is record data and the dose is what + // the server acts on; administering one is the case the level names. + const { user, actor } = await requireRecordAuth("manage", "medications"); + const { id } = await params; - // Per-caller POST rate-limit — matches the 30/min sibling routes - // (inventory, side-effects). Generous for a hand-driven session, - // tight enough to cut off the spam case. - // - // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own - // allowance and cannot collect a fresh one by switching records. - const rl = await checkRateLimit( - `medication-glp1:post:${actor.id}`, - POST_RATE_LIMIT, - POST_WINDOW_MS, - ); - if (!rl.allowed) { - return apiError("Too many requests", 429, { - headers: rateLimitHeaders(rl), - }); - } + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, + // Per-caller POST rate-limit — matches the 30/min sibling routes + // (inventory, side-effects). Generous for a hand-driven session, + // tight enough to cut off the spam case. + // + // v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own + // allowance and cannot collect a fresh one by switching records. + const rl = await checkRateLimit( + `medication-glp1:post:${actor.id}`, + POST_RATE_LIMIT, + POST_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), }); - if (jsonError) return jsonError; + } - const parsed = glp1PostBodySchema.safeParse(body); - if (!parsed.success) { - // v1.4.43 W6 — multi-issue 422. - return returnAllZodIssues(parsed.error, 422); - } + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; - const ip = getClientIp(request); + const parsed = glp1PostBodySchema.safeParse(body); + if (!parsed.success) { + // v1.4.43 W6 — multi-issue 422. + return returnAllZodIssues(parsed.error, 422); + } - if (parsed.data.doseChange) { - const { effectiveFrom, doseValue, doseUnit, note } = - parsed.data.doseChange; - const created = await prisma.medicationDoseChange.create({ - data: { - medicationId: id, - effectiveFrom, - doseValue, - doseUnit, - // Encrypt the titration note at rest; the plaintext column stays null. - noteEncrypted: encryptNote(note), - note: null, - }, - }); + const ip = getClientIp(request); - await auditLog("medication.glp1.update", { - userId: user.id, - ipAddress: ip, - details: { - medicationId: id, - kind: "doseChange", - doseChangeId: created.id, - doseValue, - doseUnit, - }, - }); - - annotate({ - action: { - name: "medication.glp1.doseChange.create", - entity_type: "medication_dose_change", - entity_id: created.id, - }, - meta: { medication_id: id, doseValue, doseUnit }, - }); - - // A dose change shifts the daily-dose estimate the list payload's - // runway derivation uses. Hard-evict the medications + compliance - // buckets so the card reflects the new runway on the next read. - invalidateUserMedications(user.id, { evict: true }); - - return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); - } - - // The schema's XOR refinement guarantees `inventory` is present - // when `doseChange` is not, so the non-null assertion is safe. - // - // DEPRECATED branch (v1.16.10): this writes the legacy - // MedicationInventoryEvent running-sum ledger. The per-item - // endpoints (`POST /api/medications/[id]/inventory`, - // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; - // GET above and the Coach snapshot read the ledger only while the - // medication has zero inventory items. New callers must register - // containers instead of posting deltas. - const { delta, reason } = parsed.data.inventory!; - const created = await prisma.medicationInventoryEvent.create({ - data: { medicationId: id, delta, reason }, + if (parsed.data.doseChange) { + const { effectiveFrom, doseValue, doseUnit, note } = parsed.data.doseChange; + const created = await prisma.medicationDoseChange.create({ + data: { + medicationId: id, + effectiveFrom, + doseValue, + doseUnit, + // Encrypt the titration note at rest; the plaintext column stays null. + noteEncrypted: encryptNote(note), + note: null, + }, }); await auditLog("medication.glp1.update", { @@ -340,27 +309,70 @@ export const POST = apiHandler( ipAddress: ip, details: { medicationId: id, - kind: "inventory", - inventoryEventId: created.id, - delta, - reason, + kind: "doseChange", + doseChangeId: created.id, + doseValue, + doseUnit, }, }); annotate({ action: { - name: "medication.glp1.inventory.create", - entity_type: "medication_inventory_event", + name: "medication.glp1.doseChange.create", + entity_type: "medication_dose_change", entity_id: created.id, }, - meta: { medication_id: id, delta }, + meta: { medication_id: id, doseValue, doseUnit }, }); - // A legacy ledger delta changes the stock the list payload reports - // for a medication with no per-item containers. Hard-evict so the - // card reflects it on the next read. + // A dose change shifts the daily-dose estimate the list payload's + // runway derivation uses. Hard-evict the medications + compliance + // buckets so the card reflects the new runway on the next read. invalidateUserMedications(user.id, { evict: true }); - return apiSuccess({ inventory: created }, 201); - }, -); + return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201); + } + + // The schema's XOR refinement guarantees `inventory` is present + // when `doseChange` is not, so the non-null assertion is safe. + // + // DEPRECATED branch (v1.16.10): this writes the legacy + // MedicationInventoryEvent running-sum ledger. The per-item + // endpoints (`POST /api/medications/[id]/inventory`, + // `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; + // GET above and the Coach snapshot read the ledger only while the + // medication has zero inventory items. New callers must register + // containers instead of posting deltas. + const { delta, reason } = parsed.data.inventory!; + const created = await prisma.medicationInventoryEvent.create({ + data: { medicationId: id, delta, reason }, + }); + + await auditLog("medication.glp1.update", { + userId: user.id, + ipAddress: ip, + details: { + medicationId: id, + kind: "inventory", + inventoryEventId: created.id, + delta, + reason, + }, + }); + + annotate({ + action: { + name: "medication.glp1.inventory.create", + entity_type: "medication_inventory_event", + entity_id: created.id, + }, + meta: { medication_id: id, delta }, + }); + + // A legacy ledger delta changes the stock the list payload reports + // for a medication with no per-item containers. Hard-evict so the + // card reflects it on the next read. + invalidateUserMedications(user.id, { evict: true }); + + return apiSuccess({ inventory: created }, 201); +} 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..b61961363 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,16 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } + // 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..193966b4e 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,16 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/monitoring/glitchtip/route.ts b/src/app/api/monitoring/glitchtip/route.ts index d84d38d87..0d444cc59 100644 --- a/src/app/api/monitoring/glitchtip/route.ts +++ b/src/app/api/monitoring/glitchtip/route.ts @@ -1,6 +1,12 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; -import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + getClientIp, + sanitiseZodIssues, +} from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { getGlitchtipSettings } from "@/lib/monitoring-settings"; import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip"; @@ -42,7 +48,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = clientErrorSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid error data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid error data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const delivery = await sendGlitchtipEvent({ dsn: settings.glitchtipDsn, 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..96fb20d0a 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,16 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } + const { data: body, error: jsonError } = await safeJson(request, { maxBytes: 64 * 1024, }); diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index f1cde5a7f..bb7b545a3 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -42,6 +42,28 @@ vi.mock("@/lib/rate-limit", () => ({ })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: ( error: string, diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 57a221e2d..77bc5f383 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -3,7 +3,13 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; @@ -53,7 +59,11 @@ export const POST = apiHandler(async (request: NextRequest) => { const result = z.safeParse(nightscoutConnectSchema, body); if (!result.success) { - return apiError("A valid Nightscout URL is required", 422); + return apiValidationError( + "A valid Nightscout URL is required", + sanitiseZodIssues(result.error.issues), + 422, + ); } const { url, token } = result.data; diff --git a/src/app/api/notifications/preferences/route.ts b/src/app/api/notifications/preferences/route.ts index 9b98b2ca4..0fbe2fa4d 100644 --- a/src/app/api/notifications/preferences/route.ts +++ b/src/app/api/notifications/preferences/route.ts @@ -2,7 +2,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationPreferenceSchema } from "@/lib/validations/notifications"; import { EVENT_TYPES, CHANNEL_TYPE_LABELS } from "@/lib/notifications/types"; import type { ChannelType } from "@/lib/notifications/types"; @@ -106,7 +111,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = notificationPreferenceSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { channelId, eventType, enabled } = parsed.data; diff --git a/src/app/api/notifications/status/route.ts b/src/app/api/notifications/status/route.ts index 54fb387cc..5adf11a2a 100644 --- a/src/app/api/notifications/status/route.ts +++ b/src/app/api/notifications/status/route.ts @@ -2,7 +2,12 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types"; import type { ChannelType, EventType } from "@/lib/notifications/types"; import { reEnableChannel } from "@/lib/notifications/channel-state"; @@ -156,7 +161,12 @@ export const POST = apiHandler(async (request: NextRequest) => { } const parsed = reEnableChannelSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const channel = await prisma.notificationChannel.findFirst({ where: { id: parsed.data.channelId, userId: user.id }, diff --git a/src/app/api/onboarding/complete/route.ts b/src/app/api/onboarding/complete/route.ts index 4bfe35382..a41c6fb52 100644 --- a/src/app/api/onboarding/complete/route.ts +++ b/src/app/api/onboarding/complete/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -22,9 +27,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const result = z.safeParse(onboardingCompleteSchema, body); if (!result.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.complete.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(result.error.issues), + 422, + { + errorCode: "onboarding.complete.invalid", + }, + ); } const data: Record = { diff --git a/src/app/api/onboarding/disclaimer/route.ts b/src/app/api/onboarding/disclaimer/route.ts index e9cf2dd5a..acb33fff5 100644 --- a/src/app/api/onboarding/disclaimer/route.ts +++ b/src/app/api/onboarding/disclaimer/route.ts @@ -3,7 +3,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { DISCLAIMER_VERSION } from "@/lib/onboarding/disclaimer"; /** @@ -38,9 +43,14 @@ export const POST = apiHandler(async (request) => { const parsed = z.safeParse(bodySchema, rawBody); if (!parsed.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.disclaimer.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.disclaimer.invalid", + }, + ); } // The server pins the canonical version it stamps — the body version is diff --git a/src/app/api/onboarding/step/route.ts b/src/app/api/onboarding/step/route.ts index 177e42831..c21dc2cc8 100644 --- a/src/app/api/onboarding/step/route.ts +++ b/src/app/api/onboarding/step/route.ts @@ -4,8 +4,10 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, + apiValidationError, getClientIp, safeJson, + sanitiseZodIssues, } from "@/lib/api-response"; import { auditLog } from "@/lib/auth/audit"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; @@ -80,9 +82,14 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "onboarding.step" }, meta: { outcome: "validation_failed" }, }); - return apiError("Invalid step payload", 422, { - errorCode: "onboarding.step.invalid", - }); + return apiValidationError( + "Invalid step payload", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.step.invalid", + }, + ); } const { step, goals } = parsed.data; diff --git a/src/app/api/onboarding/tour/route.ts b/src/app/api/onboarding/tour/route.ts index 08e9df6a2..b68d24e7b 100644 --- a/src/app/api/onboarding/tour/route.ts +++ b/src/app/api/onboarding/tour/route.ts @@ -4,7 +4,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { tourProgressSchema } from "@/lib/onboarding/tour-progress"; import { Prisma } from "@/generated/prisma/client"; @@ -56,9 +61,14 @@ export const POST = apiHandler(async (request: NextRequest) => { const parsed = z.safeParse(tourBodySchema, body); if (!parsed.success) { - return apiError("Invalid input", 422, { - errorCode: "onboarding.tour.invalid", - }); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(parsed.error.issues), + 422, + { + errorCode: "onboarding.tour.invalid", + }, + ); } // Build the update field-by-field (no mass assignment). `completed` diff --git a/src/app/api/oura/credentials/__tests__/route.test.ts b/src/app/api/oura/credentials/__tests__/route.test.ts index 800be41a7..d59052f89 100644 --- a/src/app/api/oura/credentials/__tests__/route.test.ts +++ b/src/app/api/oura/credentials/__tests__/route.test.ts @@ -23,6 +23,28 @@ vi.mock("@/lib/oura/credentials", () => ({ })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: vi.fn(), diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index 177718127..b5bb0a6e9 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { markDisconnected } from "@/lib/integrations/status"; @@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(ouraCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storeOuraClientCredentials( diff --git a/src/app/api/polar/credentials/__tests__/route.test.ts b/src/app/api/polar/credentials/__tests__/route.test.ts index 3b744bc78..2952491f7 100644 --- a/src/app/api/polar/credentials/__tests__/route.test.ts +++ b/src/app/api/polar/credentials/__tests__/route.test.ts @@ -26,6 +26,28 @@ vi.mock("@/lib/polar/credentials", () => ({ clearPolarClientCredentials: clearMock, })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: vi.fn(async (req: { _body: unknown }) => ({ diff --git a/src/app/api/polar/credentials/route.ts b/src/app/api/polar/credentials/route.ts index 7dbab091b..6d23712f0 100644 --- a/src/app/api/polar/credentials/route.ts +++ b/src/app/api/polar/credentials/route.ts @@ -1,7 +1,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { markDisconnected } from "@/lib/integrations/status"; import { @@ -51,7 +56,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(polarCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storePolarClientCredentials( diff --git a/src/app/api/settings/email/route.ts b/src/app/api/settings/email/route.ts index bd2dc592d..c8a2afb2c 100644 --- a/src/app/api/settings/email/route.ts +++ b/src/app/api/settings/email/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { emailSettingsSchema, notificationChannelEnabledSchema, @@ -102,7 +108,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = emailSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { recipient, enabled } = parsed.data; diff --git a/src/app/api/settings/ntfy/route.ts b/src/app/api/settings/ntfy/route.ts index ea54e4c0f..eb0f4b05a 100644 --- a/src/app/api/settings/ntfy/route.ts +++ b/src/app/api/settings/ntfy/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationChannelEnabledSchema, ntfySettingsSchema, @@ -119,7 +125,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = ntfySettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { serverUrl, topic, authToken, enabled } = parsed.data; diff --git a/src/app/api/settings/telegram/route.ts b/src/app/api/settings/telegram/route.ts index c837ce010..545392e9a 100644 --- a/src/app/api/settings/telegram/route.ts +++ b/src/app/api/settings/telegram/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { decrypt, encrypt } from "@/lib/crypto"; import { deleteTelegramWebhook, setTelegramWebhook } from "@/lib/telegram"; import { telegramSettingsSchema } from "@/lib/validations/telegram"; @@ -171,7 +177,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(telegramSettingsSchema, body); if (!result.success) { - return apiError("Invalid input", 422); + return apiValidationError( + "Invalid input", + sanitiseZodIssues(result.error.issues), + 422, + ); } const { botToken, chatId, enabled } = result.data; diff --git a/src/app/api/settings/webhook/route.ts b/src/app/api/settings/webhook/route.ts index 8129a2745..0b8c5082f 100644 --- a/src/app/api/settings/webhook/route.ts +++ b/src/app/api/settings/webhook/route.ts @@ -1,5 +1,11 @@ import { prisma } from "@/lib/db"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { notificationChannelEnabledSchema, webhookSettingsSchema, @@ -108,7 +114,12 @@ export const PUT = apiHandler(async (request: NextRequest) => { } const parsed = webhookSettingsSchema.safeParse(body); - if (!parsed.success) return apiError("Invalid data", 422); + if (!parsed.success) + return apiValidationError( + "Invalid data", + sanitiseZodIssues(parsed.error.issues), + 422, + ); const { url, headerName, headerValue, enabled } = parsed.data; diff --git a/src/app/api/strava/credentials/route.ts b/src/app/api/strava/credentials/route.ts index ac53d9c47..7605775ef 100644 --- a/src/app/api/strava/credentials/route.ts +++ b/src/app/api/strava/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { markDisconnected } from "@/lib/integrations/status"; @@ -52,7 +57,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(stravaCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await storeStravaClientCredentials( diff --git a/src/app/api/sync/changes/route.ts b/src/app/api/sync/changes/route.ts index 3688a6863..694929cc0 100644 --- a/src/app/api/sync/changes/route.ts +++ b/src/app/api/sync/changes/route.ts @@ -50,7 +50,12 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { + apiError, + apiSuccess, + apiValidationError, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; import { readNote } from "@/lib/crypto/note-cipher"; @@ -211,7 +216,11 @@ export const GET = apiHandler(async (request: NextRequest) => { limit: url.searchParams.get("limit") ?? undefined, }); if (!parsed.success) { - return apiError("Invalid sync query", 422); + return apiValidationError( + "Invalid sync query", + sanitiseZodIssues(parsed.error.issues), + 422, + ); } const limit = parsed.data.limit ?? DEFAULT_LIMIT; diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 16d2be8b7..b1ddc7ae5 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,17 @@ 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, { + errorCode: "record_write.rate_limited", + }); + } const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 32 * 1024, diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts index 6ca1e67ea..091c9835d 100644 --- a/src/app/api/whoop/credentials/__tests__/route.test.ts +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -19,6 +19,28 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ + // The parse-refusal pair the routes now answer with: the same envelope + // plus the per-field issue list. + sanitiseZodIssues: ( + issues: { path: (string | number)[]; code: string; message: string }[], + ) => + issues.map((issue) => ({ + path: issue.path.join("."), + code: issue.code, + message: issue.message, + })), + apiValidationError: ( + error: string, + issues: unknown[], + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + details: { issues }, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), apiError: (error: string, status: number) => ({ data: null, error, status }), safeJson: async (req: NextRequest) => { diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index ffc7e9183..26448bbc9 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -2,7 +2,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; import { markDisconnected } from "@/lib/integrations/status"; @@ -45,7 +50,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { const result = z.safeParse(whoopCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/app/api/withings/credentials/route.ts b/src/app/api/withings/credentials/route.ts index 3829ed3d6..56e1f83e8 100644 --- a/src/app/api/withings/credentials/route.ts +++ b/src/app/api/withings/credentials/route.ts @@ -1,6 +1,11 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { + apiSuccess, + apiValidationError, + safeJson, + sanitiseZodIssues, +} from "@/lib/api-response"; import { isP2025 } from "@/lib/prisma-errors"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; @@ -46,7 +51,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { if (jsonError) return jsonError; const result = z.safeParse(withingsCredentialsSchema, body); if (!result.success) { - return apiError("Client ID and Client Secret are required", 422); + return apiValidationError( + "Client ID and Client Secret are required", + sanitiseZodIssues(result.error.issues), + 422, + ); } await prisma.user.update({ diff --git a/src/lib/medications/import/auto-export-parse.ts b/src/lib/medications/import/auto-export-parse.ts index 357d9ac76..ca146e1e9 100644 --- a/src/lib/medications/import/auto-export-parse.ts +++ b/src/lib/medications/import/auto-export-parse.ts @@ -92,8 +92,14 @@ export interface AutoExportRefusal { reason: AutoExportRefusalReason; } -/** A file-level problem: nothing can be read at all. */ -export const AUTO_EXPORT_FATAL_REASONS = [ +/** + * A file-level problem: nothing can be read at all. + * + * These values ship as `meta.errorCode` on the import route's 422, which is + * why the constant is named for what they are on the wire rather than for the + * field that carries them internally. + */ +export const AUTO_EXPORT_FATAL_ERROR_CODES = [ "empty_file", "missing_required_columns", "unreadable_json", @@ -102,10 +108,11 @@ export const AUTO_EXPORT_FATAL_REASONS = [ "json_carries_no_intake_time", ] as const; -export type AutoExportFatalReason = (typeof AUTO_EXPORT_FATAL_REASONS)[number]; +export type AutoExportFatalErrorCode = + (typeof AUTO_EXPORT_FATAL_ERROR_CODES)[number]; export interface AutoExportParseOutcome { - fatal?: { reason: AutoExportFatalReason; detail?: string }; + fatal?: { reason: AutoExportFatalErrorCode; detail?: string }; doses: AutoExportDose[]; refusals: AutoExportRefusal[]; /** Data rows read, refusals included. `doses + refusals` must equal it. */ diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts new file mode 100644 index 000000000..5f8c6037b --- /dev/null +++ b/src/lib/openapi/error-codes.ts @@ -0,0 +1,349 @@ +/** + * Every `meta.errorCode` the API emits, grouped by the surface that emits it. + * + * `meta.errorCode` is the mechanism this API chose for machine-readable errors + * — the envelope's own description tells a client to branch on it rather than + * on the prose — and until this file existed a client could not enumerate it. + * Sixteen of the two hundred and forty-two codes appeared somewhere in + * `docs/api/openapi.yaml`; the rest existed only in server source, so the only + * way to learn one was to trigger the error in testing and the only way to + * learn a new one had appeared was to trigger that too. + * + * The list is generated from the code and then committed as a literal, so it is + * reviewable in a diff and stable across a release. `error-code-catalogue.test.ts` + * re-derives it from the tree on every run and fails in both directions: a code + * emitted and not listed, and a code listed and no longer emitted. + * + * Naming: four conventions coexist here — dot.case (the majority and the one + * to use), bare snake_case, a camelCase segment, and a hyphen. Nothing is + * renamed: a code is a wire value and a shipped client branches on it. New + * codes are dot.case. + * + * Not in this list, and deliberately: + * + * - `assistant.disabled.` is built from a template at runtime + * (`src/lib/feature-flags/index.ts`), so the surface segment is open. + * `assistant.disabled.coach` is the one the iOS contract names. + * - The integration-probe classes — `credentials_rejected`, `rate_limited`, + * `upstream_error`, `timeout`, `connection_failed`, and the per-provider + * additions beside them — are chosen by each `/test` route's own + * categoriser and are already enumerated in those operations' + * descriptions, where the per-provider differences can be stated. + * - `IntegrationStatus.errorCode` is a different field with the same name: it + * records an upstream HTTP status in the sync ledger and never reaches a + * response envelope. + */ +export const ERROR_CODE_CATALOGUE: Readonly> = + { + ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"], + allergy: ["allergy.invalid"], + analytics: ["analytics.invalid_query"], + anamnesis: [ + "anamnesis.fact.conflict", + "anamnesis.fact.currentExists", + "anamnesis.fact.invalidValue", + ], + auth: [ + "auth.admin.required", + "auth.mfa.code_invalid", + "auth.missing", + "auth.refresh.invalid", + "auth.refresh.reuse", + "auth.refresh.revoked", + "auth.scope.insufficient", + "auth.stepup.required", + "auth.token.expired", + "auth.token.invalid", + ], + backup: ["backup.payload.undecryptable", "backup.section.missing"], + coach: [ + "coach.conversation.invalidTitle", + "coach.conversation.notFound", + "coach.fenced.attachmentConflict", + "coach.fenced.attachmentLimit", + "coach.fenced.attachmentNotFound", + "coach.fenced.attachmentNotIndexed", + "coach.fenced.attachmentRequired", + "coach.fenced.attachmentUnavailable", + "coach.fenced.invalid", + "coach.request.invalid", + ], + "coach-prefs": [ + "coach-prefs.body.invalid_json", + "coach-prefs.body.invalid_shape", + ], + consent: ["consent.ai.required"], + cycle: [ + "cycle.bulk.invalid", + "cycle.bulk.too_large", + "cycle.calendar.invalid", + "cycle.calendar.range", + "cycle.cycles.invalid", + "cycle.day-log.conflict", + "cycle.day-log.invalid", + "cycle.disabled", + "cycle.period.invalid", + "cycle.symptom.custom.invalid", + "cycle.symptom.custom.limit", + ], + "cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"], + "disable-coach": ["disable-coach.body.invalid_json"], + documents: [ + "documents.chat.conversationNotFound", + "documents.chat.invalid", + "documents.inbound.alreadyConfirmed", + "documents.inbound.alreadyPartlyConfirmed", + "documents.inbound.budgetExceeded", + "documents.inbound.decryptFailed", + "documents.inbound.encounterNotFound", + "documents.inbound.episodeNotFound", + "documents.inbound.extractFailed", + "documents.inbound.factNotFound", + "documents.inbound.factNotPending", + "documents.inbound.factTypeMismatch", + "documents.inbound.fileTooLarge", + "documents.inbound.fileType", + "documents.inbound.invalidMetadata", + "documents.inbound.invalidQuery", + "documents.inbound.invalidUpdate", + "documents.inbound.localOcrDisabled", + "documents.inbound.notFound", + "documents.inbound.notIndexed", + "documents.inbound.pdfNeedsAnthropic", + "documents.inbound.providerUnsupported", + "documents.inbound.quotaExceeded", + "documents.inbound.rateLimited", + "documents.inbound.restoreDuplicate", + "documents.inbound.restoreGone", + "documents.inbound.uploadBusy", + "documents.inbound.uploadTimeout", + ], + encounter: [ + "encounter.invalid", + "encounter.link.invalid", + "encounter.practitioner-not-found", + "encounter.reminder-conflict", + "encounter.reminder-not-found", + ], + environment: [ + "environment.invalid", + "environment.no_home", + "environment.range_too_large", + "environment.travel.not_found", + ], + export: ["export.selection.empty", "export.selection.unknown_leaf"], + familyHistory: ["familyHistory.invalid"], + feedback: ["feedback.body.invalid"], + health_score_config: [ + "health_score_config.invalid", + "health_score_config.too_narrow", + ], + illness: [ + "illness.day-log.invalid", + "illness.disabled", + "illness.episode.chronic-no-resolve", + "illness.episode.invalid", + "illness.episode.invalid-window", + "illness.episode.parent-not-found", + "illness.insights.invalid", + ], + import: ["import.write_failed"], + ingest: ["ingest.rate_limited"], + insights: [ + "insights.generate.budgetExceeded", + "insights.generate.outboundScreened", + "insights.generate.profileScopeChanged", + "insights.generate.scopeChanged", + ], + labs: [ + "labs.biomarker.create.invalid", + "labs.biomarker.duplicate", + "labs.biomarker.notFound", + "labs.biomarker.update.invalid", + "labs.biomarker.update.referenceRangeInvalid", + "labs.create.invalid", + "labs.ocr.budgetExceeded", + "labs.ocr.extractFailed", + "labs.ocr.fileTooLarge", + "labs.ocr.fileType", + "labs.ocr.localOcrDisabled", + "labs.ocr.pdfNeedsAnthropic", + "labs.ocr.providerUnsupported", + "labs.ocr.rateLimited", + "labs.restore.invalid", + "labs.result.notFound", + "labs.update.invalid", + "labs.update.linkedFieldsImmutable", + "labs.update.numericExpected", + "labs.update.qualitativeExpected", + "labs.update.referenceRangeInvalid", + "labs.update.sourceReferenceRangeInvalid", + ], + managed_profile: [ + "managed_profile.guardian.duplicate", + "managed_profile.guardian.managed_invitee", + "managed_profile.guardian.required", + "managed_profile.not_found", + ], + measurement: [ + "measurement.batch.source_not_permitted", + "measurement.batch.too_large", + "measurement.bulk-delete.invalid", + "measurement.create.invalid", + "measurement.create.source_not_permitted", + "measurement.delete.too_large", + "measurement.duplicate_timestamp", + "measurement.restore.invalid", + "measurement.update.server_owned_source", + ], + medication: ["medication.intake.import.invalid_format"], + medications: [ + "medications.intake.bulk.apple_health_not_mirrored", + "medications.intake.bulk.invalid", + "medications.intake.bulk.too_large", + "medications.intake.force_slot.invalid", + "medications.intake.force_slot.occupied", + "medications.intake.injection_site.disallowed", + "medications.intake.taken_at.before_start", + "medications.mirror.limit_exceeded", + ], + mentalHealth: ["mentalHealth.rateLimited"], + module: ["module.disabled"], + modules: ["modules.body.invalid_json", "modules.invalid"], + mood: [ + "mood.bulk-delete.invalid", + "mood.bulk.invalid", + "mood.bulk.too_large", + "mood.create.external_id_not_delegable", + "mood.create.invalid", + "mood.duplicate_timestamp", + "mood.linkedContext.invalid", + "mood.list.invalid", + "mood.not_found", + "mood.ratedFactor.out_of_range", + "mood.restore.invalid", + "mood.update.invalid", + ], + "notification-prefs": ["notification-prefs.body.invalid_json"], + nutrient: [ + "nutrient.batch.invalid", + "nutrient.batch.too_large", + "nutrient.daily.invalid", + "nutrient.read.invalid", + "nutrient.water.invalid", + "nutrient.water.invalid_day", + ], + onboarding: [ + "onboarding.complete.invalid", + "onboarding.disclaimer.invalid", + "onboarding.step.completed", + "onboarding.step.concurrent", + "onboarding.step.invalid", + "onboarding.step.outOfOrder", + "onboarding.step.rateLimited", + "onboarding.tour.invalid", + "onboarding.user.notFound", + ], + personal_records: ["personal_records.invalid_query"], + practitioner: ["practitioner.invalid"], + profile: [ + "profile.update.emailInUse", + "profile.update.invalidBody", + "profile.update.nothingSaved", + ], + record_write: ["record_write.rate_limited"], + "report-selection": [ + "report-selection.body.invalid_json", + "report-selection.body.invalid_shape", + "report-selection.leaves.unknown", + ], + route: ["route.retired"], + "share-link": [ + "share-link.document.decryptFailed", + "share-link.document.notFound", + "share-link.document.rateLimited", + "share-link.documents.invalid", + "share-link.report.notFound", + "share-link.report.rateLimited", + "share-link.selection.forbidden_leaf", + "share-link.selection.unknown_leaf", + ], + sharing: [ + "sharing.accept.expired", + "sharing.accept.not_found", + "sharing.accept.not_pending", + "sharing.accept.revoked", + "sharing.access.denied", + "sharing.invite.duplicate", + "sharing.invite.invalid", + "sharing.invite.invalid_scope", + "sharing.invite.manage_browser_only", + "sharing.invite.refused", + "sharing.invite.self", + "sharing.not_permitted", + "sharing.renounce.already_ended", + "sharing.renounce.not_found", + "sharing.revoke.already_ended", + "sharing.revoke.not_found", + "sharing.session.changed", + "sharing.switch.invalid", + "sharing.switch.wrong_transport", + ], + "source-priority": [ + "source-priority.body.invalid_json", + "source-priority.body.invalid_shape", + ], + tokens: ["tokens.measurements.ceiling_reached"], + vaccination: [ + "vaccination.booster-invalid", + "vaccination.booster-no-antigen", + "vaccination.encounter-not-found", + "vaccination.identity-required", + "vaccination.invalid", + "vaccination.link.invalid", + "vaccination.not-found", + "vaccination.practitioner-not-found", + ], + workout: [ + "workout.batch.invalid", + "workout.batch.payload_too_large", + "workout.batch.too_large", + ], + "(unprefixed)": [ + "about_me_conflict", + "ai_response_truncated", + "coach_prefs_conflict", + "dashboard_layout_conflict", + "empty_file", + "health_score_config_conflict", + "insights_layout_conflict", + "invalid_base_updated_at", + "json_carries_no_intake_time", + "json_not_an_array", + "medication_layout_conflict", + "missing_required_columns", + "modules_conflict", + "mood_tag_layout_conflict", + "not_configured", + "notification_prefs_conflict", + "oidc_only", + "private_origin_not_approved", + "rate_limited_self", + "too_many_rows", + "unreadable_json", + "upstream_invalid_json", + "vapid_not_configured", + ], + } as const; + +/** + * The catalogue as one sentence for the published `meta.errorCode` description. + * + * Rendered rather than hand-written so the document cannot drift from the list + * the guard checks: there is one source, and it is the object above. + */ +export function renderErrorCodeCatalogue(): string { + return Object.entries(ERROR_CODE_CATALOGUE) + .map(([surface, codes]) => `${surface} — ${codes.join(", ")}`) + .join("; "); +} 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 e32173f30..f251500e6 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -33,7 +33,9 @@ import { errorEnvelope, idempotencyKeyParameter, idempotentWrite, + malformedJsonResponse, recordRefusal, + recordWriteRateLimitResponse, stdResponses, } from "./shared"; @@ -515,6 +517,7 @@ export const cyclePaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Cycle"], summary: "Create a custom cycle symptom (v1.15.1)", description: @@ -526,6 +529,7 @@ export const cyclePaths: NonNullable = { }, }, responses: { + ...idempotentWrite(), "201": { description: "The created custom symptom.", content: { @@ -707,6 +711,7 @@ export const cyclePaths: NonNullable = { }, ...cycleDisabledOnADelegableRoute, ...stdResponses, + ...recordWriteRateLimitResponse, }, }, }, @@ -991,6 +996,7 @@ export const cyclePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, 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 bdf31f54c..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, }, }, }, @@ -321,6 +322,7 @@ export const medicationPaths: NonNullable = { }, }, post: { + parameters: [idempotencyKeyParameter], tags: ["Medications"], summary: "Record a titration step or a legacy stock correction", description: @@ -331,6 +333,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: glp1PostBodySchema } }, }, responses: { + ...idempotentWrite(), ...recordRefusal(), "201": { description: @@ -1151,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/profile.ts b/src/lib/openapi/routes/profile.ts index 36c2de803..2ce21fc07 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -38,16 +38,17 @@ import { healthProfileFactKindSchema, } from "@/lib/validations/health-profile-facts"; import { - coachPrefsSchema, - dataEnvelope, - errorEnvelope, - stdResponses, baseUpdatedAtField, - updatedAtTokenField, + coachPrefsSchema, conflictResponse409, - invalidBaseTokenResponse, + dataEnvelope, deviceRevokeResponse, + errorEnvelope, + invalidBaseTokenResponse, + malformedJsonResponse, recordRefusal, + stdResponses, + updatedAtTokenField, } from "./shared"; // v1.18.0 — module enable/disable. The PATCH request is the REAL runtime @@ -1063,6 +1064,7 @@ export const profilePaths: NonNullable = { }, ...conflictResponse409("Coach preferences", "coach_prefs_conflict"), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1108,6 +1110,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, @@ -1263,6 +1266,7 @@ export const profilePaths: NonNullable = { }, ...conflictResponse409("Module preferences", "modules_conflict"), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1310,6 +1314,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, @@ -1505,6 +1510,7 @@ export const profilePaths: NonNullable = { "notification_prefs_conflict", ), ...stdResponses, + ...malformedJsonResponse, ...invalidBaseTokenResponse, }, }, @@ -1534,7 +1540,7 @@ export const profilePaths: NonNullable = { tags: ["Auth"], summary: "Replace the owner's saved doctor-report selection", description: - "Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 that names it. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.", + "Replace, not merge — the body states the whole scope or it is not a scope; a leaf omitted from the body is a leaf removed from the saved profile. An unknown leaf id is refused with a 422 carrying `meta.errorCode` `report-selection.leaves.unknown` and `meta.unknownLeaves`, the ids this build does not know. The saved selection is also what the FHIR REST face and the MCP doctor-visit surfaces replay for a caller that cannot ask a human, so every write is audit-logged. Auth via cookie or Bearer.", requestBody: { required: true, content: { "application/json": { schema: savedReportProfile } }, @@ -1552,6 +1558,7 @@ export const profilePaths: NonNullable = { }, }, ...stdResponses, + ...malformedJsonResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 43b625a9e..09f524625 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -6,6 +6,8 @@ * runtime request parsing, so the wire contract stays single-source. */ import { z } from "zod/v4"; + +import { renderErrorCodeCatalogue } from "../error-codes"; import { createMeasurementSchema as createMeasurementSchemaBase, listMeasurementsSchema as listMeasurementsSchemaBase, @@ -82,7 +84,8 @@ export const errorEnvelope = z .string() .optional() .describe( - "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded.", + "Stable machine code for this refusal. Branch on it rather than on `error`, which is prose and may be reworded. Every code the API emits today is enumerated below, grouped by the surface that emits it; four naming conventions coexist and none of them will be renamed, because a code is a wire value a shipped client branches on. Treat an unlisted code the way you would treat an unlisted enum member — as a refusal you do not recognise, not as a malformed response — since the list grows with the surfaces. Three families are outside it on purpose: `assistant.disabled.` is built from a template so the last segment is open (`assistant.disabled.coach` is the one the native client names); the integration-probe classes (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed` and the per-provider additions) are enumerated in each `/test` operation instead, where the differences can be stated; and a 401 raised by a route checking a credential of its own may carry no code at all. " + + renderErrorCodeCatalogue(), ), }) .optional(), @@ -225,6 +228,29 @@ export const invalidBaseTokenResponse = { }, }; +/** + * The 400 an unparseable JSON body earns. + * + * `safeJson` has answered 400 for a body that will not parse since it was + * written, and roughly two hundred and twenty routes go through it — but seven + * `/api/auth/me/*` writes hand-rolled the parse and answered 422, so a client's + * "my serializer produced garbage" branch had to accept two statuses on a + * subset of routes it could not predict. They answer 400 now, and this is the + * response that says so. The dotted token each route already carried moved to + * `meta.errorCode`, where a machine code belongs. + * + * Spread only on the operations that hand-rolled the parse. It is deliberately + * NOT folded into `stdResponses`: that set is spread onto reads as well, and a + * GET that accepts no body has no malformed body to refuse. + */ +export const malformedJsonResponse = { + "400": { + 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.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + // ── Standard 401 / 422 / 429 responses ─────────────────────────────── export const stdResponses = { @@ -266,6 +292,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. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. 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. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. 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..d6cc7b624 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -117,6 +117,49 @@ 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. Ten of the eleven are wrapped in `withIdempotency`, so an honest + * retry storm is deduplicated before it is counted; `POST + * /api/medications/intake` is not, because it updates a named `intakeId` + * rather than creating a row, so a replay writes the status that is already + * there. It exists to cut off the pathological loop, not to shape traffic. + * + * A refused request is counted too: the check runs before the body is read, + * so a client looping on a 422 spends its allowance without writing a row. + * + * 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 diff --git a/tests/integration/medication-write-idempotency.test.ts b/tests/integration/medication-write-idempotency.test.ts index 9f3277c4a..b83a9d138 100644 --- a/tests/integration/medication-write-idempotency.test.ts +++ b/tests/integration/medication-write-idempotency.test.ts @@ -1,5 +1,5 @@ /** - * The three medication create routes the native client replays out of its + * The four medication create routes the native client replays out of its * offline outbox, driven as routes against a real Postgres. * * Each case imports the shipped `POST` export and calls it twice with the same @@ -10,8 +10,8 @@ * keep passing after a route dropped it. * * The client sends the header on every replay, so a lost success response used - * to cost the user a duplicate medication, a duplicate symptom entry, or a - * duplicate pen in the supply count. `POST /api/medications` had a dedupe of + * to cost the user a duplicate medication, a duplicate symptom entry, a + * duplicate pen in the supply count, or a second titration step. `POST /api/medications` had a dedupe of * its own, but only for a MIRRORED create keyed on * `(externalSource, externalId)`; a manually entered medication carries * neither field and had nothing to collapse a retry onto — so the manual shape @@ -228,3 +228,41 @@ describe("POST /api/medications/[id]/inventory — replayed container", () => { expect(rows).toHaveLength(1); }); }); + +describe("POST /api/medications/[id]/glp1 — replayed titration step", () => { + it("replays the first response and writes exactly one dose change", async () => { + const medicationId = await seedMedication(); + const { POST } = await import("@/app/api/medications/[id]/glp1/route"); + const body = { + doseChange: { + effectiveFrom: "2026-09-01T00:00:00Z", + doseValue: 2.5, + doseUnit: "mg", + note: "stepped up after four weeks", + }, + }; + + const first = await (POST as Handler)( + post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(first.status).toBe(201); + const firstBody = await first.json(); + + const replay = await (POST as Handler)( + post(`/api/medications/${medicationId}/glp1`, body, "outbox-glp1-1"), + { params: Promise.resolve({ id: medicationId }) }, + ); + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + + // The titration row carries no natural key at all, so without the wrapper + // the replay would leave the medication with two dose changes on the same + // day and the runway estimate reading off the wrong one. + const rows = await getPrismaClient().medicationDoseChange.findMany({ + where: { medicationId }, + }); + expect(rows).toHaveLength(1); + }); +});