diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 0f13b06e8..ca948712d 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: @@ -12699,7 +12888,11 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: That access had already ended. + description: "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already + ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last + active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. + The floor is real and this is one of the four routes that enforce it; the way past it is to invite another + Guardian and wait for them to accept, or to delete the profile." content: application/json: schema: @@ -12728,7 +12921,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such grant held by this account. content: @@ -12736,7 +12929,120 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: That access had already ended. + description: "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already + ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last + active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody + and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that + has no self." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/managed-profiles: + post: + tags: + - Account sharing + summary: Create a record somebody looks after + description: "The one way a managed profile comes into existence. It has no password, no passkey and no second factor of + its own, and it never gains one: an account with no self is reachable only through its Guardians, which is what + makes the step-up below a boundary rather than a formality. The creator's own Guardian grant is minted in the + same transaction, so the record is switchable immediately and the caller is never left holding a profile nobody + administers. Cookie transport only — `requireFreshMfa` resolves through the session cookie, so a Bearer caller + is refused as unauthenticated; a native client sends the person to a browser rather than surfacing that as an + error." + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + displayName: + type: string + minLength: 1 + maxLength: 80 + dateOfBirth: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + locale: + type: string + enum: + - de + - en + - es + - fr + - it + - pl + timezone: + type: string + minLength: 1 + maxLength: 64 + required: + - displayName + - locale + - timezone + additionalProperties: false + responses: + "201": + description: The new record. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileEnvelope" + "401": + description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all + (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a + second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives + here too, including for a Bearer caller, whose transport this gate does not read." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": + description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none + anywhere in this API; branch on the status. Nothing was created. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/managed-profiles/{id}: + delete: + tags: + - Account sharing + summary: Delete a managed record + description: "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is + why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after + it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second + factor." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The record is gone. + content: + application/json: + schema: + $ref: "#/components/schemas/DeletedManagedProfileEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and + an ordinary record whose owner granted somebody MANAGE is not a managed profile, so it answers here too." content: application/json: schema: @@ -12780,6 +13086,149 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + post: + tags: + - Account sharing + summary: Invite another Guardian + description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only + and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on + `meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route + answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account + carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A + client branching on the status first would tell somebody to check a spelling when the real answer is that the + record is no longer theirs to administer." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + identifier: + type: string + minLength: 1 + maxLength: 255 + expiresAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + required: + - identifier + additionalProperties: false + responses: + "201": + description: The pending Guardian, in the shape the roster publishes. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileGuardianEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this + instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST + /api/account/grants` makes, and codeless there too, so this family does not answer one way here and another + way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is + not one of its Guardians." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "That account already holds a live grant on this record (`meta.errorCode: + managed_profile.guardian.duplicate`). Inviting YOURSELF lands here rather than on a refusal of its own: a + Guardian grant's grantor is the profile, never the caller, so naming yourself names an account that already + has access — which is both true and the useful sentence." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "Validation failed (the multi-issue envelope; a bare `YYYY-MM-DD` expiry arrives here), or the invitee is + itself a managed profile (`meta.errorCode: managed_profile.guardian.managed_invitee`) — a record with no + self cannot look after another one." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/managed-profiles/{id}/guardians/{grantId}: + delete: + tags: + - Account sharing + summary: End another Guardian's access + description: "Ends one Guardian grant on this profile — a pending invitation or an accepted one. Another person's, never + the caller's own: the service refuses a target grant whose grantee is the caller, and that refusal is the same + 404 an unknown profile gets, so a self-removal control would be a button that always fails with a sentence + describing something else. A Guardian steps away through `POST /api/account/grants/{id}/renounce` instead, which + carries the last-Guardian refusal properly. Count the ACTIVE rows on the roster BEFORE offering this: the floor + is stated ahead of the act rather than discovered as a refusal, and on this route it cannot arrive as one — see + the 409." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: grantId + schema: + type: string + required: true + responses: + "200": + description: What ended, and when. + content: + application/json: + schema: + $ref: "#/components/schemas/EndedManagedProfileGuardianEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such + profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's + OWN. The last is the one to design around — a client must not offer this control on the caller's own row, + because the refusal it earns describes a missing profile rather than what actually happened." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The record would be left with nobody looking after it (`meta.errorCode: + managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather + than silently: the caller must already be one active Guardian and the target is never the caller, so an + active target is always a second one — and two is never the one the floor refuses. The routes that DO emit + this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST + /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and + a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It + is documented here so that a change making it reachable through this route is a change somebody notices." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/account/switch: post: tags: @@ -12832,7 +13281,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 — @@ -37295,6 +37744,82 @@ components: - data - error additionalProperties: false + ManagedProfileEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfile" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfile: + type: object + properties: + id: + type: string + description: The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector + take, and the `{id}` in this family's paths. + displayName: + type: string + dateOfBirth: + anyOf: + - type: string + - type: "null" + locale: + type: string + timezone: + type: string + recordKind: + type: string + const: managed + description: Always `managed` here. Published so a client can file the new record without re-deriving what it just + created; the same value arrives on the account payload's entry for it. + required: + - id + - displayName + - dateOfBirth + - locale + - timezone + - recordKind + additionalProperties: false + description: "A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint: + the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, + because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change + here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner + until the next boot." + DeletedManagedProfileEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ManagedProfileGuardianListEnvelope: type: object properties: @@ -37345,7 +37870,57 @@ components: - acceptedAt additionalProperties: false description: "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than - listed with a state: the question is who looks after this record now." + listed with a state: the question is who looks after this record now. The invitation route answers with this + same shape, so a Guardian is described one way everywhere; it carries no `expiresAt`, because an expiry bounds + one invitation rather than describing who is looking after the record." + ManagedProfileGuardianEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfileGuardian" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EndedManagedProfileGuardianEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EndedManagedProfileGuardian" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EndedManagedProfileGuardian: + type: object + properties: + grantId: + type: string + revokedAt: + type: string + required: + - grantId + - revokedAt + additionalProperties: false + description: "What ended, and when. Two fields on purpose: who held it is on the roster the caller re-reads, and the + grant row's remaining columns are storage rather than contract. Nothing is deleted — the row survives as the + consent record with `revokedBy: GRANTOR`, the managed profile being the grantor of every Guardian grant." AccountSwitchEnvelope: type: object properties: diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index f19b838f7..13d7f7e98 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -206,6 +206,30 @@ describe("account payload consumer guard", () => { } }); + it("has a consumer that ADOPTS the record-session context, not only a type for it", () => { + // `recordSession` is the field the fence hands the browser, and the whole + // point of it is that a client adopts the server's value rather than + // deriving one. The generic sweep above is satisfied by any read anywhere; + // this leg names the two places that have to do the adopting, because a + // payload field read only by the transport type is the shape the file's + // opening paragraph is about. + expect(fields).toContain("recordSession"); + + const adopters = [ + join(SRC, "hooks", "use-account-switch.ts"), + join(SRC, "hooks", "use-record-capabilities.ts"), + ]; + let reads = 0; + for (const file of adopters) { + expect(stripComments(readFileSync(file, "utf8"))).toMatch( + /recordSession/, + ); + reads += 1; + } + expect(reads).toBe(adopters.length); + expect(reads).toBeGreaterThan(0); + }); + it("has non-zero entry and active-record readers for additive access fields", () => { const entrySource = stripComments( readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"), diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index aff12b1ea..5ef5aef9f 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -2,9 +2,67 @@ import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; +import { openApiPaths } from "@/lib/openapi/routes"; + const root = process.cwd(); +/** Every `route.ts` under the API tree. */ +async function apiRouteFiles(): Promise { + const entries = await readdir(path.join(root, "src/app/api"), { + recursive: true, + }); + return entries + .filter((entry) => entry.endsWith("route.ts")) + .map((entry) => path.join("src/app/api", entry)); +} + +/** The published path a route file serves, or null when it is unpublished. */ +function publishedPathFor(file: string): string | null { + const apiPath = file + .replace(/^src\/app/, "") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); + return apiPath in openApiPaths ? apiPath : null; +} + describe("managed-profile contract", () => { + it("publishes the last-Guardian refusal on every published route that emits it", async () => { + // The failure this catches is the one the release audit found: the floor + // is enforced on four surfaces and was DESCRIBED on the one route that + // cannot reach it, while two routes that answer it every time said only + // "that access had already ended". Emitters are discovered from the route + // sources, so a fifth one cannot ship undescribed. + const files = await apiRouteFiles(); + expect(files.length).toBeGreaterThan(100); + + const emitters: string[] = []; + for (const file of files) { + const source = await readFile(path.join(root, file), "utf8"); + if (!source.includes("LastManagedGuardianError")) continue; + if (!source.includes("managed_profile.guardian.required")) continue; + emitters.push(file); + } + // Non-zero, and known: the two grant routes. The lifecycle surfaces that + // also raise it (account deletion, the data wipe) answer through their own + // envelopes and are not in this spec. + expect(emitters).toContain("src/app/api/account/grants/[id]/route.ts"); + expect(emitters).toContain( + "src/app/api/account/grants/[id]/renounce/route.ts", + ); + + const silent = emitters.filter((file) => { + const apiPath = publishedPathFor(file); + if (apiPath === null) return false; + return !JSON.stringify(openApiPaths[apiPath]).includes( + "managed_profile.guardian.required", + ); + }); + expect( + silent, + "published routes that emit the last-Guardian refusal without publishing it", + ).toEqual([]); + }); + it("requires a cookie-backed fresh MFA proof for creation", async () => { const source = await readFile( path.join(root, "src/app/api/managed-profiles/route.ts"), @@ -25,6 +83,32 @@ describe("managed-profile contract", () => { expect(source).not.toContain("requireFreshMfaIfEnrolled"); }); + it("publishes no Guardian refusal a request cannot produce", async () => { + // `managed_profile.guardian.self` was written, shipped and never + // reachable: `self_grant` needs the grantor to equal the grantee, and the + // grantor of a Guardian grant is the PROFILE. Inviting yourself lands on + // the duplicate refusal; naming the profile is refused a step earlier as + // `managed_grantee`. Both doors are asserted in + // `tests/integration/managed-profile-surface.test.ts`, so the arm was + // known to be dead rather than assumed to be, and it was removed rather + // than frozen into the published contract. + const route = await readFile( + path.join(root, "src/app/api/managed-profiles/[id]/guardians/route.ts"), + "utf8", + ); + const spec = await readFile( + path.join(root, "docs/api/openapi.yaml"), + "utf8", + ); + + // Non-zero control: the codes that ARE reachable are still named, so this + // cannot pass by matching an empty file or a renamed family. + expect(route).toContain("managed_profile.guardian.duplicate"); + expect(route).toContain("managed_profile.guardian.managed_invitee"); + expect(route).not.toContain('errorCode: "managed_profile.guardian.self"'); + expect(spec).not.toContain("managed_profile.guardian.self"); + }); + it("does not publish an emancipation API surface", async () => { const source = await readFile( path.join(root, "prisma/schema.prisma"), diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts new file mode 100644 index 000000000..c96b35114 --- /dev/null +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -0,0 +1,335 @@ +/** + * 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. + * - It covers the PUBLISHED surface, which is not the whole delegable surface. + * 116 route modules resolve the fence; 90 of them are in the OpenAPI document + * and 26 have never been (mood entries, the record-settings tree, the + * medication intake tree, several insight reads). That gap predates this + * release and is not closed here — "frozen" means the published contract is + * complete and consistent, not that every delegable route is described. What + * this guard does guarantee is that one of those 26 cannot be published + * without its refusal, because publishing it puts it in scope on the next run. + */ +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("draws only from the reviewed delegable allowlists", () => { + // Containment, not equality, and per FILE rather than per verb — which is + // how `sharing-surface-guard.test.ts` keys its lists, because the + // declaration a human reviewed is "this module may act on another record", + // with the verb-level detail in the entry's prose beside it. + // + // What this closes: the guard above recomputes the delegable set from the + // route sources, so a route that started resolving the fence would be + // discovered and dressed with a refusal automatically — published as + // delegable without anybody deciding it should be. Held to the reviewed + // lists, that becomes two failures instead of a silent contract change. + const guard = readFileSync( + join(ROOT, "src/__tests__/sharing-surface-guard.test.ts"), + "utf8", + ); + const allowlist = (name: string): string[] => { + const start = guard.indexOf(`const ${name}`); + expect( + start, + `${name} is missing from the surface guard`, + ).toBeGreaterThan(-1); + const block = guard.slice(start, guard.indexOf("\n};", start)); + const keys = [...block.matchAll(/^ {2}"(app\/[^"]+)":/gm)].map( + (match) => match[1], + ); + // A block that matched nothing would make the union vacuous and the + // containment below unfalsifiable. + expect(keys.length, name).toBeGreaterThan(0); + return keys; + }; + + const reviewed = new Set([ + ...allowlist("DELEGABLE_ROUTES"), + ...allowlist("DELEGABLE_MANAGE_ROUTES"), + ...allowlist("GUARDIAN_ROUTES"), + ]); + expect(reviewed.size).toBeGreaterThan(50); + + const contributing = new Set( + fenced.map((operation) => + routeModulePath(operation.path) + .slice(join(ROOT, "src/").length) + .replaceAll("\\", "/"), + ), + ); + expect(contributing.size).toBeGreaterThan(50); + + const unreviewed = [...contributing].filter((file) => !reviewed.has(file)); + if (unreviewed.length > 0) { + throw new Error( + `${unreviewed.length} published operation(s) resolve the record fence from a module the surface guard has not reviewed:\n` + + unreviewed.map((file) => ` ❌ src/${file}`).join("\n") + + "\n\nA module becomes delegable by decision, not by discovery: add it " + + "to the matching list in sharing-surface-guard.test.ts with the " + + "reason, or take the fence back out.", + ); + } + }); + + 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/__tests__/sharing-legacy-client-contract.test.ts b/src/__tests__/sharing-legacy-client-contract.test.ts new file mode 100644 index 000000000..ff3beb419 --- /dev/null +++ b/src/__tests__/sharing-legacy-client-contract.test.ts @@ -0,0 +1,195 @@ +/** + * v1.37.0 — what a client makes of the account payload, old and new. + * + * The sharing block gained three fields this release (`level`, `sections`, + * `recordKind`) and kept the two the v1.36.0 contract published (`access`, + * `canWrite`). Two questions follow from that, and this file answers both + * against the payload the SERVER builds rather than against a copy of it: + * + * 1. a client that predates the release still decodes a whole-record grant, + * and fails closed on a scoped one rather than reading it as the whole + * record; + * 2. the canonical readers consume the new fields off both the entry and the + * active record, without a shim in between. + * + * ## Why it drives `resolveAccountAccess` and not a literal + * + * `src/lib/sharing/__tests__/account-access.test.ts` already runs the legacy + * decoder over the FIXTURE objects, which carry an `access` and a `sections` + * key of their own. That proves the decoder and proves the fixture; it cannot + * prove that a payload the server actually emits decodes, because no server + * code runs between them. This file resolves each fixture case through the real + * resolver and hands the decoder the entry that comes out — both ends and the + * pipe. The fixture's canonical fields are asserted against that entry too, so + * a fixture that drifted from the server would fail here rather than quietly + * describe something the product no longer does. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { accountGrant: { findMany: vi.fn() } }, +})); + +import { prisma } from "@/lib/db"; +import { resolveRecordCapabilities } from "@/hooks/use-record-capabilities"; +import { resolveAccountAccess } from "@/lib/sharing/account-access"; +import { + decodeLegacyWholeRecordAccess, + type AccountAccessEntry, +} from "@/lib/sharing/account-access-view"; +import { + LEGACY_ACCOUNT_PAYLOADS, + type LegacyAccountPayloadFixture, +} from "../../tests/fixtures/v137/legacy-account-payloads"; + +const OWNER = "record-owner"; + +/** The grant row behind one fixture case, as Prisma would return it. */ +function grantFor(fixture: LegacyAccountPayloadFixture) { + return { + id: `grant-${fixture.name}`, + access: + fixture.level === "manage" + ? "MANAGE" + : fixture.level === "write" + ? "WRITE" + : "READ", + scopeJson: fixture.sections, + acceptedAt: new Date("2026-08-01T00:00:00.000Z"), + revokedAt: null, + expiresAt: null, + grantor: { + id: OWNER, + username: "record-owner", + displayName: "Record owner", + managedProfileAt: + fixture.recordKind === "managed" + ? new Date("2026-08-01T00:00:00.000Z") + : null, + }, + }; +} + +/** The entry and the active record the server publishes for one case. */ +async function publishedFor(fixture: LegacyAccountPayloadFixture): Promise<{ + entry: AccountAccessEntry; + active: AccountAccessEntry | null; +}> { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + grantFor(fixture), + ] as never); + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: OWNER }, + }); + return { entry: access.accounts[0], active: access.active }; +} + +/** read < write < manage, so "never more than" is a comparison and not prose. */ +const RANK = { read: 0, write: 1, manage: 2 } as const; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("what an older client makes of the v1.37.0 account payload", () => { + it("covers every fixture case, and the fixtures describe the server", async () => { + // Non-zero discovery, and the assertion that keeps the fixture honest: it + // is a description of what the resolver emits, not a parallel invention. + expect(LEGACY_ACCOUNT_PAYLOADS.length).toBeGreaterThan(0); + + for (const fixture of LEGACY_ACCOUNT_PAYLOADS) { + const { entry } = await publishedFor(fixture); + expect(entry, fixture.name).toEqual( + expect.objectContaining({ + access: fixture.access, + level: fixture.level, + sections: fixture.sections, + canWrite: fixture.canWrite, + recordKind: fixture.recordKind, + }), + ); + } + }); + + it.each(LEGACY_ACCOUNT_PAYLOADS)( + "decodes $name the way the fixture says an old client would", + async (fixture) => { + const { entry } = await publishedFor(fixture); + const decoded = decodeLegacyWholeRecordAccess(entry); + + expect(decoded === null ? "deny" : "allow").toBe(fixture.legacyDecoder); + if (fixture.legacyDecoder === "allow") { + expect(decoded).toBe(fixture.access); + // Never MORE than the grant confers. A MANAGE grant decodes as `write` + // on an old client — under-reporting what it may do, which costs a + // control and leaks nothing. The reverse would hand a client a level + // the server will refuse. + expect(RANK[decoded!]).toBeLessThanOrEqual(RANK[fixture.level]); + } + }, + ); + + it("refuses a scoped grant rather than reading it as the whole record", async () => { + const scoped = LEGACY_ACCOUNT_PAYLOADS.find( + (fixture) => fixture.sections !== null, + ); + expect(scoped, "the fixture set must carry a scoped case").toBeDefined(); + + const { entry } = await publishedFor(scoped!); + expect(entry.sections).not.toBeNull(); + expect(decodeLegacyWholeRecordAccess(entry)).toBeNull(); + + // And the reason is the field, not the level: the same grant with the + // sections removed decodes, so a client on the old contract is refused for + // carrying a narrowing it cannot express rather than for being at READ. + expect(decodeLegacyWholeRecordAccess({ ...entry, sections: null })).toBe( + entry.access, + ); + }); +}); + +describe("what the canonical readers make of the same payload", () => { + it.each(LEGACY_ACCOUNT_PAYLOADS)( + "carries level, sections and recordKind through the active record for $name", + async (fixture) => { + const { active } = await publishedFor(fixture); + expect(active, "the session is switched into the record").not.toBeNull(); + + const capabilities = resolveRecordCapabilities(active); + expect(capabilities.level).toBe(fixture.level); + expect(capabilities.sections).toEqual(fixture.sections); + expect(capabilities.recordKind).toBe(fixture.recordKind); + expect(capabilities.inSharedRecord).toBe(true); + // Resolved server-side and rendered, never re-derived: `canAdd` follows + // `canWrite`, and MANAGE does not become `canManage` on the client — a + // delegate's management reach is the server's answer, one request at a + // time. + expect(capabilities.canWrite).toBe(fixture.canWrite); + expect(capabilities.canAdd).toBe(fixture.canWrite); + expect(capabilities.canManage).toBe(false); + }, + ); + + it("reads a scope the server could not parse as nothing, on both ends", async () => { + // The fail-closed reading: a stored scope this build cannot make sense of + // resolves to an EMPTY section list rather than to the whole record. The + // server refuses every section for it, and the client has to agree — an + // empty array rendered as "everything" would paint the entire product for + // a grant that opens none of it. + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { ...grantFor(LEGACY_ACCOUNT_PAYLOADS[0]), scopeJson: ["not-a-section"] }, + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: OWNER }, + }); + + expect(access.accounts[0].sections).toEqual([]); + expect(resolveRecordCapabilities(access.active).sections).toEqual([]); + // An old client is refused outright rather than being handed the whole + // record, which is the same fail-closed direction. + expect(decodeLegacyWholeRecordAccess(access.accounts[0])).toBeNull(); + }); +}); diff --git a/src/__tests__/sharing-native-client-contract.test.ts b/src/__tests__/sharing-native-client-contract.test.ts new file mode 100644 index 000000000..96b6fdb24 --- /dev/null +++ b/src/__tests__/sharing-native-client-contract.test.ts @@ -0,0 +1,148 @@ +/** + * v1.37.0 — what the native client may do with the third level, frozen. + * + * The release adds MANAGE and managed profiles, and the authority split it + * ships with is deliberately asymmetric: + * + * * a token EXERCISES a MANAGE grant exactly as a browser does, per request, + * through the account selector; + * * a token cannot MINT one, and cannot create a managed profile at all, + * because both acts are gated on a fresh second factor that resolves + * through the session cookie and has no Bearer fall-through. + * + * The asymmetry is a consequence of the step-up rather than a policy invented + * for the native client, and it is the sentence Plan 16's handoff note is + * derived from — so it is pinned here as a set rather than left implicit in + * five files that each prove a quarter of it. + * + * ## Why this file is a map and not a fifth copy of the assertions + * + * Every claim below is proved BEHAVIOURALLY somewhere — against the real + * resolver, or the real route, or a real Postgres. Re-asserting them here would + * make five tests that agree with each other and drift together. What has no + * home is the CLAIM SET: that these four facts are one contract, and that + * removing any one of them is a change to what a native client may do. This + * file names each claim, the file that proves it and the test title that does, + * and fails when a title moves or a file stops carrying it. + * + * The pattern is the repo's own — `record-session-fence-acceptance-map.test.ts` + * does the same for the fence's ten cases, for the same reason. + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); + +interface NativeAuthorityClaim { + /** What the contract promises. */ + claim: string; + /** Where it is proved, relative to the repository root. */ + file: string; + /** A fragment of the test title that proves it. */ + title: string; + /** How it is proved, so a reader knows what the evidence is worth. */ + kind: "resolver unit" | "route unit" | "postgres integration"; +} + +const NATIVE_AUTHORITY: NativeAuthorityClaim[] = [ + { + claim: "A token exercises a MANAGE grant on a shared record.", + file: "src/lib/__tests__/acting-account-resolver.test.ts", + title: "admits a MANAGE grant over the Bearer transport too", + kind: "resolver unit", + }, + { + claim: "A token exercises a Guardian's authority over a managed profile.", + file: "src/lib/__tests__/acting-account-resolver.test.ts", + title: "admits a Guardian over the Bearer transport, per request", + kind: "resolver unit", + }, + { + claim: + "A token cannot offer MANAGE, and is told so in a code it can branch on.", + file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts", + title: "refuses a Bearer caller with a code it can act on", + kind: "route unit", + }, + { + claim: + "The refusal lands before the invitee lookup, so it is no account oracle.", + file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts", + title: + "refuses the Bearer caller before it discloses whether the account exists", + kind: "route unit", + }, + { + claim: "A token keeps minting the two levels it always could.", + file: "src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts", + title: "lets a Bearer caller keep minting the two levels it always could", + kind: "route unit", + }, + { + claim: "A token cannot create a managed profile, at any permission width.", + file: "tests/integration/managed-profile-surface.test.ts", + title: "cannot be reached with a token, however wide its permissions", + kind: "postgres integration", + }, + { + claim: + "The positive control: a cookie session with a fresh factor creates one.", + file: "tests/integration/managed-profile-surface.test.ts", + title: "creates a profile from exactly the body the form composes", + kind: "postgres integration", + }, + { + claim: "A token cannot read the Guardian roster either — cookie-only.", + file: "tests/integration/managed-profile-surface.test.ts", + title: "refuses a Bearer caller, because the family is cookie-only", + kind: "postgres integration", + }, +]; + +describe("the native client's authority over the third level", () => { + it("claims a non-empty set, over more than one kind of evidence", () => { + // A map that named nothing would pass every assertion below it. + expect(NATIVE_AUTHORITY.length).toBeGreaterThan(5); + expect(new Set(NATIVE_AUTHORITY.map((entry) => entry.kind)).size).toBe(3); + expect(new Set(NATIVE_AUTHORITY.map((entry) => entry.file)).size).toBe(3); + }); + + it.each(NATIVE_AUTHORITY)("is proved: $claim", ({ file, title }) => { + const path = join(ROOT, file); + expect(existsSync(path), `${file} is missing`).toBe(true); + const source = readFileSync(path, "utf8"); + // The title as a test title, not as prose in a comment: a claim proved by + // a docblock is the shape this repo has been burned by. + expect(source, `${file} no longer carries "${title}"`).toContain( + `"${title}"`, + ); + }); + + it("keeps the mint refusal and the exercise on opposite sides", () => { + // The one-sentence version of the contract, asserted on the source of the + // gate rather than on prose about it: the transport check is what makes + // the invitation cookie-only, and it sits above the step-up call so that a + // token is never handed the session gate's "not authenticated". + const invite = readFileSync( + join(ROOT, "src/app/api/account/grants/route.ts"), + "utf8", + ); + const transportCheck = invite.indexOf('auth.authMethod !== "cookie"'); + const stepUp = invite.indexOf("await requireFreshMfaIfEnrolled("); + expect(transportCheck).toBeGreaterThan(-1); + expect(stepUp).toBeGreaterThan(-1); + expect(transportCheck).toBeLessThan(stepUp); + + // And the managed-profile creation does not even have the softer gate: + // `requireFreshMfa` refuses an account with no second factor enrolled, + // where `requireFreshMfaIfEnrolled` would wave it through. + const create = readFileSync( + join(ROOT, "src/app/api/managed-profiles/route.ts"), + "utf8", + ); + expect(create).toContain("requireFreshMfa("); + expect(create).not.toContain("requireFreshMfaIfEnrolled"); + }); +}); diff --git a/src/__tests__/sharing-record-fence-contract.test.ts b/src/__tests__/sharing-record-fence-contract.test.ts new file mode 100644 index 000000000..18f95d036 --- /dev/null +++ b/src/__tests__/sharing-record-fence-contract.test.ts @@ -0,0 +1,168 @@ +/** + * v1.37.0 — the record-session fence is a cookie-transport contract, frozen. + * + * The fence refuses a browser request formed under a record context the session + * has since left. Everything about it — the epoch, the two request headers, the + * echo, the 409 — belongs to the cookie transport, and the native contract is + * unchanged in every direction by it. That is the claim this file freezes, + * because it is the one a client implementer has to be able to rely on without + * reading the server: a token sends no fence header, receives none back, and is + * never refused for the absence of one. + * + * ## What lives here rather than beside the fence + * + * `src/lib/sharing/__tests__/record-session-fence.test.ts` proves the verdict + * table and the enforcement, including both Bearer arms. This file proves the + * two things that face a CLIENT and had no test of their own: + * + * 1. `GET /api/auth/me` publishes `recordSession` on a cookie session and + * `null` on a token — the field is how a Bearer client is told the fence + * does not apply to it, rather than being left to infer that from silence; + * 2. the PUBLISHED contract says so, in the two request-header parameters and + * in the schema of the field, under the exact header names the code uses. + * + * The second is not a paraphrase check. A spec that named the headers slightly + * differently from the server would be a contract nobody could implement + * against, and the names live in a client-safe module precisely so both ends + * can be held to one spelling. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + RECORD_EPOCH_HEADER, + RECORD_FENCE_ERROR_CODE, + RECORD_SCOPE_HEADER, + RECORD_SCOPE_SELF, +} from "@/lib/sharing/record-session-fence-contract"; +import { recordSessionForPayload } from "@/lib/sharing/record-session-fence"; +import { + recordEpochParameter, + recordScopeParameter, +} from "@/lib/openapi/routes/account-sharing"; +import type { AuthContext } from "@/lib/api-handler"; + +const ROOT = process.cwd(); + +/** + * The published parameter, narrowed. `ZodOpenApiObject["components"].parameters` + * admits a `$ref` as well as an object, so the union has no `name` — these two + * are literal objects and the cast says which arm they are. + */ +function headerParameter(parameter: unknown): { + name: string; + in: string; + required: boolean; + description: string; +} { + return parameter as { + name: string; + in: string; + required: boolean; + description: string; + }; +} + +/** The shape `recordSessionForPayload` reads, and nothing else. */ +function auth( + authMethod: "cookie" | "bearer", + session: { recordEpoch?: number; actingAsUserId?: string | null } = {}, +): AuthContext { + return { + authMethod, + user: { id: "caller" }, + session: { + recordEpoch: session.recordEpoch, + actingAsUserId: session.actingAsUserId ?? null, + }, + } as unknown as AuthContext; +} + +describe("what the account payload tells each transport about the fence", () => { + it("publishes the context a browser adopts", () => { + expect( + recordSessionForPayload( + auth("cookie", { recordEpoch: 3, actingAsUserId: "record-owner" }), + ), + ).toEqual({ epoch: 3, scope: "record-owner" }); + + // Its own record is `scope: null`, not the absence of the field: null is a + // first-class answer here, and the header spelling for it is `self`. + expect(recordSessionForPayload(auth("cookie", { recordEpoch: 0 }))).toEqual( + { epoch: 0, scope: null }, + ); + }); + + it("publishes null on the Bearer transport, however switched the row looks", () => { + // The stamped columns are ignored rather than absent — a token issued to an + // account whose session row carries a selector still gets null, because the + // token has no session and nothing to be stale about. Told explicitly, so a + // native client can assert on the field instead of inferring from omission. + expect( + recordSessionForPayload( + auth("bearer", { recordEpoch: 9, actingAsUserId: "record-owner" }), + ), + ).toBeNull(); + }); + + it("treats a session row without an epoch as the un-switched start", () => { + expect(recordSessionForPayload(auth("cookie"))).toEqual({ + epoch: 0, + scope: null, + }); + }); +}); + +describe("what the published contract says about the fence's headers", () => { + const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); + + it("publishes both request headers under the names the code uses", () => { + // One header, two spellings, and the difference is deliberate rather than + // sloppy: the server matches the lower-case form because that is what an + // HTTP/2 client sends and what `Headers.get` normalises to, while the spec + // publishes the canonical capitalisation an implementer will type. Held + // equal ignoring case, so a genuine rename cannot pass as a casing choice. + expect(headerParameter(recordEpochParameter).name.toLowerCase()).toBe( + RECORD_EPOCH_HEADER, + ); + expect(headerParameter(recordScopeParameter).name.toLowerCase()).toBe( + RECORD_SCOPE_HEADER, + ); + expect(spec.toLowerCase()).toContain(RECORD_EPOCH_HEADER); + expect(spec.toLowerCase()).toContain(RECORD_SCOPE_HEADER); + }); + + it("marks both as cookie-transport only, in the description a client reads", () => { + for (const parameter of [recordEpochParameter, recordScopeParameter]) { + const { description } = headerParameter(parameter); + expect(description.length).toBeGreaterThan(100); + expect(description).toMatch(/[Cc]ookie transport only/); + expect(parameter).toMatchObject({ in: "header", required: false }); + } + + // The epoch's description additionally carries the two refusals a client + // has to tell apart, because they mean opposite things: reconcile and + // retry, versus leave the record and reload. + const epoch = headerParameter(recordEpochParameter).description; + expect(epoch).toContain(RECORD_FENCE_ERROR_CODE); + expect(epoch).toContain("sharing.access.denied"); + }); + + it("publishes the field a client adopts, and that it is null on Bearer", () => { + // Read off the generated document rather than the source object: the spec + // is what an implementer holds, and a schema that never reached the YAML + // would still satisfy an assertion made against the registry. + const state = spec.slice(spec.indexOf(" RecordSessionState:")); + // Up to the next schema key at the same indent — matched as "four spaces + // then a non-space", because a plain four-space search would stop at the + // first nested line instead. + const next = /\n {4}\S/.exec(state.slice(1)); + const block = state.slice(0, next ? next.index + 1 : state.length); + expect(block).toContain("epoch"); + expect(block).toContain("scope"); + expect(block).toMatch(/[Nn]ull on Bearer/); + expect(block).toContain(RECORD_SCOPE_SELF); + }); +}); diff --git a/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts b/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts index 6b33c31d0..d1261cb5d 100644 --- a/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts @@ -31,7 +31,21 @@ export const DELETE = apiHandler( action: { name: "managed_profile.guardian.revoked" }, meta: { profile_id: id, grant_id: grant.id }, }); - return apiSuccess(grant); + // Two fields, not the grant row this used to hand back whole. What ended + // and when is the entire answer; the party who held it is already on the + // roster the caller is about to re-read, and `grantorId`, `granteeId` and + // `scopeJson` are a table's columns rather than a contract. + const { revokedAt } = grant; + if (!revokedAt) { + // The service stamps it inside the transaction it returns from, so + // this cannot happen — and if it ever does, an invented timestamp + // would be worse than the operator hearing about it. + throw new Error("A revoked Guardian grant carries no revocation time"); + } + return apiSuccess({ + grantId: grant.id, + revokedAt: revokedAt.toISOString(), + }); } catch (error) { if (error instanceof LastManagedGuardianError) { return apiError("Add another Guardian before ending this access", 409, { diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts index ff797843c..8afbb7e28 100644 --- a/src/app/api/managed-profiles/[id]/guardians/route.ts +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -1,5 +1,4 @@ import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { apiHandler, @@ -15,10 +14,15 @@ import { } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; -import { listManagedProfileGuardians } from "@/lib/managed-profiles/guardian-list"; +import { + listManagedProfileGuardians, + toManagedProfileGuardianView, +} from "@/lib/managed-profiles/guardian-list"; import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle"; import { checkRateLimit } from "@/lib/rate-limit"; +import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view"; import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants"; +import { inviteManagedProfileGuardianSchema } from "@/lib/validations/managed-profiles"; type RouteParams = { params: Promise<{ id: string }> }; @@ -73,13 +77,6 @@ export const GET = apiHandler( const INVITE_LIMIT = 10; const INVITE_WINDOW_MS = 60 * 60 * 1000; -const guardianInviteSchema = z - .object({ - identifier: z.string().trim().min(1).max(255), - expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), - }) - .strict(); - /** Invite another Guardian from a cookie-backed, freshly verified session. */ export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { @@ -96,7 +93,7 @@ export const POST = apiHandler( maxBytes: 8 * 1024, }); if (jsonError) return jsonError; - const parsed = guardianInviteSchema.safeParse(body); + const parsed = inviteManagedProfileGuardianSchema.safeParse(body); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); const { id } = await params; @@ -107,8 +104,17 @@ export const POST = apiHandler( { email: { equals: parsed.data.identifier, mode: "insensitive" } }, ], }, - select: { id: true }, + select: GRANT_PARTY_SELECT, }); + // No `errorCode`, deliberately, and the same for the 429 above. Rate-limit + // refusals carry none anywhere in this application, and the sibling + // invitation route (`POST /api/account/grants`) answers an unknown + // identifier with a bare 404 too. A code here would make one route in the + // family unlike both. The published contract says so, and says that a + // client resolves on `meta.errorCode` FIRST: this route's other 404 — + // "you are not a Guardian of that profile" — does carry one, and branching + // on the status would tell somebody to check a spelling when the real + // answer is that the profile is no longer theirs to administer. if (!invitee) return apiError("No account with that name or e-mail", 404); try { @@ -124,18 +130,31 @@ export const POST = apiHandler( action: { name: "managed_profile.guardian.invited" }, meta: { profile_id: id, grant_id: grant.id }, }); - return apiSuccess(grant, 201); + // The roster's entry, not the grant row. A fresh invitation is always + // PENDING, so the mapper cannot answer null here — but it decides that + // rather than this route asserting it. + const view = toManagedProfileGuardianView(grant, invitee); + if (!view) throw new Error("A new invitation resolved to an ended grant"); + return apiSuccess(view, 201); } catch (error) { - if (error instanceof GrantError && error.code === "self_grant") { - return apiError("An account cannot be shared with itself", 422, { - errorCode: "managed_profile.guardian.self", - }); - } - if (error instanceof GrantError) { + if ( + error instanceof GrantError && + error.code === "duplicate_live_grant" + ) { return apiError("That account already has access", 409, { errorCode: "managed_profile.guardian.duplicate", }); } + // No arm for `self_grant`, and its absence is a decision. It fires when + // the grantor equals the grantee; in this family the grantor is the + // PROFILE, never the caller, so inviting yourself names somebody who is + // not the grantor and lands on the duplicate refusal above, while naming + // the profile itself is refused one step earlier as `managed_grantee`. + // The 422 that used to sit here published `managed_profile.guardian.self` + // — a code no request could elicit, on a route this release freezes. A + // grant refusal nothing can produce is not caught into a sentence that + // would then be wrong; it reaches the operator as a 500 with its wide + // event, which is what an impossible state deserves. if (error instanceof ManagedProfileLifecycleError) { if (error.code === "managed_grantee") { return apiError("A managed profile cannot be a Guardian", 422, { diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts index d65868ebf..ddc059d68 100644 --- a/src/app/api/managed-profiles/route.ts +++ b/src/app/api/managed-profiles/route.ts @@ -1,5 +1,4 @@ import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { apiHandler, @@ -16,7 +15,7 @@ import { auditLog } from "@/lib/auth/audit"; import { createManagedProfile } from "@/lib/managed-profiles/create"; import { annotate } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { isValidTimezone } from "@/lib/tz/format"; +import { createManagedProfileSchema } from "@/lib/validations/managed-profiles"; /** * The same ceiling its guardian sibling carries, and for the same reason. @@ -30,19 +29,6 @@ import { isValidTimezone } from "@/lib/tz/format"; const CREATE_LIMIT = 10; const CREATE_WINDOW_MS = 60 * 60 * 1000; -const managedProfileSchema = z - .object({ - displayName: z.string().trim().min(1).max(80), - dateOfBirth: z.iso.date().nullable().optional(), - locale: z.enum(["de", "en", "es", "fr", "it", "pl"]), - timezone: z - .string() - .min(1) - .max(64) - .refine(isValidTimezone, "Invalid IANA timezone"), - }) - .strict(); - /** * Create a record a Guardian administers. This deliberately uses the * cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less @@ -65,7 +51,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (jsonError) return jsonError; - const parsed = managedProfileSchema.safeParse(body); + const parsed = createManagedProfileSchema.safeParse(body); if (!parsed.success) return returnAllZodIssues(parsed.error, 422); const { profile, creatorGrant } = await createManagedProfile({ diff --git a/src/lib/__tests__/acting-account-resolver.test.ts b/src/lib/__tests__/acting-account-resolver.test.ts index affbe2c84..46cb1cf0c 100644 --- a/src/lib/__tests__/acting-account-resolver.test.ts +++ b/src/lib/__tests__/acting-account-resolver.test.ts @@ -822,6 +822,26 @@ describe("a route declaring manage refuses everything below it", () => { ); expect(ctx.user.id).toBe(OWNER); }); + + it("admits a MANAGE grant over the Bearer transport too", async () => { + // The native half of the third level, and the asymmetry worth pinning: + // a token cannot MINT a MANAGE grant (the step-up that gates the + // invitation resolves through the session cookie — see + // `src/app/api/account/grants/__tests__/invite-scope-and-manage.test.ts`), + // and it EXERCISES one exactly as a browser does. Nothing about the level + // is transport-dependent once the grant exists; a client that could not + // use what its user was given would be a second authorization model. + signedInBearer(); + selector(OWNER); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute( + () => requireRecordAuth("manage", "measurements"), + "DELETE", + ); + expect(ctx.user.id).toBe(OWNER); + expect(ctx.actor.id).toBe(DELEGATE); + }); }); /** @@ -845,6 +865,20 @@ describe("the guardian resolver gates on the marker, not the grant", () => { expect(ctx.grantId).toBe("grant-1"); }); + it("admits a Guardian over the Bearer transport, per request", async () => { + // A Guardian's phone administers the profile it looks after, with the + // selector on each request rather than a switch it accumulates. The + // marker still decides; the transport does not. + signedInBearer(); + selector(OWNER); + userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z"))); + liveGrant({ access: "MANAGE" }); + + const ctx = await resolveInRoute(() => requireGuardianAuth()); + expect(ctx.user.id).toBe(OWNER); + expect(ctx.actor.id).toBe(DELEGATE); + }); + it("refuses a MANAGE grant on an ordinary adult's record", async () => { // The fence. The grant is perfect and the answer is still no, because the // record has an owner who runs it. diff --git a/src/lib/managed-profiles/guardian-list.ts b/src/lib/managed-profiles/guardian-list.ts index 21340ae0c..db27a63d4 100644 --- a/src/lib/managed-profiles/guardian-list.ts +++ b/src/lib/managed-profiles/guardian-list.ts @@ -46,6 +46,44 @@ export interface ManagedProfileGuardianView { acceptedAt: string | null; } +/** The lifecycle columns this view derives its state from. */ +type GuardianGrantRow = { + id: string; + invitedAt: Date; + acceptedAt: Date | null; + expiresAt: Date | null; + revokedAt: Date | null; +}; + +/** + * One live Guardian grant, as every surface publishes it. + * + * Exported because the invitation route answers with it too. That route used to + * return the `AccountGrant` row Prisma handed it — `grantorId`, `granteeId`, + * `scopeJson`, `lastUsedAt` and the rest — which made a table's columns the + * wire contract of a route nobody had described. One mapper means the roster + * and the invitation cannot describe a Guardian differently. + * + * `null` for a row that is neither PENDING nor ACTIVE, so an ended grant cannot + * reach a list the last-Guardian floor is counted from. The caller decides what + * an ended row means for it; here it simply is not a Guardian. + */ +export function toManagedProfileGuardianView( + grant: GuardianGrantRow, + account: GrantParty, + now: Date = new Date(), +): ManagedProfileGuardianView | null { + const state = grantState(grant, now); + if (state !== "PENDING" && state !== "ACTIVE") return null; + return { + grantId: grant.id, + account, + state, + invitedAt: grant.invitedAt.toISOString(), + acceptedAt: grant.acceptedAt?.toISOString() ?? null, + }; +} + /** * The roster, or `null` when the caller may not have it. * @@ -107,19 +145,12 @@ export async function listManagedProfileGuardians(input: { const roster: ManagedProfileGuardianView[] = []; for (const grant of grants) { - const state = grantState(grant, now); - // The `where` above already excludes both, so this is the belt on the - // derivation rather than the filter itself: `grantState` is the authority - // on what a row IS, and a row it calls ended must not reach a roster the - // last-Guardian floor is counted from. - if (state !== "PENDING" && state !== "ACTIVE") continue; - roster.push({ - grantId: grant.id, - account: grant.grantee, - state, - invitedAt: grant.invitedAt.toISOString(), - acceptedAt: grant.acceptedAt?.toISOString() ?? null, - }); + // The `where` above already excludes an ended row, so the mapper's null is + // the belt on the derivation rather than the filter itself: `grantState` is + // the authority on what a row IS, and a row it calls ended must not reach a + // roster the last-Guardian floor is counted from. + const view = toManagedProfileGuardianView(grant, grant.grantee, now); + if (view) roster.push(view); } return roster; } diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 4ffa64a81..b0d81c4fb 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -29,12 +29,28 @@ import { inviteGrantSchema, switchAccountSchema, } from "@/lib/validations/account-sharing"; +import { + createManagedProfileSchema, + inviteManagedProfileGuardianSchema, +} from "@/lib/validations/managed-profiles"; import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; inviteGrantSchema.meta({ id: "AccountGrantInvite", description: - "Offer access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own, and MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights — never the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). `scope` narrows a READ or WRITE grant to named sections; omitted or null is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person to a browser. The level and the scope are fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", + "Offer access to another account on this instance. Every sentence here describes an INVITED grant on this endpoint, and nothing else: a Guardian of a managed profile also holds MANAGE over a whole record, but that grant is minted by `POST /api/managed-profiles/{id}/guardians` under its own gate and answers with its own shape, so none of the rules below — the step-up, the transport, the refusals — may be carried across to it. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own, and MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights — never the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). `scope` narrows a READ or WRITE grant to named sections; omitted or null is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person to a browser. The level and the scope are fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", +}); + +createManagedProfileSchema.meta({ + id: "CreateManagedProfileRequest", + description: + "Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.", +}); + +inviteManagedProfileGuardianSchema.meta({ + id: "InviteManagedProfileGuardianRequest", + description: + "Offer somebody else a share in looking after this profile. `identifier` is their username or e-mail, matched case-insensitively. `expiresAt` is an ISO instant WITH an offset — a bare `YYYY-MM-DD` is a 422 — or null for an invitation that runs until somebody ends it. The grant is created PENDING at MANAGE over the whole record; the invitee accepts it on their own invitations card exactly as they would any other, and until they do they do not satisfy the last-Guardian floor. Strict, cookie-only, step-up gated and rate-limited to ten an hour, like every act in this family.", }); switchAccountSchema.meta({ @@ -137,7 +153,41 @@ const managedProfileGuardian = z .meta({ id: "ManagedProfileGuardian", description: - "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than listed with a state: the question is who looks after this record now.", + "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than listed with a state: the question is who looks after this record now. The invitation route answers with this same shape, so a Guardian is described one way everywhere; it carries no `expiresAt`, because an expiry bounds one invitation rather than describing who is looking after the record.", + }); + +const managedProfileCreated = z + .object({ + id: z + .string() + .describe( + "The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector take, and the `{id}` in this family's paths.", + ), + displayName: z.string(), + dateOfBirth: z.string().nullable(), + locale: z.string(), + timezone: z.string(), + recordKind: z + .literal("managed") + .describe( + "Always `managed` here. Published so a client can file the new record without re-deriving what it just created; the same value arrives on the account payload's entry for it.", + ), + }) + .meta({ + id: "ManagedProfile", + description: + "A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint: the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner until the next boot.", + }); + +const endedGuardianGrant = z + .object({ + grantId: z.string(), + revokedAt: z.string(), + }) + .meta({ + id: "EndedManagedProfileGuardian", + description: + "What ended, and when. Two fields on purpose: who held it is on the roster the caller re-reads, and the grant row's remaining columns are storage rather than contract. Nothing is deleted — the row survives as the consent record with `revokedBy: GRANTOR`, the managed profile being the grantor of every Guardian grant.", }); const grantListResponse = z @@ -546,7 +596,8 @@ export const accountSharingPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "409": { - description: "That access had already ended.", + description: + "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. The floor is real and this is one of the four routes that enforce it; the way past it is to invite another Guardian and wait for them to accept, or to delete the profile.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -580,7 +631,81 @@ export const accountSharingPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, "409": { - description: "That access had already ended.", + description: + "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that has no self.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/managed-profiles": { + post: { + tags: ["Account sharing"], + summary: "Create a record somebody looks after", + description: + "The one way a managed profile comes into existence. It has no password, no passkey and no second factor of its own, and it never gains one: an account with no self is reachable only through its Guardians, which is what makes the step-up below a boundary rather than a formality. The creator's own Guardian grant is minted in the same transaction, so the record is switchable immediately and the caller is never left holding a profile nobody administers. Cookie transport only — `requireFreshMfa` resolves through the session cookie, so a Bearer caller is refused as unauthenticated; a native client sends the person to a browser rather than surfacing that as an error.", + requestBody: { + required: true, + content: { + "application/json": { schema: createManagedProfileSchema }, + }, + }, + responses: { + ...stdResponses, + "201": { + description: "The new record.", + content: { + "application/json": { + schema: dataEnvelope( + managedProfileCreated, + "ManagedProfileEnvelope", + ), + }, + }, + }, + "401": { + description: + "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives here too, including for a Bearer caller, whose transport this gate does not read.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none anywhere in this API; branch on the status. Nothing was created.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/managed-profiles/{id}": { + delete: { + tags: ["Account sharing"], + summary: "Delete a managed record", + description: + "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second factor.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + ...stdResponses, + "200": { + description: "The record is gone.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeletedManagedProfileEnvelope", + ), + }, + }, + }, + "401": { + description: + "No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and an ordinary record whose owner granted somebody MANAGE is not a managed profile, so it answers here too.", content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -615,6 +740,103 @@ export const accountSharingPaths: NonNullable = { }, }, }, + post: { + tags: ["Account sharing"], + summary: "Invite another Guardian", + description: + "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on `meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A client branching on the status first would tell somebody to check a spelling when the real answer is that the record is no longer theirs to administer.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { + schema: inviteManagedProfileGuardianSchema, + }, + }, + }, + responses: { + ...stdResponses, + "201": { + description: + "The pending Guardian, in the shape the roster publishes.", + content: { + "application/json": { + schema: dataEnvelope( + managedProfileGuardian, + "ManagedProfileGuardianEnvelope", + ), + }, + }, + }, + "401": { + description: + "No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST /api/account/grants` makes, and codeless there too, so this family does not answer one way here and another way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is not one of its Guardians.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "That account already holds a live grant on this record (`meta.errorCode: managed_profile.guardian.duplicate`). Inviting YOURSELF lands here rather than on a refusal of its own: a Guardian grant's grantor is the profile, never the caller, so naming yourself names an account that already has access — which is both true and the useful sentence.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "Validation failed (the multi-issue envelope; a bare `YYYY-MM-DD` expiry arrives here), or the invitee is itself a managed profile (`meta.errorCode: managed_profile.guardian.managed_invitee`) — a record with no self cannot look after another one.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than ten invitations from one caller within the hour. No error code, as above; branch on the status.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/managed-profiles/{id}/guardians/{grantId}": { + delete: { + tags: ["Account sharing"], + summary: "End another Guardian's access", + description: + "Ends one Guardian grant on this profile — a pending invitation or an accepted one. Another person's, never the caller's own: the service refuses a target grant whose grantee is the caller, and that refusal is the same 404 an unknown profile gets, so a self-removal control would be a button that always fails with a sentence describing something else. A Guardian steps away through `POST /api/account/grants/{id}/renounce` instead, which carries the last-Guardian refusal properly. Count the ACTIVE rows on the roster BEFORE offering this: the floor is stated ahead of the act rather than discovered as a refusal, and on this route it cannot arrive as one — see the 409.", + requestParams: { + path: z.object({ id: z.string(), grantId: z.string() }), + }, + responses: { + ...stdResponses, + "200": { + description: "What ended, and when.", + content: { + "application/json": { + schema: dataEnvelope( + endedGuardianGrant, + "EndedManagedProfileGuardianEnvelope", + ), + }, + }, + }, + "401": { + description: + "No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's OWN. The last is the one to design around — a client must not offer this control on the caller's own row, because the refusal it earns describes a missing profile rather than what actually happened.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The record would be left with nobody looking after it (`meta.errorCode: managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather than silently: the caller must already be one active Guardian and the target is never the caller, so an active target is always a second one — and two is never the one the floor refuses. The routes that DO emit this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It is documented here so that a change making it reachable through this route is a change somebody notices.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, }, "/api/account/switch": { post: { 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, }, }, diff --git a/src/lib/validations/managed-profiles.ts b/src/lib/validations/managed-profiles.ts new file mode 100644 index 000000000..eb35b48e2 --- /dev/null +++ b/src/lib/validations/managed-profiles.ts @@ -0,0 +1,37 @@ +/** + * v1.37.0 — the two bodies the managed-profile family accepts. + * + * Here rather than beside each route because the OpenAPI table publishes them, + * and a schema written twice is a contract that drifts on the third change. + * The route modules import these; nothing imports the route modules. + * + * Both are `.strict()`. A managed profile is a person's health record created + * by somebody else, so a field the sender did not mean to send is refused + * rather than ignored — and the client types name exactly these fields for the + * same reason (`src/lib/queries/use-managed-profiles.ts`). + */ +import { z } from "zod/v4"; + +import { isValidTimezone } from "@/lib/tz/format"; + +/** The body `POST /api/managed-profiles` accepts. */ +export const createManagedProfileSchema = z + .object({ + displayName: z.string().trim().min(1).max(80), + dateOfBirth: z.iso.date().nullable().optional(), + locale: z.enum(["de", "en", "es", "fr", "it", "pl"]), + timezone: z + .string() + .min(1) + .max(64) + .refine(isValidTimezone, "Invalid IANA timezone"), + }) + .strict(); + +/** The body `POST /api/managed-profiles/{id}/guardians` accepts. */ +export const inviteManagedProfileGuardianSchema = z + .object({ + identifier: z.string().trim().min(1).max(255), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + }) + .strict(); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index 0f7e14872..dbb4bc92e 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -302,26 +302,33 @@ describe("managed profile lifecycle (real Postgres)", () => { expect(invited.status).toBe(201); const invitation = (await invited.json()).data; expect(invitation.acceptedAt).toBeNull(); - expect(new Date(invitation.expiresAt).getTime()).toBe( - invitationExpiry.getTime(), - ); + // The expiry is read off the stored row: the route answers with the entry + // the roster publishes, and that entry describes who looks after the + // record rather than the terms of one invitation. + expect( + ( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.grantId }, + }) + ).expiresAt?.getTime(), + ).toBe(invitationExpiry.getTime()); cookieJar.set("healthlog_session", secondGuardian.sessionId); const { POST: acceptInvitation } = await import("@/app/api/account/grants/[id]/accept/route"); const accepted = await acceptInvitation( new NextRequest( - `http://localhost/api/account/grants/${invitation.id}/accept`, + `http://localhost/api/account/grants/${invitation.grantId}/accept`, { method: "POST", }, ), - { params: Promise.resolve({ id: invitation.id }) }, + { params: Promise.resolve({ id: invitation.grantId }) }, ); expect(accepted.status).toBe(200); expect( await getPrismaClient().accountGrant.findUniqueOrThrow({ - where: { id: invitation.id }, + where: { id: invitation.grantId }, }), ).toMatchObject({ acceptedAt: expect.any(Date), expiresAt: null }); await switchSessionTo(secondGuardian.sessionId, profile.id); @@ -331,15 +338,20 @@ describe("managed profile lifecycle (real Postgres)", () => { await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); const revoked = await revokeGuardian( new NextRequest( - `http://localhost/api/managed-profiles/${profile.id}/guardians/${invitation.id}`, + `http://localhost/api/managed-profiles/${profile.id}/guardians/${invitation.grantId}`, { method: "DELETE" }, ), - { params: Promise.resolve({ id: profile.id, grantId: invitation.id }) }, + { + params: Promise.resolve({ + id: profile.id, + grantId: invitation.grantId, + }), + }, ); expect(revoked.status).toBe(200); expect( await getPrismaClient().accountGrant.findUniqueOrThrow({ - where: { id: invitation.id }, + where: { id: invitation.grantId }, }), ).toMatchObject({ revokedBy: "GRANTOR" }); expect( @@ -365,8 +377,8 @@ describe("managed profile lifecycle (real Postgres)", () => { creator.user.id, ]); expect(audit.map((entry) => JSON.parse(entry.details ?? "{}"))).toEqual([ - { grantId: invitation.id }, - { grantId: invitation.id }, + { grantId: invitation.grantId }, + { grantId: invitation.grantId }, ]); }); @@ -404,20 +416,20 @@ describe("managed profile lifecycle (real Postgres)", () => { const pending = await invite(); const withdrawn = await revokeGuardian( new NextRequest( - `http://localhost/api/managed-profiles/${profile.id}/guardians/${pending.id}`, + `http://localhost/api/managed-profiles/${profile.id}/guardians/${pending.grantId}`, { method: "DELETE" }, ), - { params: Promise.resolve({ id: profile.id, grantId: pending.id }) }, + { params: Promise.resolve({ id: profile.id, grantId: pending.grantId }) }, ); expect(withdrawn.status).toBe(200); expect( await getPrismaClient().accountGrant.findUniqueOrThrow({ - where: { id: pending.id }, + where: { id: pending.grantId }, }), ).toMatchObject({ acceptedAt: null, revokedAt: expect.any(Date) }); const replacement = await invite(); - expect(replacement.id).not.toBe(pending.id); + expect(replacement.grantId).not.toBe(pending.grantId); expect(replacement.acceptedAt).toBeNull(); }); diff --git a/tests/integration/managed-profile-surface.test.ts b/tests/integration/managed-profile-surface.test.ts index 6425a661d..253db33a0 100644 --- a/tests/integration/managed-profile-surface.test.ts +++ b/tests/integration/managed-profile-surface.test.ts @@ -370,6 +370,31 @@ describe("who looks after a managed profile (real Postgres)", () => { }); describe("every call the managed-profile card can make", () => { + it("cannot be reached with a token, however wide its permissions", async () => { + // The native half of the contract, and the reason the freeze can promise + // it: `requireFreshMfa` resolves the session cookie and never falls + // through to the Bearer branch, so a wildcard token — the widest a client + // can hold — cannot mint a credential-less person and a permanent + // management relationship. The positive control is the very next case: + // the same body, from a cookie session that has proved its factor, + // creates the record. + const creator = await person("creator"); + await signInWithBearer(creator); + + const response = await createProfile({ + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + expect(response.status).toBe(401); + expect( + await getPrismaClient().user.count({ + where: { managedProfileAt: { not: null } }, + }), + ).toBe(0); + }); + it("creates a profile from exactly the body the form composes", async () => { const creator = await person("creator"); signIn(creator); @@ -476,7 +501,17 @@ describe("every call the managed-profile card can make", () => { expect(response.status).toBe(201); const grant = (await response.json()).data; expect(grant.acceptedAt).toBeNull(); - expect(new Date(grant.expiresAt).toISOString()).toBe(expiresAt); + + // Read the stored row, because the entry the route answers with does not + // carry the expiry: a Guardian is published in one shape everywhere, and + // that shape is the roster's five fields. Asserting the column is the + // stronger claim anyway — it proves the offset instant was persisted + // rather than that it was echoed. + const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: grant.grantId }, + select: { expiresAt: true }, + }); + expect(stored.expiresAt?.toISOString()).toBe(expiresAt); // PENDING, and therefore not counted by the floor — which is the sentence // the card states before anybody tries to leave. @@ -523,6 +558,70 @@ describe("every call the managed-profile card can make", () => { expect((await response.json()).data.acceptedAt).toBeNull(); }); + it("answers an invitation with the roster's shape, not the grant row", async () => { + // `Object.keys`, for the same reason the roster read asserts it that way: + // the handler used to answer with the `AccountGrant` row straight out of + // Prisma — `grantorId`, `granteeId`, `scopeJson`, `lastUsedAt` and the + // rest — and every presence assertion in this file passed while it did. + // The invitation now answers with the entry the roster publishes, so one + // shape describes a Guardian everywhere and the published contract does + // not pin a table's columns to the wire. + const creator = await person("creator"); + const invitee = await person("invitee"); + signIn(creator); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + const response = await inviteGuardian(profile.id, { + identifier: invitee.username, + expiresAt: null, + }); + expect(response.status).toBe(201); + + const entry = (await response.json()).data as Record; + expect(Object.keys(entry).sort()).toEqual([ + "acceptedAt", + "account", + "grantId", + "invitedAt", + "state", + ]); + expect(Object.keys(entry.account as object).sort()).toEqual([ + "displayName", + "id", + "username", + ]); + expect(entry.state).toBe("PENDING"); + expect((entry.account as { username: string }).username).toBe( + invitee.username, + ); + + // The pipe, not the ends: the entry the invitation answered with is the + // entry the roster read answers with a moment later. + const roster = (await (await listGuardians(profile.id)).json()) + .data as Array>; + expect(roster).toContainEqual(entry); + }); + + it("answers a removal with the access it ended and when", async () => { + const { creator, profile, invitation } = + await profileWithPendingSecondGuardian(); + signIn(creator); + + const response = await removeGuardian(profile.id, invitation.id); + expect(response.status).toBe(200); + + const ended = (await response.json()).data as Record; + expect(Object.keys(ended).sort()).toEqual(["grantId", "revokedAt"]); + expect(ended.grantId).toBe(invitation.id); + expect(ended.revokedAt).toEqual(expect.any(String)); + }); + it("refuses a bare calendar date as an expiry", async () => { const creator = await person("creator"); const invitee = await person("invitee");