From 38766cd4ca966769546dc5c52e8dccef21556e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 7 Aug 2026 04:56:56 +0200 Subject: [PATCH] docs: define sharing refusal contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every route that resolves a record through the sharing fence can answer 403 `sharing.access.denied`, and until now that fact was published on the selector header and on none of the 120 operations that produce it. All 120 carry it now, byte for byte, from one constant: the refusal is deliberately indistinguishable across a missing account, a missing grant, a scope that does not reach the surface and a read grant on a write, so 120 paraphrases would invite a client to tell apart what the server refuses to. Where a delegable route already answered 403 for its own reason — a module gate, the cycle gate, AI consent — the two share one description rather than one of them going unpublished, since OpenAPI allows a single response per status. The guard recomputes the delegable set from the route sources on every run and classifies per verb, which matters: seventeen modules have a delegable read beside a write that refuses a switch outright. --- docs/api/openapi.yaml | 353 ++++++++++++++---- src/__tests__/openapi-sharing-denial.test.ts | 268 +++++++++++++ src/lib/openapi/routes/allergies.ts | 12 +- src/lib/openapi/routes/biomarkers.ts | 11 +- src/lib/openapi/routes/coach.ts | 16 +- src/lib/openapi/routes/custom-metrics.ts | 10 +- src/lib/openapi/routes/cycle.ts | 42 ++- src/lib/openapi/routes/daily.ts | 10 +- src/lib/openapi/routes/dashboard.ts | 6 +- src/lib/openapi/routes/documents.ts | 12 +- src/lib/openapi/routes/family-history.ts | 12 +- src/lib/openapi/routes/health-record.ts | 8 +- src/lib/openapi/routes/illness.ts | 16 +- src/lib/openapi/routes/insights-signals.ts | 5 +- src/lib/openapi/routes/insights/paths.ts | 31 +- src/lib/openapi/routes/labs.ts | 13 +- .../openapi/routes/measurement-reminders.ts | 14 +- src/lib/openapi/routes/measurements.ts | 16 +- src/lib/openapi/routes/medications/paths.ts | 24 +- src/lib/openapi/routes/mental-health.ts | 4 +- src/lib/openapi/routes/nutrients.ts | 19 +- src/lib/openapi/routes/settings.ts | 3 +- src/lib/openapi/routes/shared.ts | 70 +++- src/lib/openapi/routes/workouts.ts | 4 +- 24 files changed, 837 insertions(+), 142 deletions(-) create mode 100644 src/__tests__/openapi-sharing-denial.test.ts 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, }, },