diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f761f6f77..412ccaab0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -75,7 +75,20 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": &a2 - description: Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). + description: >- + Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD + being read, not the caller: a delegate inside somebody else's record sees the owner's setting. + + + 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 response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -565,7 +578,13 @@ paths: schema: $ref: "#/components/schemas/CycleBulkEnvelope" "401": *a1 - "403": *a2 + "403": &a5 + description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD + being read, not the caller: a delegate inside somebody else's record sees the owner's setting." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/cycle/period: @@ -727,7 +746,7 @@ paths: schema: $ref: "#/components/schemas/CyclePurgeEnvelope" "401": *a1 - "403": *a2 + "403": *a5 "422": *a3 "429": *a4 /api/cycle/profile: @@ -961,6 +980,20 @@ paths: type: string format: binary "401": *a1 + "403": &a6 + 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 + response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record + owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers + one further case: a session that has entered a shared record and sends a request without its record-context + assertion, which is a client older than the fence — leave the record and reload. A request that names no + record but the caller's own never reaches this response, so a client that never switches and never sends the + per-request account selector will not see it." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/share-links: @@ -2466,6 +2499,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurements/series: @@ -2517,6 +2551,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurements: @@ -2713,6 +2748,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -2970,6 +3006,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 + "403": *a6 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. @@ -2999,6 +3036,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 + "403": *a6 "404": description: Measurement not found (or owned by another user). content: @@ -3046,6 +3084,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 + "403": *a6 "404": description: Measurement not found (or owned by another user). content: @@ -3081,6 +3120,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 + "403": *a6 "404": description: Measurement not found (or owned by another user). content: @@ -3162,6 +3202,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurements/restore: @@ -3187,6 +3228,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/sleep/night: @@ -3216,10 +3258,22 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a5 - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = + "403": &a7 + 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.' + valid Bearer token. Clients hide the whole module surface end-to-end rather than retry. + + + 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 response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -3245,7 +3299,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/workouts: @@ -3294,7 +3348,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/workouts/{id}: @@ -3330,7 +3384,14 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": *a5 + "403": &a10 + 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.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": description: Workout not found (or owned by another user). content: @@ -3624,6 +3685,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -3652,6 +3714,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/medications/layout: @@ -3670,6 +3733,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 put: @@ -3703,7 +3767,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a6 + "422": &a8 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. @@ -3748,6 +3812,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3784,6 +3849,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3855,6 +3921,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3887,6 +3954,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3921,6 +3989,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3963,6 +4032,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 + "403": *a6 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -3997,6 +4067,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 + "403": *a6 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -4074,6 +4145,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -4101,6 +4173,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/medications/{id}/compliance: @@ -4364,6 +4437,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -4401,6 +4475,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -4444,6 +4519,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 + "403": *a6 "404": description: Medication or revision not found (or owned by another user). content: @@ -4484,6 +4560,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 + "403": *a6 "404": description: Medication or revision not found (or owned by another user). content: @@ -4546,6 +4623,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 + "403": *a6 "409": description: A dose-history import is already in progress. content: @@ -4607,6 +4685,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 + "403": *a6 "404": description: No such job for this medication (or owned by another user). content: @@ -4640,6 +4719,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 + "403": *a6 "404": description: No such job for this account (or owned by another user). content: @@ -4713,7 +4793,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/auth/me/disable-coach: get: @@ -4911,7 +4991,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/auth/me/source-priority: get: @@ -5937,7 +6017,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/auth/me/report-selection: get: @@ -6381,7 +6461,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 delete: tags: @@ -6470,6 +6550,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/coach/seen: @@ -6584,7 +6665,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/anamnesis/facts: get: @@ -6602,6 +6683,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -6709,7 +6791,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a7 + "404": &a9 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -6745,7 +6827,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a7 + "404": *a9 "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. @@ -7027,7 +7109,19 @@ paths: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 "403": - description: Coach surface disabled. + description: >- + Coach surface disabled. + + + 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 response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -7337,6 +7431,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/comprehensive: @@ -7356,8 +7451,20 @@ paths: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 "403": - description: "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = - `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying." + description: >- + AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = + `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying. + + + 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 response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -7382,7 +7489,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/insights/pregenerate: @@ -7557,6 +7664,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/pulse-status: @@ -7585,6 +7693,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/weight-status: @@ -7613,6 +7722,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/bmi-status: @@ -7641,6 +7751,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/mood-status: @@ -7669,6 +7780,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/medication-compliance-status: @@ -7697,6 +7809,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/metric-status: @@ -7788,6 +7901,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/biomarker-assessment: @@ -7829,6 +7943,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/derived: @@ -7911,6 +8026,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/derived/batch: @@ -7949,6 +8065,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/glp1-plateau: @@ -7968,6 +8085,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/ecg: @@ -7990,7 +8108,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 post: @@ -8028,7 +8146,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a5 + "403": *a10 "422": *a3 "429": *a4 /api/insights/ecg/{id}: @@ -8068,7 +8186,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -8100,7 +8218,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/insights/correlations: @@ -8121,6 +8239,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/patterns: @@ -8138,7 +8257,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/insights/patterns/{id}: @@ -8168,7 +8287,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "404": description: No current pattern with that id for this account. content: @@ -8193,6 +8312,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/breathing-screening: @@ -8211,6 +8331,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/labs-changes: @@ -8229,6 +8350,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/mood-entries/bulk: @@ -8365,7 +8487,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a8 + "404": &a11 description: Not found / not owned by the caller. content: application/json: @@ -8400,7 +8522,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 /api/mood/tags/{key}/hidden: @@ -8441,7 +8563,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 /api/mood/tags/groups: @@ -8516,7 +8638,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 delete: @@ -8547,7 +8669,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 /api/mood/tags/layout: @@ -8595,7 +8717,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/settings/reminder-thresholds: get: @@ -8613,6 +8735,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/consent/ai: @@ -8747,6 +8870,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -8769,6 +8893,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurement-reminders/{id}: @@ -8791,7 +8916,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "404": &a9 + "403": *a6 + "404": &a12 description: Measurement reminder not found / not owned. content: application/json: @@ -8825,7 +8951,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 delete: @@ -8847,7 +8974,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -8871,7 +8999,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/complete: @@ -8898,7 +9027,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 /api/labs: @@ -8970,6 +9100,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -9041,6 +9172,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/labs/{id}: @@ -9063,7 +9195,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "404": &a10 + "403": *a6 + "404": &a13 description: Lab result not found (or owned by another user). content: application/json: @@ -9141,7 +9274,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "404": *a10 + "403": *a6 + "404": *a13 "422": *a3 "429": *a4 delete: @@ -9163,7 +9297,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "404": *a10 + "403": *a6 + "404": *a13 "422": *a3 "429": *a4 /api/labs/restore: @@ -9187,6 +9322,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/labs/ocr/capability: @@ -9332,6 +9468,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -9380,7 +9517,8 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "409": &a12 + "403": *a6 + "409": &a15 description: A biomarker with this name already exists for the caller. content: application/json: @@ -9408,7 +9546,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "404": &a11 + "403": *a6 + "404": &a14 description: Biomarker not found (or owned by another user). content: application/json: @@ -9475,8 +9614,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "404": *a11 - "409": *a12 + "403": *a6 + "404": *a14 + "409": *a15 "422": *a3 "429": *a4 delete: @@ -9499,7 +9639,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a11 + "404": *a14 "422": *a3 "429": *a4 /api/illness/episodes: @@ -9532,6 +9672,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -9602,6 +9743,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/illness/episodes/{id}: @@ -9624,7 +9766,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "404": &a13 + "403": *a6 + "404": &a16 description: Illness episode not found (or owned by another user). content: application/json: @@ -9702,7 +9845,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 delete: @@ -9725,7 +9869,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -9751,7 +9896,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -9787,7 +9933,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -9837,7 +9984,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 post: @@ -9916,7 +10064,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -9943,7 +10092,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a3 "429": *a4 /api/illness/insights: @@ -10016,6 +10165,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10082,6 +10232,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/allergies: @@ -10114,6 +10265,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10189,6 +10341,7 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/allergies/{id}: @@ -10211,7 +10364,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "404": &a14 + "403": *a6 + "404": &a17 description: Allergy not found (or owned by another user). content: application/json: @@ -10293,7 +10447,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "404": *a14 + "403": *a6 + "404": *a17 "422": *a3 "429": *a4 delete: @@ -10316,7 +10471,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "404": *a14 + "403": *a6 + "404": *a17 "422": *a3 "429": *a4 /api/family-history: @@ -10340,6 +10496,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10399,6 +10556,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/family-history/{id}: @@ -10421,7 +10579,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "404": &a15 + "403": *a6 + "404": &a18 description: Family history entry not found (or owned by another user). content: application/json: @@ -10489,7 +10648,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "404": *a15 + "403": *a6 + "404": *a18 "422": *a3 "429": *a4 delete: @@ -10512,7 +10672,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "404": *a15 + "403": *a6 + "404": *a18 "422": *a3 "429": *a4 /api/documents/inbound: @@ -10619,6 +10780,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10744,6 +10906,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/documents/inbound/bulk: @@ -10826,6 +10989,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 patch: @@ -10974,6 +11138,7 @@ paths: type: string format: binary "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/documents/inbound/{id}/thumbnail: @@ -11002,6 +11167,7 @@ paths: type: string format: binary "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/documents/inbound/{id}/extract: @@ -11573,6 +11739,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -11621,7 +11788,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a17 + "409": &a20 description: A custom metric with this name already exists for the caller. content: application/json: @@ -11649,7 +11816,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "404": &a16 + "403": *a6 + "404": &a19 description: Custom metric not found (or owned by another user). content: application/json: @@ -11713,8 +11881,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a16 - "409": *a17 + "404": *a19 + "409": *a20 "422": *a3 "429": *a4 delete: @@ -11737,7 +11905,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a16 + "404": *a19 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -11780,7 +11948,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "404": *a16 + "403": *a6 + "404": *a19 "422": *a3 "429": *a4 post: @@ -11824,7 +11993,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a16 + "404": *a19 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -11874,7 +12043,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a18 + "404": &a21 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -11906,7 +12075,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a18 + "404": *a21 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -12042,7 +12211,7 @@ paths: schema: $ref: "#/components/schemas/NutrientBatchEnvelope" "401": *a1 - "403": &a19 + "403": 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 on. @@ -12076,7 +12245,26 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a19 + "403": &a22 + 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 + on. + + + 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 response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/nutrients/daily: @@ -12112,7 +12300,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a19 + "403": *a22 "422": *a3 "429": *a4 /api/nutrients/water: @@ -12139,7 +12327,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a19 + "403": *a22 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -12327,7 +12515,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/daily/digest/dismiss: @@ -12363,7 +12551,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a5 + "403": *a10 "422": *a3 "429": *a4 /api/dashboard/widgets: @@ -12381,6 +12569,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 put: @@ -12414,7 +12603,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 delete: tags: @@ -12476,7 +12665,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a20 + "403": &a23 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 @@ -12501,7 +12690,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "422": *a3 "429": *a4 post: @@ -12647,7 +12836,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such invitation for this account. content: @@ -12691,7 +12880,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such grant on this account's record. content: @@ -12728,7 +12917,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such grant held by this account. content: @@ -13082,7 +13271,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "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 — diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts new file mode 100644 index 000000000..de01b7039 --- /dev/null +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -0,0 +1,268 @@ +/** + * v1.37.0 — one refusal, on every path a delegate can reach. + * + * A route that resolves through `requireRecordAuth` / `requireGuardianAuth` can + * be asked to act on somebody else's record, and can therefore answer 403 + * `sharing.access.denied`. Before this file that fact was published once, on the + * per-request selector parameter, and on none of the hundred and twenty + * operations that produce it: a client reading the spec for `/api/measurements` + * learned that the header existed somewhere and nothing about what the read + * does when the grant behind it ends. This guard closes the pair — the set of + * fenced operations is recomputed from the route sources on every run, and + * every member has to publish the same sentence, byte for byte. + * + * ## Byte-identical, and why it is the point + * + * The refusal carries no reason on the wire. A selector naming an account that + * does not exist, one naming an account that granted nothing, a grant whose + * sections do not reach the surface, and a read grant on a write — all four are + * the same status, the same code and the same bytes, because a distinguishable + * refusal is an account-enumeration oracle for anyone with a login. A contract + * that described them differently per path would invite a client to tell them + * apart, and the first client that tried would be reading tea leaves. So the + * description is ONE string, spliced into every fenced operation, and a + * paraphrase on one path fails here. + * + * ## What this guard deliberately does not do + * + * - It classifies per VERB, not per file, because the two disagree on + * seventeen route modules: `GET /api/dashboard/widgets` is delegable and its + * `PUT` is not. The matcher takes each handler's own top-level declaration + * and follows every declaration in the same module it names, transitively — + * which is how `POST /api/custom-metrics` (body in `postCustomMetric`) and + * `POST /api/cycle/day-logs` (body in `postDayLog`, wrapped in + * `withIdempotency` and declared BELOW the `GET`) are classified correctly. A + * first draft that segmented on export boundaries and matched call sites got + * both of those wrong, in both directions. + * - It resolves within the module only. A handler whose fence lived in an + * imported helper would read as unfenced; `sharing-surface-guard.test.ts` is + * what holds the declaration to the route module itself. + * - It says nothing about the verbs that are NOT fenced. Those refuse a + * switched caller with `sharing.not_permitted` — a different fact, published + * today only where the sharing family itself documents it. Naming it on every + * authenticated path in the application is a larger contract change than a + * freeze should make. + * - Comments are stripped before matching. `.../guardians/route.ts` names both + * fence helpers in a docblock explaining why it uses neither, and counting + * prose would classify an actor surface as delegable. + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { openApiPaths } from "@/lib/openapi/routes"; +import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared"; + +const ROOT = process.cwd(); +const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** A published operation, resolved to the module that serves it. */ +interface PublishedOperation { + path: string; + method: HttpMethod; + /** The operation object as the route table declares it. */ + operation: Record; + /** The handler's own declaration plus every module-local one it names. */ + handlerSource: string; +} + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); +} + +function routeModulePath(apiPath: string): string { + return join( + ROOT, + "src/app", + apiPath.replace(/\{([^}]+)\}/g, "[$1]"), + "route.ts", + ); +} + +/** + * Every top-level declaration in a module, by name. + * + * The unit is the declaration and not the line range between two exports, + * because a route module puts helpers wherever it likes: `cycle/day-logs` + * declares `postDayLog` BELOW its `GET`, so a naive "from this export to the + * next" slice hands GET a body that belongs to POST. + */ +function topLevelChunks(source: string): Map { + const declaration = + /^(?:export\s+)?(?:async\s+function|function|const|let|class)\s+([A-Za-z0-9_$]+)/gm; + const found = [...source.matchAll(declaration)]; + const chunks = new Map(); + found.forEach((match, index) => { + const from = match.index ?? 0; + const to = found[index + 1]?.index ?? source.length; + chunks.set(match[1], source.slice(from, to)); + }); + return chunks; +} + +/** + * The text a handler can reach: its own declaration plus every top-level + * declaration in the same module it names, transitively. + * + * Both indirections in the tree need this. `apiHandler(postCustomMetric)` + * names its body as a bare reference, and + * `apiHandler(withIdempotency<[NextRequest]>(postDayLog))` names it as an + * argument to a wrapper — neither is a call site, so a matcher keyed on + * `name(` finds nothing and the handler reads as unfenced. It resolves within + * the module only: a fence resolved in an imported helper would be missed, and + * `sharing-surface-guard.test.ts` is what holds the declaration to the route + * module. + */ +function handlerText(source: string, method: HttpMethod): string { + const chunks = topLevelChunks(source); + const own = chunks.get(method.toUpperCase()); + if (!own) return ""; + + const seen = new Set([method.toUpperCase()]); + let text = own; + for (let depth = 0; depth < 5; depth += 1) { + const named = [...text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)].map( + (match) => match[1], + ); + const next = named.filter((name) => chunks.has(name) && !seen.has(name)); + if (next.length === 0) break; + for (const name of next) { + seen.add(name); + text += chunks.get(name); + } + } + return text; +} + +function publishedOperations(): PublishedOperation[] { + const operations: PublishedOperation[] = []; + const missing: string[] = []; + for (const [path, item] of Object.entries(openApiPaths)) { + const modulePath = routeModulePath(path); + if (!existsSync(modulePath)) { + missing.push(path); + continue; + } + const source = stripComments(readFileSync(modulePath, "utf8")); + for (const method of HTTP_METHODS) { + const operation = (item as Record)[method]; + if (!operation || typeof operation !== "object") continue; + operations.push({ + path, + method, + operation: operation as Record, + handlerSource: handlerText(source, method), + }); + } + } + // A published path with no route module would silently drop out of every + // assertion below, so it fails here instead. + expect(missing, "published paths with no route module").toEqual([]); + return operations; +} + +/** Does this handler resolve the record fence? */ +function isFenced(operation: PublishedOperation): boolean { + return /require(?:Record|Guardian)Auth\s*\(/.test(operation.handlerSource); +} + +function refusal( + operation: PublishedOperation, +): { description?: string; content?: unknown } | null { + const responses = operation.operation.responses as + Record | undefined; + return responses?.["403"] ?? null; +} + +const operations = publishedOperations(); +const fenced = operations.filter(isFenced); +const unfenced = operations.filter((operation) => !isFenced(operation)); +const name = (operation: PublishedOperation) => + `${operation.method.toUpperCase()} ${operation.path}`; + +describe("the sharing refusal every delegable path publishes", () => { + it("discovers a real set of fenced operations", () => { + // Non-zero, and anchored at both ends: a matcher that stopped matching + // would leave every assertion below vacuously true. + expect(operations.length).toBeGreaterThan(200); + expect(fenced.length).toBeGreaterThan(100); + + const fencedNames = fenced.map(name); + expect(fencedNames).toContain("GET /api/measurements"); + expect(fencedNames).toContain("POST /api/labs"); + expect(fencedNames).toContain("GET /api/dashboard/widgets"); + + const unfencedNames = unfenced.map(name); + // The other half of the same module: `PUT` and `DELETE` on the widget + // layout are the caller's own settings and refuse a switch outright. + expect(unfencedNames).toContain("PUT /api/dashboard/widgets"); + expect(unfencedNames).toContain("DELETE /api/dashboard/widgets"); + // An actor surface, and a route whose docblock names both fence helpers + // while calling neither. + expect(unfencedNames).toContain("GET /api/auth/me"); + expect(unfencedNames).toContain("GET /api/managed-profiles/{id}/guardians"); + }); + + it("publishes the refusal on every fenced operation", () => { + const silent = fenced.filter((operation) => { + const response = refusal(operation); + return ( + !response || + !response.description?.includes(SHARING_ACCESS_DENIED_DESCRIPTION) + ); + }); + + if (silent.length > 0) { + throw new Error( + `${silent.length} delegable operation(s) publish no sharing refusal:\n` + + silent.map((operation) => ` ❌ ${name(operation)}`).join("\n") + + "\n\nSpread `...recordRefusal()` into the operation's responses — or " + + "`...recordRefusal()` when it already answers 403 " + + "for a module gate or a consent gate.", + ); + } + }); + + it("says it the same way every time", () => { + // One string, not ninety paraphrases. Collected from the published objects + // rather than asserted against the constant, so a hand-written copy that + // differs by a comma is what this catches. + const sentences = new Set( + fenced.map((operation) => { + const description = refusal(operation)?.description ?? ""; + const at = description.indexOf(SHARING_ACCESS_DENIED_DESCRIPTION); + return description.slice(at); + }), + ); + expect(sentences.size).toBe(1); + expect([...sentences][0]).toBe(SHARING_ACCESS_DENIED_DESCRIPTION); + + // What the sentence has to carry: the code a client branches on, and the + // indistinguishability that keeps it from being an enumeration oracle. + expect(SHARING_ACCESS_DENIED_DESCRIPTION).toContain( + "sharing.access.denied", + ); + expect(SHARING_ACCESS_DENIED_DESCRIPTION.length).toBeGreaterThan(200); + }); + + it("keeps it off the paths that cannot answer with it", () => { + const overreaching = unfenced.filter((operation) => + refusal(operation)?.description?.includes( + SHARING_ACCESS_DENIED_DESCRIPTION, + ), + ); + expect(overreaching.map(name)).toEqual([]); + }); + + it("carries the refusal into the generated document", () => { + const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); + expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60)); + // The emitter aliases identical response objects, so the sentence appears + // once per DISTINCT composition rather than once per path. Both ends are + // checked: the text is there, and the paths that reference it outnumber it. + const aliases = spec.match(/"403": \*/g)?.length ?? 0; + expect(aliases).toBeGreaterThan(fenced.length - 10); + }); +}); diff --git a/src/lib/openapi/routes/allergies.ts b/src/lib/openapi/routes/allergies.ts index 834ecd3a3..86a303d39 100644 --- a/src/lib/openapi/routes/allergies.ts +++ b/src/lib/openapi/routes/allergies.ts @@ -24,7 +24,12 @@ import { allergyStatusEnum, } from "@/lib/validations/allergy"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; allergyCreateSchema.meta({ id: "CreateAllergyRequest", @@ -80,6 +85,7 @@ export const allergyPaths: NonNullable = { "Returns the caller's live (non-deleted) allergy/intolerance records, newest-first. `includeInactive=false` returns only ACTIVE records.", requestParams: { query: allergyListQuerySchema }, responses: { + ...recordRefusal(), "200": { description: "The caller's allergy records.", content: { @@ -101,6 +107,7 @@ export const allergyPaths: NonNullable = { content: { "application/json": { schema: allergyCreateSchema } }, }, responses: { + ...recordRefusal(), "201": { description: "Allergy created.", content: { @@ -121,6 +128,7 @@ export const allergyPaths: NonNullable = { "Returns the record including its decrypted `reaction` + `note`. Owner-scoped; a cross-user or tombstoned id 404s.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "The allergy record.", content: { @@ -144,6 +152,7 @@ export const allergyPaths: NonNullable = { content: { "application/json": { schema: allergyUpdateSchema } }, }, responses: { + ...recordRefusal(), "200": { description: "Allergy updated.", content: { @@ -163,6 +172,7 @@ export const allergyPaths: NonNullable = { "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `allergy.delete`. Owner-scoped.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Soft-deleted.", content: { diff --git a/src/lib/openapi/routes/biomarkers.ts b/src/lib/openapi/routes/biomarkers.ts index 11f27dca8..b2b7ef8ef 100644 --- a/src/lib/openapi/routes/biomarkers.ts +++ b/src/lib/openapi/routes/biomarkers.ts @@ -19,7 +19,12 @@ import { updateBiomarkerSchema, } from "@/lib/validations/biomarkers"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; createBiomarkerSchema.meta({ id: "CreateBiomarkerRequest", @@ -79,6 +84,7 @@ export const biomarkerPaths: NonNullable = { description: "Returns every biomarker the caller has defined, name-ordered. Each carries its unit, reference bounds, and decrypted context.", responses: { + ...recordRefusal(), "200": { description: "Biomarker catalog.", content: { @@ -100,6 +106,7 @@ export const biomarkerPaths: NonNullable = { content: { "application/json": { schema: createBiomarkerSchema } }, }, responses: { + ...recordRefusal(), "201": { description: "Created biomarker.", content: { @@ -121,6 +128,7 @@ export const biomarkerPaths: NonNullable = { "Returns the biomarker including its decrypted `context`. Cross-user rows surface as 404.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Biomarker detail.", content: { @@ -144,6 +152,7 @@ export const biomarkerPaths: NonNullable = { content: { "application/json": { schema: updateBiomarkerSchema } }, }, responses: { + ...recordRefusal(), "200": { description: "Updated biomarker.", content: { diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 1c1796986..268dd7c02 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -42,12 +42,13 @@ import { coachSuggestedActionSchema, } from "@/lib/validations/coach-reminder"; import { - dataEnvelope, - errorEnvelope, - stdResponses, baseUpdatedAtField, conflictResponse409, + dataEnvelope, + errorEnvelope, invalidBaseTokenResponse, + recordRefusal, + stdResponses, } from "./shared"; // ── Coach cadence suggestions (v1.18.1) ────────────────────────────── @@ -1105,6 +1106,7 @@ export const coachPaths: NonNullable = { description: 'v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller\'s newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.', responses: { + ...recordRefusal(), "200": { description: "The current unread state.", content: { @@ -1247,6 +1249,7 @@ export const coachPaths: NonNullable = { description: "Returns the current value and immutable revision history for the caller's smoking status, alcohol pattern and shift schedule. Values are decrypted server-side; unreadable ciphertext returns `value: null` with `unreadable: true`.", responses: { + ...recordRefusal(), "200": { description: "Current facts and their revision history.", content: { @@ -1565,10 +1568,9 @@ export const coachReminderPaths: NonNullable = { }, }, }, - "403": { - description: "Coach surface disabled.", - content: { "application/json": { schema: errorEnvelope } }, - }, + // The read is delegable and the create beside it is not, so only this + // one shares its 403 with the sharing refusal. + ...recordRefusal("Coach surface disabled."), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/custom-metrics.ts b/src/lib/openapi/routes/custom-metrics.ts index 8a1081841..ae6d2183b 100644 --- a/src/lib/openapi/routes/custom-metrics.ts +++ b/src/lib/openapi/routes/custom-metrics.ts @@ -21,7 +21,12 @@ import { updateCustomMetricSchema, } from "@/lib/validations/custom-metrics"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; createCustomMetricSchema.meta({ id: "CreateCustomMetricRequest", @@ -137,6 +142,7 @@ export const customMetricPaths: NonNullable = { description: "Returns every custom metric the caller has defined, name-ordered, each with its latest logged value and total value count.", responses: { + ...recordRefusal(), "200": { description: "Custom-metric catalog.", content: { @@ -182,6 +188,7 @@ export const customMetricPaths: NonNullable = { "Returns the custom-metric definition. Cross-user rows surface as 404.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Custom-metric detail.", content: { @@ -259,6 +266,7 @@ export const customMetricPaths: NonNullable = { }), }, responses: { + ...recordRefusal(), "200": { description: "Logged values.", content: { diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 0a8efbe27..3fe139830 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -24,7 +24,12 @@ import { cyclePeriodSchema, cyclePrefsSchema, } from "@/lib/validations/cycle"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; // ── Cycle tracking (v1.15.0) ───────────────────────────────────────── // The `/api/cycle/*` capture / calendar / history / settings surface + @@ -404,14 +409,25 @@ const cycleBulkResponse = z.object({ }); // A reusable 403 the cycle routes carry (the feature gate). +const CYCLE_DISABLED_DESCRIPTION = + "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD being read, not the caller: a delegate inside somebody else's record sees the owner's setting."; + const cycleDisabledResponse = { "403": { - description: - "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`).", + description: CYCLE_DISABLED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, } as const; +/** + * The same gate on a delegable route, sharing its status with the sharing + * refusal. One 403 per operation is all OpenAPI allows, so the two reasons + * arrive in one description rather than one of them going unpublished. + */ +const cycleDisabledOnADelegableRoute = recordRefusal( + CYCLE_DISABLED_DESCRIPTION, +); + export const cyclePaths: NonNullable = { "/api/cycle/day-logs": { get: { @@ -432,7 +448,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -465,7 +481,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -494,7 +510,7 @@ export const cyclePaths: NonNullable = { description: "Day-log not found / not owned.", content: { "application/json": { schema: errorEnvelope } }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -510,7 +526,7 @@ export const cyclePaths: NonNullable = { description: "Day-log not found / not owned.", content: { "application/json": { schema: errorEnvelope } }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -558,7 +574,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -587,7 +603,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -615,7 +631,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -638,7 +654,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -656,7 +672,7 @@ export const cyclePaths: NonNullable = { description: "Cycle not found / not owned.", content: { "application/json": { schema: errorEnvelope } }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, @@ -705,7 +721,7 @@ export const cyclePaths: NonNullable = { }, }, }, - ...cycleDisabledResponse, + ...cycleDisabledOnADelegableRoute, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/daily.ts b/src/lib/openapi/routes/daily.ts index dd4075f4f..59e78b2f6 100644 --- a/src/lib/openapi/routes/daily.ts +++ b/src/lib/openapi/routes/daily.ts @@ -14,7 +14,13 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { ARRIVAL_KINDS } from "@/lib/arrivals/types"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { dismissPriorityItemSchema } from "@/lib/validations/daily"; -import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; +import { + MODULE_DISABLED_DESCRIPTION, + dataEnvelope, + moduleDisabledResponse, + recordRefusal, + stdResponses, +} from "./shared"; const priorityItemSchema = z .object({ @@ -169,7 +175,7 @@ export const dailyPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 2d7ff5895..1ea829b2b 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -25,11 +25,12 @@ import { } from "@/lib/dashboard-layout"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; import { - dataEnvelope, - stdResponses, baseUpdatedAtField, conflictResponse409, + dataEnvelope, invalidBaseTokenResponse, + recordRefusal, + stdResponses, } from "./shared"; const dashboardWidgetConfig = z.object({ @@ -106,6 +107,7 @@ export const dashboardWidgetPaths: NonNullable = { description: "Returns the resolved effective layout (defaults merged in if the user has not customised it) plus the optimistic-concurrency `updatedAt` token.", responses: { + ...recordRefusal(), "200": { description: "The resolved layout (custom or default) plus its token.", diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 0f714b4d9..e4081882c 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -28,7 +28,12 @@ import { INBOUND_DOCUMENT_STATUSES, } from "@/lib/validations/inbound-documents"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; const kindEnum = z.enum(INBOUND_DOCUMENT_KINDS); const statusEnum = z.enum(INBOUND_DOCUMENT_STATUSES); @@ -309,6 +314,7 @@ export const inboundDocumentPaths: NonNullable = { }, ], responses: { + ...recordRefusal(), "200": { description: "A page of documents.", content: { @@ -425,6 +431,7 @@ export const inboundDocumentPaths: NonNullable = { description: "The caller's vault usage and effective limits: `usedBytes` (every non-purged row — tombstones inside the 30-day undo grace still count), `quotaBytes` (per-user override ?? instance default), `maxFileBytes` (admin-tunable per-file cap), `acceptedExtensions` for picker `accept` lists, and `linkedEpisodes` (episodes carrying at least one live document link — the condition-filter chips). Read this before offering an upload.", responses: { + ...recordRefusal(), "200": { description: "Usage + limits.", content: { @@ -510,6 +517,7 @@ export const inboundDocumentPaths: NonNullable = { }, ], responses: { + ...recordRefusal(), "200": { description: "Document detail.", content: { @@ -635,6 +643,7 @@ export const inboundDocumentPaths: NonNullable = { }, ], responses: { + ...recordRefusal(), "200": { description: "The decrypted original document bytes. Inline types carry the stored MIME type; attachment types are served as application/octet-stream.", @@ -669,6 +678,7 @@ export const inboundDocumentPaths: NonNullable = { }, ], responses: { + ...recordRefusal(), "200": { description: "The decrypted JPEG preview thumbnail bytes.", content: { diff --git a/src/lib/openapi/routes/family-history.ts b/src/lib/openapi/routes/family-history.ts index 96ccf9a85..79b6624e0 100644 --- a/src/lib/openapi/routes/family-history.ts +++ b/src/lib/openapi/routes/family-history.ts @@ -21,7 +21,12 @@ import { familyRelationshipEnum, } from "@/lib/validations/family-history"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; familyHistoryCreateSchema.meta({ id: "CreateFamilyHistoryRequest", @@ -73,6 +78,7 @@ export const familyHistoryPaths: NonNullable = { "Returns the caller's live (non-deleted) family-history entries, newest-first.", requestParams: { query: familyHistoryListQuerySchema }, responses: { + ...recordRefusal(), "200": { description: "The caller's family-history entries.", content: { @@ -99,6 +105,7 @@ export const familyHistoryPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "201": { description: "Entry created.", content: { @@ -122,6 +129,7 @@ export const familyHistoryPaths: NonNullable = { "Returns the entry including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "The family-history entry.", content: { @@ -150,6 +158,7 @@ export const familyHistoryPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Entry updated.", content: { @@ -172,6 +181,7 @@ export const familyHistoryPaths: NonNullable = { "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope. Audits as `family-history.delete`. Owner-scoped.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Soft-deleted.", content: { diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 8a5d566f0..e37742ada 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -12,7 +12,12 @@ import { createShareLinkSchema, unlockShareLinkSchema, } from "@/lib/validations/clinician-share-link"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; // v1.11.0 — clinician share-link owner-facing summary (never the raw token). const shareLinkSummary = z.object({ @@ -90,6 +95,7 @@ export const healthRecordPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Export generated. Content-Type varies by `format`: application/pdf, application/fhir+json, or application/zip.", diff --git a/src/lib/openapi/routes/illness.ts b/src/lib/openapi/routes/illness.ts index 66660dac0..289a9961e 100644 --- a/src/lib/openapi/routes/illness.ts +++ b/src/lib/openapi/routes/illness.ts @@ -29,7 +29,12 @@ import { illnessLifecycleEnum, } from "@/lib/validations/illness"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; illnessEpisodeCreateSchema.meta({ id: "CreateIllnessEpisodeRequest", @@ -293,6 +298,7 @@ export const illnessPaths: NonNullable = { "Returns the caller's live (non-deleted) illness/condition episodes, newest-first by onset. `includeResolved=false` hides episodes that already carry a `resolvedAt`. Born-gated.", requestParams: { query: illnessEpisodeListQuerySchema }, responses: { + ...recordRefusal(), "200": { description: "The caller's illness episodes.", content: { @@ -319,6 +325,7 @@ export const illnessPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "201": { description: "Episode created.", content: { @@ -342,6 +349,7 @@ export const illnessPaths: NonNullable = { "Returns the episode including its decrypted `note`. Owner-scoped; a cross-user or tombstoned id 404s. Born-gated.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "The episode.", content: { @@ -367,6 +375,7 @@ export const illnessPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Episode updated.", content: { @@ -389,6 +398,7 @@ export const illnessPaths: NonNullable = { "Stamps `deletedAt` (tombstone). Idempotent — a re-delete is a no-op. Returns the `{ deleted: true }` envelope (the cross-module DELETE shape). Audits as `illness.episode.delete`. Owner-scoped + module-gated.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Soft-deleted.", content: { @@ -413,6 +423,7 @@ export const illnessPaths: NonNullable = { "Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Episode restored.", content: { @@ -443,6 +454,7 @@ export const illnessPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Episode resolved.", content: { @@ -470,6 +482,7 @@ export const illnessPaths: NonNullable = { query: illnessDayLogGetQuery, }, responses: { + ...recordRefusal(), "200": { description: "With `date`: the day-log or null. Without `date`: the paged day-log list.", @@ -499,6 +512,7 @@ export const illnessPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Existing day-log updated.", content: { diff --git a/src/lib/openapi/routes/insights-signals.ts b/src/lib/openapi/routes/insights-signals.ts index d52b8cfaa..988f2f68f 100644 --- a/src/lib/openapi/routes/insights-signals.ts +++ b/src/lib/openapi/routes/insights-signals.ts @@ -13,7 +13,7 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; const healthStatusResponse = z .object({ @@ -115,6 +115,7 @@ export const insightsSignalPaths: NonNullable = { description: "Surfaces vitals drifting from the user's personal normal — out-of-band deviations from the personal-baseline engine plus dated, sustained level shifts from the changepoint detector. Pure compute over the rollup tier; awareness only, never a diagnosis. Requires the insights module. Cookie or Bearer auth.", responses: { + ...recordRefusal(), "200": { description: "The drift summary.", content: { @@ -137,6 +138,7 @@ export const insightsSignalPaths: NonNullable = { description: "Summarises the last ~30 nights of the per-night sleep-breathing-disturbance index plus device-flagged events into a calm awareness read. A screening signal only — never a HealthLog diagnosis. Requires the insights module. Cookie or Bearer auth.", responses: { + ...recordRefusal(), "200": { description: "The breathing-screening summary.", content: { @@ -159,6 +161,7 @@ export const insightsSignalPaths: NonNullable = { description: "Pairs the two most-recent numeric lab panels and reports each shared analyte's signed delta plus its reference-band standing. Absent when there are fewer than two panels or no analyte is shared. Neutral framing, never a diagnosis. Cookie or Bearer auth.", responses: { + ...recordRefusal(), "200": { description: "The lab-change summary.", content: { diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index e749c10e3..cbb8b383c 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -6,10 +6,12 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { z } from "zod/v4"; import { - consentRequiredResponse, + AI_CONSENT_REQUIRED_DESCRIPTION, + MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, moduleDisabledResponse, + recordRefusal, stdResponses, } from "../shared"; import { @@ -52,6 +54,7 @@ export const insightsPaths: NonNullable = { description: "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.", responses: { + ...recordRefusal(), "200": { description: "Dashboard snapshot.", content: { @@ -86,7 +89,7 @@ export const insightsPaths: NonNullable = { }, }, ...stdResponses, - ...consentRequiredResponse, + ...recordRefusal(AI_CONSENT_REQUIRED_DESCRIPTION), }, }, }, @@ -108,7 +111,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -177,6 +180,7 @@ export const insightsPaths: NonNullable = { query: insightStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -202,6 +206,7 @@ export const insightsPaths: NonNullable = { query: insightStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -227,6 +232,7 @@ export const insightsPaths: NonNullable = { query: insightStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -252,6 +258,7 @@ export const insightsPaths: NonNullable = { query: insightStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -277,6 +284,7 @@ export const insightsPaths: NonNullable = { query: insightStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -302,6 +310,7 @@ export const insightsPaths: NonNullable = { query: insightStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Compliance assessment envelope (fresh, cached, or preparing).", @@ -328,6 +337,7 @@ export const insightsPaths: NonNullable = { query: metricStatusQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -353,6 +363,7 @@ export const insightsPaths: NonNullable = { query: biomarkerAssessmentQuery, }, responses: { + ...recordRefusal(), "200": { description: "Assessment envelope (fresh, cached, or preparing).", content: { @@ -378,6 +389,7 @@ export const insightsPaths: NonNullable = { query: derivedMetricQuery, }, responses: { + ...recordRefusal(), "200": { description: "The flat derived-metric value (ok or insufficient).", content: { @@ -403,6 +415,7 @@ export const insightsPaths: NonNullable = { query: derivedBatchQuery, }, responses: { + ...recordRefusal(), "200": { description: "The map of derived-metric values, keyed by token.", content: { @@ -425,6 +438,7 @@ export const insightsPaths: NonNullable = { description: "Deterministic (non-LLM) weight-plateau read for users on an active GLP-1 medication: flags a stable dose held for at least the trailing window with no weight loss beyond the threshold. `plateau` is null whenever the condition does not hold, so clients hide the note cleanly. Association only — no verdict, no dose advice. Auth via cookie or Bearer.", responses: { + ...recordRefusal(), "200": { description: "Plateau context (or null) plus the window length.", content: { @@ -455,7 +469,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -529,7 +543,7 @@ export const insightsPaths: NonNullable = { "No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one).", content: { "application/json": { schema: errorEnvelope } }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -552,7 +566,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -564,6 +578,7 @@ export const insightsPaths: NonNullable = { description: "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", responses: { + ...recordRefusal(), "200": { description: "The discovered correlations + the tested-pair count.", content: { @@ -597,7 +612,7 @@ export const insightsPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -631,7 +646,7 @@ export const insightsPaths: NonNullable = { description: "No current pattern with that id for this account.", content: { "application/json": { schema: errorEnvelope } }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/labs.ts b/src/lib/openapi/routes/labs.ts index d2df9f671..ce310b3e1 100644 --- a/src/lib/openapi/routes/labs.ts +++ b/src/lib/openapi/routes/labs.ts @@ -16,7 +16,12 @@ import { updateLabResultSchema, } from "@/lib/validations/labs"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; createLabResultSchema.meta({ id: "CreateLabResultRequest", @@ -104,6 +109,7 @@ export const labsPaths: NonNullable = { "Returns the caller's live (non-deleted) lab results, newest first, with optional analyte / panel / date-range filters. Each row carries a server-computed neutral `rangeStatus` and a `hasNote` flag (the encrypted note itself is not echoed).", requestParams: { query: listLabResultsSchema }, responses: { + ...recordRefusal(), "200": { description: "Lab-result list.", content: { @@ -125,6 +131,7 @@ export const labsPaths: NonNullable = { content: { "application/json": { schema: createLabResultSchema } }, }, responses: { + ...recordRefusal(), "201": { description: "Created lab result.", content: { @@ -145,6 +152,7 @@ export const labsPaths: NonNullable = { "Returns the lab result including its decrypted `note`. Cross-user rows surface as 404.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Lab-result detail.", content: { @@ -168,6 +176,7 @@ export const labsPaths: NonNullable = { content: { "application/json": { schema: updateLabResultSchema } }, }, responses: { + ...recordRefusal(), "200": { description: "Updated lab result.", content: { @@ -187,6 +196,7 @@ export const labsPaths: NonNullable = { "Soft-deletes the lab result (stamps `deletedAt`). Idempotent. Audits as `labResult.delete`.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Deletion succeeded.", content: { @@ -220,6 +230,7 @@ export const labsPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Restore count.", content: { diff --git a/src/lib/openapi/routes/measurement-reminders.ts b/src/lib/openapi/routes/measurement-reminders.ts index cd6be33df..d0309e006 100644 --- a/src/lib/openapi/routes/measurement-reminders.ts +++ b/src/lib/openapi/routes/measurement-reminders.ts @@ -14,7 +14,12 @@ import { measurementReminderDto, measurementReminderCompletionDto, } from "@/lib/validations/measurement-reminders"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; const reminderNotFound = { "404": { @@ -32,6 +37,7 @@ export const measurementReminderPaths: NonNullable = description: "Returns the owner's live (non-tombstoned) Vorsorge reminders, sorted by server-computed nextDueAt ascending (nulls last). Each row carries the canonical nextDueAt the client renders without recomputing.", responses: { + ...recordRefusal(), "200": { description: "The owner's measurement reminders.", content: { @@ -58,6 +64,7 @@ export const measurementReminderPaths: NonNullable = }, }, responses: { + ...recordRefusal(), "201": { description: "Reminder created.", content: { @@ -80,6 +87,7 @@ export const measurementReminderPaths: NonNullable = description: "Owner-scoped; a cross-user or tombstoned id 404s.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "The reminder.", content: { @@ -108,6 +116,7 @@ export const measurementReminderPaths: NonNullable = }, }, responses: { + ...recordRefusal(), "200": { description: "Reminder updated.", content: { @@ -129,6 +138,7 @@ export const measurementReminderPaths: NonNullable = description: "Sets deletedAt (tombstone). Idempotent. Owner-scoped.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Soft-deleted.", content: { @@ -153,6 +163,7 @@ export const measurementReminderPaths: NonNullable = "Manual 'Erledigt': stamps lastSatisfiedAt = now and recomputes nextDueAt past now. Free-text reminders resolve only through this path; typed reminders also auto-resolve in the cron when a matching reading lands.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Reminder satisfied; next-due re-anchored.", content: { @@ -177,6 +188,7 @@ export const measurementReminderPaths: NonNullable = "Explicit user-action completion (iOS #23): the app marks a reminder done server-side instead of only dismissing it locally. Routes through the same satisfaction primitive as the cron auto-resolve — stamps lastSatisfiedAt = now, re-anchors nextDueAt, fires no notification. Idempotent: completing an already-completed / auto-satisfied reminder returns 200 with completed=false. Owner-scoped.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Completion applied (completed=true) or already-satisfied no-op (completed=false); reminder carries the canonical post-completion DTO.", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index d9c62a33a..8224c9dbc 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -17,9 +17,10 @@ import { import { seriesBatchQuerySchema } from "@/lib/validations/series-batch"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; import { + MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, - moduleDisabledResponse, + recordRefusal, stdResponses, } from "./shared"; @@ -686,6 +687,7 @@ export const measurementPaths: NonNullable = { query: seriesBatchQuerySchema, }, responses: { + ...recordRefusal(), "200": { description: "Per-type daily series.", content: { @@ -711,6 +713,7 @@ export const measurementPaths: NonNullable = { query: seriesQuerySchema, }, responses: { + ...recordRefusal(), "200": { description: "Resolved series.", content: { @@ -736,6 +739,7 @@ export const measurementPaths: NonNullable = { query: listMeasurementsSchema, }, responses: { + ...recordRefusal(), "200": { description: "Measurement page.", content: { @@ -772,6 +776,7 @@ export const measurementPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "201": { description: "Created. A single resource for the object body; an array of resources, in request order, for the array body.", @@ -801,6 +806,7 @@ export const measurementPaths: NonNullable = { "Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed).", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Measurement.", content: { @@ -830,6 +836,7 @@ export const measurementPaths: NonNullable = { content: { "application/json": { schema: updateMeasurementSchema } }, }, responses: { + ...recordRefusal(), "200": { description: "Updated measurement.", content: { @@ -860,6 +867,7 @@ export const measurementPaths: NonNullable = { "Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.", requestParams: { path: z.object({ id: z.string() }) }, responses: { + ...recordRefusal(), "200": { description: "Deleted.", content: { @@ -943,6 +951,7 @@ export const measurementPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Bulk delete processed.", content: { @@ -971,6 +980,7 @@ export const measurementPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Restore processed.", content: { @@ -1012,7 +1022,7 @@ export const measurementPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -1032,7 +1042,7 @@ export const measurementPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 2e572c814..58881a273 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -23,11 +23,12 @@ import { medicationEfficacyResponseSchema, } from "@/lib/validations/medication-efficacy"; import { + conflictResponse409, dataEnvelope, errorEnvelope, - stdResponses, - conflictResponse409, invalidBaseTokenResponse, + recordRefusal, + stdResponses, } from "../shared"; efficacyTargetOverrideSchema.meta({ @@ -200,6 +201,7 @@ export const medicationPaths: NonNullable = { description: "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", responses: { + ...recordRefusal(), "200": { description: "Medication list.", content: { @@ -224,6 +226,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: createMedicationSchema } }, }, responses: { + ...recordRefusal(), "201": { description: "Created medication with its schedules.", content: { @@ -246,6 +249,7 @@ export const medicationPaths: NonNullable = { description: "Returns the per-user /medications presentation (card/table view + manual order) plus the optimistic-concurrency `updatedAt` token. Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.", responses: { + ...recordRefusal(), "200": { description: "The resolved presentation (custom or default) plus its token.", @@ -324,6 +328,7 @@ export const medicationPaths: NonNullable = { path: z.object({ id: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Medication detail.", content: { @@ -355,6 +360,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: updateMedicationSchema } }, }, responses: { + ...recordRefusal(), "200": { description: "Updated medication.", content: { @@ -415,6 +421,7 @@ export const medicationPaths: NonNullable = { content: { "application/json": { schema: intakeSchema } }, }, responses: { + ...recordRefusal(), "201": { description: "Intake event created.", content: { @@ -456,6 +463,7 @@ export const medicationPaths: NonNullable = { path: z.object({ id: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Inventory item list.", content: { @@ -493,6 +501,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "201": { description: "Created inventory item.", content: { @@ -528,6 +537,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Updated inventory item.", content: { @@ -556,6 +566,7 @@ export const medicationPaths: NonNullable = { path: z.object({ id: z.string(), itemId: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Deletion succeeded.", content: { @@ -634,6 +645,7 @@ export const medicationPaths: NonNullable = { }), }, responses: { + ...recordRefusal(), "200": { description: "Cadence response.", content: { @@ -660,6 +672,7 @@ export const medicationPaths: NonNullable = { description: "Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.", responses: { + ...recordRefusal(), "200": { description: "One adherence row per medication.", content: { @@ -811,6 +824,7 @@ export const medicationPaths: NonNullable = { path: z.object({ id: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Era list.", content: { @@ -844,6 +858,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "201": { description: "Manual era created.", content: { @@ -879,6 +894,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Era corrected.", content: { @@ -912,6 +928,7 @@ export const medicationPaths: NonNullable = { path: z.object({ id: z.string(), revisionId: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Deletion succeeded.", content: { @@ -961,6 +978,7 @@ export const medicationPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "200": { description: "Dry-run verdict (nothing written).", content: { @@ -1018,6 +1036,7 @@ export const medicationPaths: NonNullable = { path: z.object({ id: z.string(), jobId: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Current job status.", content: { @@ -1045,6 +1064,7 @@ export const medicationPaths: NonNullable = { path: z.object({ jobId: z.string() }), }, responses: { + ...recordRefusal(), "200": { description: "Current job status.", content: { diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 5f61be24d..9f60e092c 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -18,7 +18,7 @@ import { listAssessmentsSchema, } from "@/lib/validations/mental-health"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; createAssessmentSchema.meta({ id: "CreateMentalHealthAssessmentRequest", @@ -90,6 +90,7 @@ export const mentalHealthPaths: NonNullable = { "Returns PHQ-9 / GAD-7 / WHO-5 / SCI administrations (newest first). Totals + bands + flags only; raw item answers are never returned.", requestParams: { query: listAssessmentsSchema }, responses: { + ...recordRefusal(), "200": { description: "Screener history.", content: { @@ -115,6 +116,7 @@ export const mentalHealthPaths: NonNullable = { }, }, responses: { + ...recordRefusal(), "201": { description: "Assessment recorded.", content: { diff --git a/src/lib/openapi/routes/nutrients.ts b/src/lib/openapi/routes/nutrients.ts index b08015045..5cb358e17 100644 --- a/src/lib/openapi/routes/nutrients.ts +++ b/src/lib/openapi/routes/nutrients.ts @@ -21,12 +21,19 @@ import { nutrientWaterWriteSchema, nutrientWaterWriteResponseSchema, } from "@/lib/validations/nutrients"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + recordRefusal, + stdResponses, +} from "./shared"; + +const NUTRIENTS_DISABLED_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 on."; const moduleDisabled = { "403": { - 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 on.", + description: NUTRIENTS_DISABLED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, } as const; @@ -89,7 +96,7 @@ export const nutrientPaths: NonNullable = { }, }, }, - ...moduleDisabled, + ...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -113,7 +120,7 @@ export const nutrientPaths: NonNullable = { }, }, }, - ...moduleDisabled, + ...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION), ...stdResponses, }, }, @@ -142,7 +149,7 @@ export const nutrientPaths: NonNullable = { }, }, }, - ...moduleDisabled, + ...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION), ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 927554db9..ecb83a289 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -7,7 +7,7 @@ */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { dataEnvelope, stdResponses } from "./shared"; +import { dataEnvelope, recordRefusal, stdResponses } from "./shared"; // v1.16.11 — the one threshold read every dose-status consumer makes // (cards, table, take-all-due derivation). `lateMinutes` / @@ -60,6 +60,7 @@ export const settingsPaths: NonNullable = { description: "Returns the operator-level late/missed minute thresholds that tier an open dose's status, plus the calling user's low-stock runway threshold (days; null = alert off). One endpoint so every threshold consumer reads one shape. Auth via cookie or Bearer.", responses: { + ...recordRefusal(), "200": { description: "Resolved thresholds.", content: { diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index d65db203c..c88764d41 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -177,6 +177,64 @@ export const stdResponses = { }, }; +// ── The delegated-record refusal (v1.37.0) ─────────────────────────── + +/** + * What a route that can act on somebody else's record answers when it may not. + * + * One string, published on every such path, byte for byte. The refusal carries + * no reason on the wire and the description says why: a selector naming an + * account that does not exist, one naming an account that granted nothing, a + * grant whose sections do not reach the surface and a read grant on a write are + * all the same status, the same code and the same bytes, because a + * distinguishable refusal is an account-enumeration oracle for anybody with a + * login. Ninety paraphrases of that would invite a client to tell them apart. + * + * `src/__tests__/openapi-sharing-denial.test.ts` recomputes the set of routes + * that resolve through `requireRecordAuth` / `requireGuardianAuth` and holds + * every one of them to this exact sentence. + */ +export const SHARING_ACCESS_DENIED_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 response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it."; + +/** + * The 403 above, optionally sharing the status with the reasons a route already + * refuses for. + * + * OpenAPI allows one response per status, so a module-gated delegable route + * cannot publish two 403 objects. The other reasons come first, in their own + * words, and the shared sentence is spliced on the end — which keeps it + * byte-identical everywhere and keeps the module gate's own prose intact. + * + * Memoised by the composed text so identical responses are one object: the YAML + * emitter aliases by identity, and a fresh object per call site would print + * this paragraph a hundred and eighteen times. + */ +interface RefusalResponse { + "403": { + description: string; + content: { "application/json": { schema: typeof errorEnvelope } }; + }; +} + +const refusalCache = new Map(); + +export function recordRefusal(...alsoRefusesFor: string[]): RefusalResponse { + const description = [...alsoRefusesFor, SHARING_ACCESS_DENIED_DESCRIPTION] + .filter(Boolean) + .join("\n\n"); + const cached = refusalCache.get(description); + if (cached) return cached; + const response = { + "403": { + description, + content: { "application/json": { schema: errorEnvelope } }, + }, + }; + refusalCache.set(description, response); + return response; +} + // ── AI-consent precondition (v1.16.13) ─────────────────────────────── // The server-managed AI-egress gate requires an active ConsentReceipt // (`ai_full`, or the surface-specific `ai_insights_only` / `ai_coach`) @@ -186,10 +244,12 @@ export const stdResponses = { // grant-consent notice and call POST /api/consent/ai (or, on web, POST // /api/consent/ai/web) to mint the receipt. BYOK / local / ChatGPT-OAuth // chains are the user's own egress and never trip this gate. +export const AI_CONSENT_REQUIRED_DESCRIPTION = + "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying."; + export const consentRequiredResponse = { "403": { - description: - "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying.", + description: AI_CONSENT_REQUIRED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }; @@ -202,10 +262,12 @@ export const consentRequiredResponse = { // module key) so the iOS retry classifier branches on it and the client // can drop the whole surface. The errorEnvelope shape already declares // `meta.errorCode`; `meta.module` is documented here in prose. +export const MODULE_DISABLED_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.'; + export const moduleDisabledResponse = { "403": { - 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.', + description: MODULE_DISABLED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }; diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 60c29b569..9c4b2f910 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -10,9 +10,11 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { measurementSourceEnum } from "@/lib/validations/measurement"; import { createBatchWorkoutSchema } from "@/lib/validations/workout"; import { + MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, moduleDisabledResponse, + recordRefusal, stdResponses, } from "./shared"; @@ -233,7 +235,7 @@ export const workoutPaths: NonNullable = { }, }, }, - ...moduleDisabledResponse, + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), ...stdResponses, }, },