diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ec63a5ca0..a6d1ab4e2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -399,7 +399,7 @@ paths: "403": *a2 "409": *a7 "422": *a4 - "429": &a16 + "429": &a12 description: "Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the routes that meter themselves with a dedicated @@ -770,7 +770,7 @@ paths: application/json: schema: $ref: "#/components/schemas/CyclePrefsPatchEnvelope" - "400": &a18 + "400": &a11 description: The request body is not parseable JSON. Nothing was read and nothing was written. `meta.errorCode` names the surface that refused it (`.body.invalid_json`). This is a client-side serialisation fault, not a validation failure — a body that parses but fails the schema is the 422 beside this. @@ -782,6 +782,193 @@ paths: "403": *a9 "422": *a4 "429": *a5 + /api/cycle/feed/{token}: + get: + tags: + - Cycle + summary: "The subscribable cycle forecast as iCalendar (public, #1350)" + description: "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by + `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every + gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's + external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live + MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed + grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next + few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next + fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304." + security: [] + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hlf_` feed token, optionally with `.ics`. + required: true + description: Raw `hlf_` feed token, optionally with `.ics`. + responses: + "200": + description: "The calendar. `Cache-Control: private, no-cache, max-age=0`, `ETag`." + content: + text/calendar: + schema: + type: string + "304": + description: The body is unchanged since the `If-None-Match` ETag. + "404": + description: Flat 404 for every miss class (errorCode `cycle.feed.notFound`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: The source-address or per-token bucket is exhausted. + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/cycle/feed-subscription: + get: + tags: + - Cycle + summary: Calendar subscription status (#1350) + description: The record's live calendar subscription without its secret (prefix, options, fetch statistics) and what the + feed serves right now. Never the hash, never the URL. + responses: + "200": + description: The status; `feed` is null while the subscription is off. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedStatus" + "401": *a1 + "403": &a10 + description: >- + `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the + generic auth gate answers it before the route's own checks run. Otherwise: Calendar subscriptions are + managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched + off for the record answers `cycle.disabled`; the operator's external-access switch answers + `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE + delegate whose grant covers the cycle section, never by a READ or WRITE delegate. + + + 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": *a4 + "429": *a5 + post: + tags: + - Cycle + summary: Turn the calendar subscription on (#1350) + description: "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live + subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles + ahead, no history, no fertile window, discreet titles. Rate-limited per acting account + (`cycle-feed:mint:`, 10/h, shared with rotate)." + responses: + "201": &a13 + description: The new link, returned exactly once. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedMinted" + "401": &a14 + description: "Not signed in, or the session has no fresh proof: turning the subscription on or rotating it needs a + sign-in or `POST /api/auth/reproof` within the last five minutes, answered otherwise with 401 + `auth.reproof.required` and `meta.methods`." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": *a10 + "409": + description: A subscription is already on (`cycle.feed.exists`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 + patch: + tags: + - Cycle + summary: Change the calendar subscription's options (#1350) + description: Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedOptionsPatch" + responses: + "200": + description: The status after the change. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedStatusAfterPatch" + "400": *a11 + "401": *a1 + "403": *a10 + "404": + description: No subscription is on (`cycle.feed.none`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a12 + delete: + tags: + - Cycle + summary: Turn the calendar subscription off (#1350) + description: Revokes the link; calendars holding it stop updating on their next poll. Copies a calendar provider already + holds are beyond the app's reach. + responses: + "200": + description: Revoked. + content: + application/json: + schema: + $ref: "#/components/schemas/CycleFeedRevoked" + "401": *a1 + "403": *a10 + "404": + description: No subscription is on (`cycle.feed.none`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a12 + /api/cycle/feed-subscription/rotate: + post: + tags: + - Cycle + summary: Replace the calendar subscription's link (#1350) + description: "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL + EXACTLY ONCE. Calendars using the old link stop updating." + responses: + "201": *a13 + "401": *a14 + "403": *a10 + "404": + description: No subscription is on (`cycle.feed.none`). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/measurement-categories: get: tags: @@ -983,7 +1170,7 @@ paths: type: string format: binary "401": *a1 - "403": &a15 + "403": &a20 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account @@ -1309,7 +1496,7 @@ paths: schema: type: string format: binary - "400": &a10 + "400": &a15 description: A search parameter the server cannot honour, such as an `_offset` above 9007199254740991. The body is an `OperationOutcome` (`issue[0].code` = `invalid`, the reason in `diagnostics`) served as `application/fhir+json`. @@ -1319,7 +1506,7 @@ paths: type: string format: binary "401": *a1 - "403": &a11 + "403": &a16 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` @@ -1330,7 +1517,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": &a12 + "429": &a17 description: "Rate limit exceeded: the per-account `fhir:` bucket, 120 requests per hour, shared by every `/api/fhir/*` data route. The body is an `OperationOutcome` (`issue[0].code` = `throttled`) served as `application/fhir+json`, not the JSON error envelope. The `Retry-After`, `X-RateLimit-Limit`, @@ -1384,11 +1571,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationStatement: get: tags: @@ -1415,11 +1602,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationAdministration: get: tags: @@ -1446,11 +1633,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Coverage: get: tags: @@ -1478,11 +1665,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Patient/{id}: get: tags: @@ -1506,7 +1693,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1515,7 +1702,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Coverage/{id}: get: tags: @@ -1539,7 +1726,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1548,7 +1735,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Observation/{id}: get: tags: @@ -1573,7 +1760,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1582,7 +1769,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationStatement/{id}: get: tags: @@ -1606,7 +1793,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1615,7 +1802,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/MedicationAdministration/{id}: get: tags: @@ -1639,7 +1826,7 @@ paths: type: string format: binary "401": *a1 - "403": *a11 + "403": *a16 "404": description: OperationOutcome (application/fhir+json). content: @@ -1648,7 +1835,7 @@ paths: type: string format: binary "422": *a4 - "429": *a12 + "429": *a17 /api/fhir/Patient/$everything: get: tags: @@ -1682,11 +1869,11 @@ paths: schema: type: string format: binary - "400": *a10 + "400": *a15 "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 - "429": *a12 + "429": *a17 /api/export: get: tags: @@ -2219,7 +2406,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": &a13 + "409": &a18 description: 'Refused before anything was checked or spent: the request stated `clientTransport.protocol: "http"` on a non-loopback host while this server issues `Secure` session cookies, so the browser would drop the session. `meta.errorCode` = `auth.session.insecure_transport`, `meta.sessionCookieSecure` = `true`. No password was @@ -2255,7 +2442,7 @@ paths: $ref: "#/components/schemas/MfaVerifyResponse" "401": *a1 "403": *a9 - "409": *a13 + "409": *a18 "422": *a4 "429": *a5 /api/auth/oidc/native/token: @@ -2565,7 +2752,7 @@ paths: application/json: schema: $ref: "#/components/schemas/TotpSetupEnvelope" - "401": &a14 + "401": &a19 description: No session or elevation; or, on the cookie path, `auth.reproof.required` (no recent sign-in and no proof in the body) or `auth.reproof.failed` (the proof did not verify). content: @@ -2718,7 +2905,7 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterOptionsEnvelope" - "401": *a14 + "401": *a19 "403": *a9 "422": *a4 "429": *a5 @@ -2872,7 +3059,7 @@ paths: $ref: "#/components/schemas/MfaWebauthnVerifyResponse" "401": *a1 "403": *a9 - "409": *a13 + "409": *a18 "422": *a4 "429": *a5 /api/auth/passkey/login-verify: @@ -3238,7 +3425,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a13 + "409": *a18 "429": *a5 /api/auth/passkeys/{id}: patch: @@ -3926,7 +4113,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a20 + "429": &a23 description: "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. @@ -4387,7 +4574,7 @@ paths: schema: $ref: "#/components/schemas/GetBloodPressureTimeOfDayResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements/series-batch: @@ -4437,7 +4624,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements/series: @@ -4492,7 +4679,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements: @@ -4741,7 +4928,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -4797,7 +4984,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. @@ -4813,7 +5000,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/measurements/{id}: get: tags: @@ -4834,7 +5021,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Measurement not found (or owned by another user). content: @@ -4882,7 +5069,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Measurement not found (or owned by another user). content: @@ -4921,7 +5108,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Measurement not found (or owned by another user). content: @@ -5035,7 +5222,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -5067,7 +5254,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -5100,7 +5287,7 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a17 + "403": &a21 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Module disabled for this account: @@ -5143,7 +5330,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/workouts: @@ -5194,7 +5381,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/workouts/{id}: @@ -5230,7 +5417,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": *a11 + "403": *a16 "404": description: Workout not found (or owned by another user). content: @@ -5385,7 +5572,7 @@ paths: schema: $ref: "#/components/schemas/ActivityCalendarEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/activity/goals: @@ -5428,7 +5615,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a16 + "429": *a12 /api/measurements/provenance: get: tags: @@ -5558,7 +5745,7 @@ paths: schema: $ref: "#/components/schemas/DayProvenanceEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/measurements/provenance/compare: @@ -5743,7 +5930,7 @@ paths: schema: $ref: "#/components/schemas/SourceComparisonEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/devices: @@ -5855,7 +6042,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeAggregateEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": description: The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with @@ -5985,7 +6172,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/medications/{id}/glp1: get: tags: @@ -6010,7 +6197,7 @@ paths: schema: $ref: "#/components/schemas/Glp1DetailResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6094,7 +6281,7 @@ paths: schema: $ref: "#/components/schemas/Glp1PostResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6233,7 +6420,7 @@ paths: schema: $ref: "#/components/schemas/ReminderPhaseConfigEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6351,7 +6538,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationSideEffectsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6390,7 +6577,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationSideEffectEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6435,7 +6622,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationSideEffectEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable. @@ -6483,7 +6670,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationIntakeEventEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable. @@ -6531,7 +6718,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationIntakeEventEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No event with that id under this medication and record. content: @@ -6574,7 +6761,7 @@ paths: schema: $ref: "#/components/schemas/BulkDeleteMedicationIntakeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6616,7 +6803,7 @@ paths: schema: $ref: "#/components/schemas/PurgeMedicationIntakeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6661,7 +6848,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportAcceptedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another account). content: @@ -6742,7 +6929,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -6781,7 +6968,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -6801,7 +6988,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationCategoriesResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -6828,7 +7015,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationCategoryResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -6999,7 +7186,7 @@ paths: schema: $ref: "#/components/schemas/EndMedicationCourseResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (`medications.course.notFound`), or another user's. content: @@ -7105,7 +7292,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 put: @@ -7140,7 +7327,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a19 + "422": &a22 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. @@ -7186,7 +7373,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7227,7 +7414,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7301,7 +7488,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7319,7 +7506,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 get: tags: - Medications @@ -7384,7 +7571,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7417,7 +7604,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7453,7 +7640,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7497,7 +7684,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7532,7 +7719,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -7630,7 +7817,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7679,7 +7866,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/medications/{id}/compliance: @@ -7864,7 +8051,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7902,7 +8089,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication not found (or owned by another user). content: @@ -7946,7 +8133,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication or revision not found (or owned by another user). content: @@ -7987,7 +8174,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Medication or revision not found (or owned by another user). content: @@ -8050,7 +8237,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": description: A dose-history import is already in progress. content: @@ -8112,7 +8299,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No such job for this medication (or owned by another user). content: @@ -8146,7 +8333,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No such job for this account (or owned by another user). content: @@ -8176,7 +8363,7 @@ paths: schema: $ref: "#/components/schemas/ProfileSummaryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/ai/test: @@ -8386,7 +8573,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutCoachPrefsResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "409": @@ -8397,7 +8584,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/auth/me/disable-coach: get: @@ -8436,7 +8623,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDisableCoachResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "422": *a4 @@ -8597,7 +8784,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchModulesResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "409": @@ -8608,7 +8795,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/auth/me/source-priority: get: @@ -9784,7 +9971,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutSourcePriorityResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "422": *a4 @@ -9944,7 +10131,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a20 + "429": *a23 /api/auth/me/notification-prefs: get: tags: @@ -9987,7 +10174,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchNotificationPrefsResponse" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "409": @@ -9998,7 +10185,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/auth/me/report-selection: get: @@ -10041,7 +10228,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutReportSelectionResponseEnvelope" - "400": *a18 + "400": *a11 "401": *a1 "403": *a9 "422": *a4 @@ -10090,7 +10277,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchUnitPreferenceEnvelope" - "400": &a21 + "400": &a24 description: The request body is not parseable JSON. Nothing was written. content: application/json: @@ -10098,13 +10285,13 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 - "413": &a22 + "413": &a25 description: Request body exceeds 1024 bytes. Nothing was written. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a23 + "415": &a26 description: "The request did not carry `Content-Type: application/json`. Nothing was read and nothing was written." content: application/json: @@ -10160,11 +10347,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchGlucoseUnitEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/documents-auto-ai-read: @@ -10220,11 +10407,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchDocumentsAutoAiReadEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/injection-site-prefs: @@ -10526,11 +10713,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchLabsLocalOcrEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/workout-routes: @@ -10571,11 +10758,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchWorkoutRoutesEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/use-central-codex: @@ -10621,11 +10808,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PatchUseCentralCodexEnvelope" - "400": *a21 + "400": *a24 "401": *a1 "403": *a9 - "413": *a22 - "415": *a23 + "413": *a25 + "415": *a26 "422": *a4 "429": *a5 /api/auth/me/locale: @@ -10832,7 +11019,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a26 + "503": &a29 description: "The AI capability could not be resolved (its inputs could not be read) and the answer failed closed: `meta.errorCode` = `ai.unavailable`, `meta.reason` = `check_failed`. Retry later; no data depends on it." content: @@ -11251,7 +11438,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 delete: tags: @@ -11433,7 +11620,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/coach/seen: @@ -11554,7 +11741,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/anamnesis/facts: get: @@ -11573,7 +11760,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -12048,7 +12235,7 @@ paths: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 "403": *a9 - "404": &a24 + "404": &a27 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -12085,7 +12272,7 @@ paths: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 "403": *a9 - "404": *a24 + "404": *a27 "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. @@ -12456,7 +12643,7 @@ paths: schema: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -12624,7 +12811,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteList" "401": *a1 - "403": &a25 + "403": &a28 description: The session is valid but its account is not an administrator. `meta.errorCode` = `auth.admin.required`. content: application/json: @@ -12665,7 +12852,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteCreated" "401": *a1 - "403": *a25 + "403": *a28 "422": *a4 "429": *a5 /api/admin/invites/{id}: @@ -12690,7 +12877,7 @@ paths: schema: $ref: "#/components/schemas/AdminInviteRevoked" "401": *a1 - "403": *a25 + "403": *a28 "422": *a4 "429": *a5 /api/insights/chat/messages/{id}/feedback: @@ -12746,7 +12933,7 @@ paths: schema: $ref: "#/components/schemas/AdminMeasurementMaintenanceStatusResponse" "401": *a1 - "403": *a25 + "403": *a28 "422": *a4 "429": *a5 post: @@ -12876,7 +13063,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/comprehensive: @@ -12898,7 +13085,7 @@ paths: schema: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/cards: @@ -12919,7 +13106,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/pregenerate: @@ -13057,7 +13244,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a26 + "503": *a29 post: tags: - Insights @@ -13276,7 +13463,7 @@ paths: schema: $ref: "#/components/schemas/CoachReadStripEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/narrative: @@ -13325,7 +13512,7 @@ paths: schema: $ref: "#/components/schemas/NarrativeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/glp1-timeline: @@ -13362,7 +13549,7 @@ paths: schema: $ref: "#/components/schemas/Glp1TimelineEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/pulse/intraday: @@ -13398,7 +13585,7 @@ paths: schema: $ref: "#/components/schemas/IntradayPulseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/settings: @@ -13474,7 +13661,7 @@ paths: schema: $ref: "#/components/schemas/TargetsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/analytics/range: @@ -13556,7 +13743,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/pulse-status: @@ -13596,7 +13783,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/weight-status: @@ -13636,7 +13823,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/bmi-status: @@ -13676,7 +13863,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/mood-status: @@ -13716,7 +13903,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/medication-compliance-status: @@ -13757,7 +13944,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/metric-status: @@ -13864,7 +14051,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/biomarker-assessment: @@ -13917,7 +14104,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/derived: @@ -14029,7 +14216,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/derived/batch: @@ -14069,7 +14256,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/score-history: @@ -14119,7 +14306,7 @@ paths: schema: $ref: "#/components/schemas/ScoreHistoryResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/glp1-plateau: @@ -14139,7 +14326,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/ecg: @@ -14161,7 +14348,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -14239,7 +14426,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -14271,7 +14458,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/correlations: @@ -14293,7 +14480,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/patterns: @@ -14311,7 +14498,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/patterns/{id}: @@ -14341,7 +14528,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: No current pattern with that id for this account. content: @@ -14366,7 +14553,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/breathing-screening: @@ -14385,7 +14572,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/insights/labs-changes: @@ -14404,7 +14591,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/mood/analytics: @@ -14427,7 +14614,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/mood/insights: @@ -14462,7 +14649,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -14485,7 +14672,7 @@ paths: schema: $ref: "#/components/schemas/MoodPrognosisEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/mood-entries/bulk: @@ -14599,7 +14786,7 @@ paths: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 "403": *a9 - "404": &a27 + "404": &a30 description: Not found / not owned by the caller. content: application/json: @@ -14635,7 +14822,7 @@ paths: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -14672,7 +14859,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -14727,7 +14914,7 @@ paths: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14759,7 +14946,7 @@ paths: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a30 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -14809,7 +14996,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 /api/mood-entries: get: @@ -14893,7 +15080,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntryListEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -14926,8 +15113,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a15 - "409": &a28 + "403": *a20 + "409": &a31 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -14936,7 +15123,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a12 /api/mood-entries/{id}: get: tags: @@ -14958,8 +15145,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a15 - "404": *a27 + "403": *a20 + "404": *a30 "422": *a4 "429": *a5 put: @@ -14993,9 +15180,9 @@ paths: schema: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 - "403": *a15 - "404": *a27 - "409": *a28 + "403": *a20 + "404": *a30 + "409": *a31 "422": *a4 "429": *a5 delete: @@ -15020,8 +15207,8 @@ paths: schema: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 - "403": *a15 - "404": *a27 + "403": *a20 + "404": *a30 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -15050,7 +15237,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesRestoreEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -15079,7 +15266,7 @@ paths: schema: $ref: "#/components/schemas/MoodEntriesBulkDeleteEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -15123,7 +15310,7 @@ paths: schema: $ref: "#/components/schemas/MoodLinkedContextEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/settings/ntfy: @@ -15172,7 +15359,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a29 + "400": &a32 description: Body is not parseable JSON. content: application/json: @@ -15187,13 +15374,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a30 + "413": &a33 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a31 + "415": &a34 description: Content-Type is not `application/json`. content: application/json: @@ -15254,7 +15441,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a29 + "400": *a32 "401": *a1 "403": description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the @@ -15270,8 +15457,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a30 - "415": *a31 + "413": *a33 + "415": *a34 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -15326,11 +15513,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a29 + "400": *a32 "401": *a1 "403": *a9 - "413": *a30 - "415": *a31 + "413": *a33 + "415": *a34 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -15357,7 +15544,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/settings/global-services: @@ -15978,7 +16165,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a32 + "400": &a35 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." @@ -16039,7 +16226,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a32 + "400": *a35 "401": *a1 "403": *a9 "422": *a4 @@ -16786,7 +16973,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -16811,7 +16998,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -16835,8 +17022,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "403": *a15 - "404": &a33 + "403": *a20 + "404": &a36 description: Measurement reminder not found / not owned. content: application/json: @@ -16873,8 +17060,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 delete: @@ -16896,8 +17083,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -16921,8 +17108,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -16949,8 +17136,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -16979,8 +17166,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -17013,8 +17200,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -17054,8 +17241,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 - "403": *a15 - "404": *a33 + "403": *a20 + "404": *a36 "422": *a4 "429": *a5 /api/labs: @@ -17133,7 +17320,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -17160,7 +17347,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": description: "Nothing was written. `meta.errorCode` = `labs.create.invalid` when the body failed validation (every issue @@ -17175,7 +17362,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/labs/overview: get: tags: @@ -17194,7 +17381,7 @@ paths: schema: $ref: "#/components/schemas/LabOverviewEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/labs/{id}: @@ -17217,8 +17404,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "403": *a15 - "404": &a34 + "403": *a20 + "404": &a37 description: Lab result not found (or owned by another user). content: application/json: @@ -17252,8 +17439,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "403": *a15 - "404": *a34 + "403": *a20 + "404": *a37 "422": *a4 "429": *a5 delete: @@ -17275,8 +17462,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "403": *a15 - "404": *a34 + "403": *a20 + "404": *a37 "422": *a4 "429": *a5 /api/labs/restore: @@ -17302,7 +17489,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -17364,7 +17551,7 @@ paths: schema: $ref: "#/components/schemas/OcrTextExtractRequest" responses: - "202": &a44 + "202": &a47 description: Queued for the background worker. Every refusal the route can answer quickly (capability, provider, rate limit, budget, validation) has already been answered; anything that goes wrong from here is reported on the run. @@ -17488,7 +17675,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -17513,7 +17700,7 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "403": *a15 + "403": *a20 "409": description: A biomarker with this name already exists for the caller. content: @@ -17521,7 +17708,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a12 /api/biomarkers/{id}: get: tags: @@ -17544,8 +17731,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "403": *a15 - "404": &a35 + "403": *a20 + "404": &a38 description: "Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one." @@ -17586,8 +17773,8 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "403": *a15 - "404": *a35 + "403": *a20 + "404": *a38 "409": description: "`labs.biomarker.duplicate`: a rename collides with another of the caller's markers. `labs.biomarker.alias.taken` (v1.43): one of `aliases` is already the name or an alias of another marker; @@ -17620,8 +17807,8 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "403": *a15 - "404": *a35 + "403": *a20 + "404": *a38 "422": *a4 "429": *a5 /api/biomarkers/{id}/series: @@ -17696,7 +17883,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a35 + "404": *a38 "422": description: "`labs.series.invalid`: an unknown `zoom`, a malformed date, a `range` without both ends or longer than 15 years, or `from` after `to`." @@ -17739,8 +17926,8 @@ paths: schema: $ref: "#/components/schemas/MergeBiomarkersResponse" "401": *a1 - "403": *a15 - "404": *a35 + "403": *a20 + "404": *a38 "409": *a7 "422": description: "`labs.biomarker.merge.invalid`: a malformed body, or the target among the sources (Zod issues in @@ -17751,7 +17938,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a16 + "429": *a12 /api/encounters: get: tags: @@ -17814,7 +18001,7 @@ paths: schema: $ref: "#/components/schemas/ListEncountersEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -17840,10 +18027,10 @@ paths: schema: $ref: "#/components/schemas/CreateEncounterEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/encounters/procedures: get: tags: @@ -17874,7 +18061,7 @@ paths: schema: $ref: "#/components/schemas/ListProceduresEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/body-sites: @@ -17911,7 +18098,7 @@ paths: schema: $ref: "#/components/schemas/ListBodySitesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/encounters/suggest: @@ -17941,7 +18128,7 @@ paths: schema: $ref: "#/components/schemas/SuggestEncounterEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/encounters/{id}: @@ -17964,8 +18151,8 @@ paths: schema: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 - "403": *a15 - "404": &a36 + "403": *a20 + "404": &a39 description: Visit not found (or owned by another user). content: application/json: @@ -17998,8 +18185,8 @@ paths: schema: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 - "403": *a15 - "404": *a36 + "403": *a20 + "404": *a39 "422": *a4 "429": *a5 delete: @@ -18023,8 +18210,8 @@ paths: schema: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 - "403": *a15 - "404": *a36 + "403": *a20 + "404": *a39 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -18049,8 +18236,8 @@ paths: schema: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 - "403": *a15 - "404": *a36 + "403": *a20 + "404": *a39 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -18082,7 +18269,7 @@ paths: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a36 + "404": *a39 "422": *a4 "429": *a5 delete: @@ -18112,7 +18299,7 @@ paths: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a36 + "404": *a39 "422": *a4 "429": *a5 /api/practitioners: @@ -18152,7 +18339,7 @@ paths: schema: $ref: "#/components/schemas/ListPractitionersEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18176,7 +18363,7 @@ paths: schema: $ref: "#/components/schemas/CreatePractitionerEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -18200,8 +18387,8 @@ paths: schema: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 - "403": *a15 - "404": &a37 + "403": *a20 + "404": &a40 description: Practitioner not found (or owned by another user). content: application/json: @@ -18234,8 +18421,8 @@ paths: schema: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 - "403": *a15 - "404": *a37 + "403": *a20 + "404": *a40 "422": *a4 "429": *a5 delete: @@ -18259,8 +18446,8 @@ paths: schema: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 - "403": *a15 - "404": *a37 + "403": *a20 + "404": *a40 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -18285,8 +18472,8 @@ paths: schema: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 - "403": *a15 - "404": *a37 + "403": *a20 + "404": *a40 "422": *a4 "429": *a5 /api/vaccinations/custom: @@ -18304,7 +18491,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomVaccinesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18329,8 +18516,8 @@ paths: schema: $ref: "#/components/schemas/CreateCustomVaccineEnvelope" "401": *a1 - "403": *a15 - "409": &a38 + "403": *a20 + "409": &a41 description: A live definition on the record already carries this name, compared without case (`vaccination.custom.name-taken`). content: @@ -18338,7 +18525,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a16 + "429": *a12 /api/vaccinations/custom/{id}: patch: tags: @@ -18366,14 +18553,14 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomVaccineEnvelope" "401": *a1 - "403": *a15 - "404": &a39 + "403": *a20 + "404": &a42 description: No live definition with this id on the record (`vaccination.custom.not-found`). content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "409": *a38 + "409": *a41 "422": *a4 "429": *a5 delete: @@ -18397,8 +18584,8 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomVaccineEnvelope" "401": *a1 - "403": *a15 - "404": *a39 + "403": *a20 + "404": *a42 "422": *a4 "429": *a5 /api/vaccinations: @@ -18451,7 +18638,7 @@ paths: schema: $ref: "#/components/schemas/ListVaccinationsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18478,7 +18665,7 @@ paths: schema: $ref: "#/components/schemas/CreateVaccinationEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: A practice, visit or own vaccine the body names is not the record's (`vaccination.practitioner-not-found`, `vaccination.encounter-not-found`, `vaccination.custom-vaccine-not-found`). @@ -18488,7 +18675,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/vaccinations/{id}: get: tags: @@ -18509,8 +18696,8 @@ paths: schema: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": &a40 + "403": *a20 + "404": &a43 description: Vaccination not found (or owned by another user). content: application/json: @@ -18544,8 +18731,8 @@ paths: schema: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": *a40 + "403": *a20 + "404": *a43 "422": *a4 "429": *a5 delete: @@ -18569,8 +18756,8 @@ paths: schema: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": *a40 + "403": *a20 + "404": *a43 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -18594,8 +18781,8 @@ paths: schema: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 - "403": *a15 - "404": *a40 + "403": *a20 + "404": *a43 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -18663,7 +18850,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a40 + "404": *a43 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -18691,7 +18878,7 @@ paths: schema: $ref: "#/components/schemas/VaccinationSuggestEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/vaccinations/{id}/links: @@ -18724,7 +18911,7 @@ paths: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a40 + "404": *a43 "422": *a4 "429": *a5 delete: @@ -18754,7 +18941,7 @@ paths: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a40 + "404": *a43 "422": *a4 "429": *a5 /api/illness/episodes: @@ -18818,7 +19005,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -18844,7 +19031,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -18868,8 +19055,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": &a41 + "403": *a20 + "404": &a44 description: Illness episode not found (or owned by another user). content: application/json: @@ -18903,8 +19090,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 delete: @@ -18927,8 +19114,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -18955,8 +19142,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "409": *a7 "422": *a4 "429": *a5 @@ -18988,8 +19175,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -19039,8 +19226,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 post: @@ -19076,8 +19263,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "403": *a15 - "404": *a41 + "403": *a20 + "404": *a44 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -19105,7 +19292,7 @@ paths: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 "403": *a9 - "404": *a41 + "404": *a44 "422": *a4 "429": *a5 /api/illness/insights: @@ -19179,7 +19366,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19212,7 +19399,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -19239,7 +19426,7 @@ paths: schema: $ref: "#/components/schemas/GetMentalHealthAssessmentEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/allergies: @@ -19272,7 +19459,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19297,10 +19484,10 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/allergies/{id}: get: tags: @@ -19321,8 +19508,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "403": *a15 - "404": &a42 + "403": *a20 + "404": &a45 description: Allergy not found (or owned by another user). content: application/json: @@ -19355,8 +19542,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "403": *a15 - "404": *a42 + "403": *a20 + "404": *a45 "422": *a4 "429": *a5 delete: @@ -19379,8 +19566,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "403": *a15 - "404": *a42 + "403": *a20 + "404": *a45 "422": *a4 "429": *a5 /api/family-history: @@ -19404,7 +19591,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19429,7 +19616,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -19453,8 +19640,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "403": *a15 - "404": &a43 + "403": *a20 + "404": &a46 description: Family history entry not found (or owned by another user). content: application/json: @@ -19487,8 +19674,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "403": *a15 - "404": *a43 + "403": *a20 + "404": *a46 "422": *a4 "429": *a5 delete: @@ -19511,8 +19698,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "403": *a15 - "404": *a43 + "403": *a20 + "404": *a46 "422": *a4 "429": *a5 /api/documents/inbound: @@ -19627,7 +19814,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -19920,7 +20107,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/documents/inbound/layout: @@ -19938,7 +20125,7 @@ paths: schema: $ref: "#/components/schemas/DocumentsLayoutEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 put: @@ -20009,7 +20196,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 patch: @@ -20186,7 +20373,7 @@ paths: type: string format: binary "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/documents/inbound/{id}/thumbnail: @@ -20215,7 +20402,7 @@ paths: type: string format: binary "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/documents/inbound/{id}/extract: @@ -20259,7 +20446,7 @@ paths: required: true schema: type: string - - &a45 + - &a48 name: Prefer in: header required: false @@ -20284,9 +20471,9 @@ paths: application/json: schema: $ref: "#/components/schemas/InboundExtractEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": &a46 + "403": &a49 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: AI refusal: the `documentAi` capability is unavailable for this record. The envelope carries `meta.errorCode`, `meta.capability` and @@ -20312,7 +20499,7 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - "503": &a47 + "503": &a50 description: "`ai.unavailable` — the inputs behind the AI capability could not be read, and the answer failed closed. Nothing was sent. Retry later." content: @@ -20342,7 +20529,7 @@ paths: required: true schema: type: string - - *a45 + - *a48 requestBody: required: false content: @@ -20378,12 +20565,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSuggestEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/summary: post: tags: @@ -20433,7 +20620,7 @@ paths: type: boolean default: false description: With `persist=true`, explicitly replace an existing stored summary. - - *a45 + - *a48 requestBody: required: false content: @@ -20469,12 +20656,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentSummaryEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/index: post: tags: @@ -20502,7 +20689,7 @@ paths: required: true schema: type: string - - *a45 + - *a48 requestBody: required: false content: @@ -20538,12 +20725,12 @@ paths: application/json: schema: $ref: "#/components/schemas/DocumentIndexEnvelope" - "202": *a44 + "202": *a47 "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/chat: post: tags: @@ -20583,10 +20770,10 @@ paths: type: string description: "SSE frames: `data: \\n\\n`. See the operation description for the per-`type` frame shapes." "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 get: tags: - Documents @@ -20650,10 +20837,10 @@ paths: schema: $ref: "#/components/schemas/DocumentReindexEnvelope" "401": *a1 - "403": *a46 + "403": *a49 "422": *a4 "429": *a5 - "503": *a47 + "503": *a50 /api/documents/inbound/{id}/facts/{factId}: patch: tags: @@ -20737,7 +20924,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 post: @@ -20763,7 +20950,7 @@ paths: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 "403": *a9 - "409": &a49 + "409": &a52 description: A custom metric with this name already exists for the caller. content: application/json: @@ -20791,8 +20978,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "403": *a15 - "404": &a48 + "403": *a20 + "404": &a51 description: Custom metric not found (or owned by another user). content: application/json: @@ -20827,8 +21014,8 @@ paths: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 "403": *a9 - "404": *a48 - "409": *a49 + "404": *a51 + "409": *a52 "422": *a4 "429": *a5 delete: @@ -20852,7 +21039,7 @@ paths: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 "403": *a9 - "404": *a48 + "404": *a51 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -20895,8 +21082,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "403": *a15 - "404": *a48 + "403": *a20 + "404": *a51 "422": *a4 "429": *a5 post: @@ -20926,10 +21113,10 @@ paths: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 "403": *a9 - "404": *a48 + "404": *a51 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/custom-metrics/{id}/entries/{entryId}: patch: tags: @@ -20963,7 +21150,7 @@ paths: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 "403": *a9 - "404": &a50 + "404": &a53 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -20997,7 +21184,7 @@ paths: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 "403": *a9 - "404": *a50 + "404": *a53 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -21305,7 +21492,7 @@ paths: schema: $ref: "#/components/schemas/NutrientCatalogEnvelope" "401": *a1 - "403": &a51 + "403": &a54 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: The opt-in `nutrients` module is @@ -21454,7 +21641,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a51 + "403": *a54 "422": *a4 "429": *a5 /api/nutrients/daily: @@ -21491,7 +21678,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a51 + "403": *a54 "422": *a4 "429": *a5 /api/nutrients/water: @@ -21520,7 +21707,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a51 + "403": *a54 "409": *a7 "422": *a4 "429": *a5 @@ -22232,7 +22419,7 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a52 + "400": &a55 description: Body is not parseable JSON. content: application/json: @@ -22240,19 +22427,19 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "403": *a9 - "413": &a53 + "413": &a56 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a54 + "415": &a57 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a55 + "422": &a58 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -22443,12 +22630,12 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22603,12 +22790,12 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22774,12 +22961,12 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -22859,21 +23046,21 @@ paths: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 "403": *a9 - "422": &a56 + "422": &a59 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a57 + "429": &a60 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a58 + "502": &a61 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -22919,12 +23106,12 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -23004,9 +23191,9 @@ paths: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/strava/credentials: get: tags: @@ -23043,12 +23230,12 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a52 + "400": *a55 "401": *a1 "403": *a9 - "413": *a53 - "415": *a54 - "422": *a55 + "413": *a56 + "415": *a57 + "422": *a58 "429": *a5 delete: tags: @@ -23130,9 +23317,9 @@ paths: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/status: get: tags: @@ -23170,7 +23357,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/fitbit/test: post: tags: @@ -23196,9 +23383,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/google-health/resume: post: tags: @@ -23216,7 +23403,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/google-health/test: post: tags: @@ -23250,9 +23437,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/whoop/resume: post: tags: @@ -23270,7 +23457,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/whoop/test: post: tags: @@ -23288,9 +23475,9 @@ paths: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 - "502": *a58 + "422": *a59 + "429": *a60 + "502": *a61 /api/integrations/withings/resume: post: tags: @@ -23309,7 +23496,7 @@ paths: "401": *a1 "403": *a9 "422": *a4 - "429": *a57 + "429": *a60 /api/integrations/withings/test: post: tags: @@ -23328,8 +23515,8 @@ paths: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 "403": *a9 - "422": *a56 - "429": *a57 + "422": *a59 + "429": *a60 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -23362,7 +23549,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/daily/digest/dismiss: @@ -23455,7 +23642,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSummaryEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/dashboard/widgets: @@ -23474,7 +23661,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 put: @@ -23509,7 +23696,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a19 + "422": *a22 "429": *a5 delete: tags: @@ -23747,7 +23934,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a59 + "403": &a62 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never @@ -23774,7 +23961,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "422": *a4 "429": *a5 post: @@ -23878,7 +24065,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "404": description: No such invitation for this account. content: @@ -23922,7 +24109,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "404": description: No such grant on this account's record. content: @@ -23963,7 +24150,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "404": description: No such grant held by this account. content: @@ -24362,7 +24549,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a59 + "403": *a62 "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 — @@ -24751,7 +24938,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a60 + "403": &a63 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as @@ -24785,7 +24972,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -24812,7 +24999,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "422": *a4 "429": *a5 post: @@ -24845,7 +25032,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "413": description: Body exceeds 16 KiB. content: @@ -24888,7 +25075,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a60 + "403": *a63 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -24915,7 +25102,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/record-settings/integrations: @@ -24934,7 +25121,7 @@ paths: schema: $ref: "#/components/schemas/RecordSettingsIntegrationsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/record-settings/integrations/{provider}/connect: @@ -24950,7 +25137,7 @@ paths: record (audited). Fitbit is not offered: its classic API is retired. Cookie session, while acting on a managed record the caller guards." parameters: - - &a61 + - &a64 name: provider in: path required: true @@ -24972,7 +25159,7 @@ paths: schema: $ref: "#/components/schemas/ManagedConnectStartEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: The path named a provider outside the closed set. Answered before the caller is authenticated. content: @@ -24997,7 +25184,7 @@ paths: Audited against the record with the Guardian as the actor. Cookie session or Bearer, while acting on a managed record the caller guards." parameters: - - *a61 + - *a64 responses: "200": description: The provider is disconnected. @@ -25006,7 +25193,7 @@ paths: schema: $ref: "#/components/schemas/ManagedDisconnectEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: The path named a provider outside the closed set, or the record has no connection to it. content: @@ -25023,7 +25210,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a62 + - &a65 name: family in: path required: true @@ -25047,7 +25234,7 @@ paths: schema: $ref: "#/components/schemas/GetRecordSettingsFamilyEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: Either the path named a family outside the closed set, or the record row itself is gone. The two are not distinguished, and the first is answered before the caller is authenticated at all. @@ -25067,7 +25254,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a62 + - *a65 requestBody: required: true content: @@ -25088,7 +25275,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: The path named a family outside the closed set. Answered before authentication. content: @@ -25202,7 +25389,7 @@ paths: schema: $ref: "#/components/schemas/ListPersonalRecordsEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": description: "`metricType` or `limit` carried an unacceptable value. `meta.errorCode` = `personal_records.invalid_query`, with the offending path under `details.issues`." @@ -25255,7 +25442,7 @@ paths: schema: $ref: "#/components/schemas/AchievementsEnvelope" "401": *a1 - "403": *a17 + "403": *a21 "422": *a4 "429": *a5 /api/environment/preferences: @@ -25281,7 +25468,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentPreferencesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment: @@ -25299,7 +25486,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment/home: @@ -25336,7 +25523,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -25396,7 +25583,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: No home location is set, so there is nothing to resolve the days against (`meta.errorCode` = `environment.no_home`), or a backfill for this account is already queued in the current twenty-minute slot @@ -25456,7 +25643,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -25495,7 +25682,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -25548,7 +25735,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -25780,7 +25967,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a25 + "403": *a28 "404": description: The account chosen with `into=existing` does not exist (`backup.target.not_found`). Nothing was stored. content: @@ -25849,13 +26036,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a63 + "403": &a66 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a64 + "404": &a67 description: No stored copy with this id. content: application/json: @@ -25929,8 +26116,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a63 - "404": *a64 + "403": *a66 + "404": *a67 "409": description: A restore of the same account is already queued or running (`meta.errorCode` = `backup.restore.active`, `meta.jobId` names it), the stored copy was replaced by a newer one while it was being checked @@ -25974,7 +26161,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobListResponse" - "403": *a63 + "403": *a66 /api/admin/backups/restores/{jobId}: get: tags: @@ -25997,7 +26184,7 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupRestoreJobResponse" - "403": *a63 + "403": *a66 "404": description: No restore job with this id. content: @@ -26016,7 +26203,7 @@ paths: request leaves the server. Needs neither the Documents module nor the list: with the list unset, `available` is false and any connection still stored is listed (with `originAllowed: false`) so it can be removed. Cookie session only; not delegable." - security: &a65 + security: &a68 - cookieAuth: [] responses: "200": @@ -26026,7 +26213,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourcesStatusEnvelope" "401": *a1 - "403": &a66 + "403": &a69 description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Refused before any request to the source: `documents.sources.browserOnly` for a Bearer caller, `sharing.not_permitted` while acting on another @@ -26051,9 +26238,9 @@ paths: `documents.sources.tokenRequired`), so a stored token is never sent to an address it was not saved for. A Papra organization id is letters, digits, `_` and `-`. The token is stored encrypted (AES-256-GCM) and never returned. Ten saves and tests a minute per person. Cookie session only; not delegable." - security: *a65 + security: *a68 parameters: - - &a67 + - &a70 name: system in: path required: true @@ -26092,8 +26279,8 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceConnectionEnvelope" "401": *a1 - "403": *a66 - "404": &a68 + "403": *a69 + "404": &a71 description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. `documents.sources.notFound`: the source has no such document, or (Papra) no such organization, including @@ -26112,7 +26299,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": &a69 + "502": &a72 description: "The source could not answer usefully, with the reason in `meta.errorCode` and, when the source answered at all, its HTTP status in `meta.upstreamStatus` (its body is never passed on): `documents.sources.unreachable` (no connection, timeout, or a destination refused at dial time), `documents.sources.redirected` (the source @@ -26131,9 +26318,9 @@ paths: description: Deletes the caller's connection for this system, and its token with it. Works even after the operator removed the origin from the list, and with the Documents module switched off. `disconnected` is false when there was nothing to delete. Cookie session only; not delegable. - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 responses: "200": description: Removed, or there was nothing to remove. @@ -26142,7 +26329,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceDisconnectEnvelope" "401": *a1 - "403": *a66 + "403": *a69 "422": *a4 "429": *a5 /api/documents/sources/{system}/test: @@ -26153,9 +26340,9 @@ paths: description: One request to the source with the stored token. Answers `ok` with the round-trip time and stamps `lastVerifiedAt`, or the reason in `meta.errorCode`. Shares the save's allowance of ten a minute. Cookie session only; not delegable. - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 responses: "200": description: The source answered. @@ -26164,9 +26351,9 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTestEnvelope" "401": *a1 - "403": *a66 - "404": *a68 - "422": &a70 + "403": *a69 + "404": *a71 + "422": &a73 description: "`documents.sources.originNotAllowed`: the base address's origin is not on the operator's list (checked on every request, so a connection saved earlier is cut off once its origin is removed), or another validation failure." @@ -26175,7 +26362,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "502": *a69 + "502": *a72 /api/documents/sources/{system}/tags: get: tags: @@ -26183,9 +26370,9 @@ paths: summary: Tags of a connected archive description: The source's tags for the picker's filter, the first 250 by name, in the source's own ids. Shares the search allowance of sixty a minute. Cookie session only; not delegable. - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 responses: "200": description: The tags. @@ -26194,11 +26381,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceTagsEnvelope" "401": *a1 - "403": *a66 - "404": *a68 - "422": *a70 + "403": *a69 + "404": *a71 + "422": *a73 "429": *a5 - "502": *a69 + "502": *a72 /api/documents/sources/{system}/search: get: tags: @@ -26212,9 +26399,9 @@ paths: under that source key: `new`, `imported` with the vault document's `documentId`, or `deleted` (deleted in HealthLog; importing it again stores nothing). Nothing is stored. Sixty searches a minute, shared with the tag read. Cookie session only; not delegable." - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 - name: q in: query required: false @@ -26255,11 +26442,11 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceSearchEnvelope" "401": *a1 - "403": *a66 - "404": *a68 - "422": *a70 + "403": *a69 + "404": *a71 + "422": *a73 "429": *a5 - "502": *a69 + "502": *a72 /api/documents/sources/{system}/import: post: tags: @@ -26281,9 +26468,9 @@ paths: fetched), including a duplicate. `kind` sets the document type (default OTHER). Automatic AI reading follows the person's own setting. Counts against the person's upload allowance (60 an hour) and storage quota. 201 for a new document, 200 otherwise. Cookie session only; not delegable." - security: *a65 + security: *a68 parameters: - - *a67 + - *a70 requestBody: required: true content: @@ -26344,7 +26531,7 @@ paths: schema: $ref: "#/components/schemas/DocumentSourceImportEnvelope" "401": *a1 - "403": *a66 + "403": *a69 "404": description: "`documents.sources.unavailable`: the operator has not listed any origin in `DOCUMENT_SOURCE_ORIGINS`, so the picker is off. `documents.sources.notConnected`: no connection is saved for this system. @@ -26375,7 +26562,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a70 + "422": *a73 "429": description: "`documents.inbound.rateLimited` (the person's hourly upload allowance, with the rate-limit headers) or `documents.inbound.uploadBusy` (too many uploads in progress; `Retry-After: 1`)." @@ -26383,7 +26570,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": *a69 + "502": *a72 /api/symptoms/definitions: get: tags: @@ -26407,7 +26594,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomDefinitionsEnvelope" "401": *a1 - "403": &a71 + "403": &a74 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: `illness.disabled`: the record @@ -26451,7 +26638,7 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomDefinitionEnvelope" "401": *a1 - "403": *a71 + "403": *a74 "409": *a7 "422": *a4 "429": *a5 @@ -26481,8 +26668,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomDefinitionEnvelope" "401": *a1 - "403": *a71 - "404": &a72 + "403": *a74 + "404": &a75 description: "`symptoms.definition.notFound`: no definition with this id on the record." content: application/json: @@ -26516,8 +26703,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomDefinitionEnvelope" "401": *a1 - "403": *a71 - "404": *a72 + "403": *a74 + "404": *a75 "422": *a4 "429": *a5 /api/symptoms/events: @@ -26566,7 +26753,7 @@ paths: schema: $ref: "#/components/schemas/ListSymptomEventsEnvelope" "401": *a1 - "403": *a71 + "403": *a74 "422": *a4 "429": *a5 post: @@ -26592,8 +26779,8 @@ paths: schema: $ref: "#/components/schemas/CreateSymptomEventEnvelope" "401": *a1 - "403": *a71 - "404": *a72 + "403": *a74 + "404": *a75 "409": *a7 "422": *a4 "429": *a5 @@ -26623,8 +26810,8 @@ paths: schema: $ref: "#/components/schemas/UpdateSymptomEventEnvelope" "401": *a1 - "403": *a71 - "404": &a73 + "403": *a74 + "404": &a76 description: "`symptoms.event.notFound`: no occurrence with this id on the record." content: application/json: @@ -26651,8 +26838,8 @@ paths: schema: $ref: "#/components/schemas/DeleteSymptomEventEnvelope" "401": *a1 - "403": *a71 - "404": *a73 + "403": *a74 + "404": *a76 "422": *a4 "429": *a5 /api/day/index: @@ -26685,7 +26872,7 @@ paths: schema: $ref: "#/components/schemas/DayIndexEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/day/notable: @@ -26720,7 +26907,7 @@ paths: schema: $ref: "#/components/schemas/DayNotableWindowEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/day/{date}: @@ -26750,7 +26937,7 @@ paths: schema: $ref: "#/components/schemas/DayEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/timeline: @@ -26811,7 +26998,7 @@ paths: schema: $ref: "#/components/schemas/TimelineEnvelope" "401": *a1 - "403": &a74 + "403": &a77 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: `module.disabled` with @@ -26850,7 +27037,7 @@ paths: schema: $ref: "#/components/schemas/TimelineReadinessEnvelope" "401": *a1 - "403": *a74 + "403": *a77 "422": *a4 "429": *a5 /api/life-events: @@ -26868,7 +27055,7 @@ paths: schema: $ref: "#/components/schemas/LifeEventListEnvelope" "401": *a1 - "403": &a75 + "403": &a78 description: >- `meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the generic auth gate answers it before the route's own checks run. Otherwise: Owner-only in v1.42: no share @@ -26910,10 +27097,10 @@ paths: schema: $ref: "#/components/schemas/CreateLifeEventEnvelope" "401": *a1 - "403": *a75 + "403": *a78 "409": *a7 "422": *a4 - "429": *a16 + "429": *a12 /api/life-events/{id}: patch: tags: @@ -26941,8 +27128,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLifeEventEnvelope" "401": *a1 - "403": *a75 - "404": &a76 + "403": *a78 + "404": &a79 description: No such life event on this account. content: application/json: @@ -26969,8 +27156,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLifeEventEnvelope" "401": *a1 - "403": *a75 - "404": *a76 + "403": *a78 + "404": *a79 "422": *a4 "429": *a5 /api/search: @@ -27016,7 +27203,7 @@ paths: schema: $ref: "#/components/schemas/SearchEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/auth/me/ai-usage: @@ -27145,7 +27332,7 @@ paths: required: - file responses: - "202": *a44 + "202": *a47 "401": *a1 "403": description: "`meta.errorCode` = `auth.scope.insufficient` when a Bearer token's scope does not reach this route; the @@ -27181,7 +27368,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 - "503": *a26 + "503": *a29 /api/anamnesis/life-circumstances: get: tags: @@ -27196,7 +27383,7 @@ paths: schema: $ref: "#/components/schemas/LifeCircumstancesEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "422": *a4 "429": *a5 /api/anamnesis/life-circumstances/ai-sections: @@ -27285,7 +27472,7 @@ paths: schema: $ref: "#/components/schemas/AuditCCreatedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "409": *a7 "422": *a4 "429": *a5 @@ -27309,7 +27496,7 @@ paths: schema: $ref: "#/components/schemas/AuditCRemovedEnvelope" "401": *a1 - "403": *a15 + "403": *a20 "404": description: "`anamnesis.auditC.notFound`." content: @@ -27340,7 +27527,7 @@ paths: schema: $ref: "#/components/schemas/DawarichConnectEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/dawarich/status: @@ -27356,7 +27543,7 @@ paths: schema: $ref: "#/components/schemas/DawarichStatusEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/dawarich/places: @@ -27380,7 +27567,7 @@ paths: schema: $ref: "#/components/schemas/DawarichPlacesEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: "`dawarich.not_connected`." content: @@ -27418,7 +27605,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSettingsEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: "`dawarich.not_connected`." content: @@ -27450,7 +27637,7 @@ paths: schema: $ref: "#/components/schemas/DawarichProbeEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 "502": @@ -27486,7 +27673,7 @@ paths: schema: $ref: "#/components/schemas/DawarichSyncEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "409": description: "`dawarich.not_connected`." content: @@ -27521,7 +27708,7 @@ paths: schema: $ref: "#/components/schemas/DawarichDisconnectEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment/travel/suggestions: @@ -27539,7 +27726,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionsEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "422": *a4 "429": *a5 /api/environment/travel/suggestions/{id}/dismiss: @@ -27562,7 +27749,7 @@ paths: schema: $ref: "#/components/schemas/TravelSuggestionDismissEnvelope" "401": *a1 - "403": *a11 + "403": *a16 "404": description: "`environment.travel_suggestion.not_found`." content: @@ -28508,6 +28695,27 @@ components: - type: "null" description: Partial cycle-preferences deep-merge. `enabled` flips the feature gate (`cycleTrackingEnabled`). Omitted fields are left untouched. + CycleFeedOptionsPatch: + type: object + properties: + horizonCycles: + type: integer + minimum: 1 + maximum: 6 + historyCycles: + type: integer + minimum: 0 + maximum: 12 + includeFertile: + type: boolean + titleMode: + type: string + enum: + - DISCREET + - DESCRIPTIVE + additionalProperties: false + description: "The writable options. At least one field. `includeFertile: true` is refused (422 + `cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE." HealthRecordExportRequest: type: object properties: @@ -39454,26 +39662,27 @@ components: coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, - cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, - cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, - cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; - dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, - dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — - demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, - documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, - documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, - documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, - documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, - documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, - documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, - documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, - documents.inbound.pdfRendererUnavailable, documents.inbound.providerUnsupported, - documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, - documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, - documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, - documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, - documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, - documents.sources.organizationRequired, documents.sources.originNotAllowed, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.feed.api_disabled, + cycle.feed.cookie_only, cycle.feed.exists, cycle.feed.fertile_goal, cycle.feed.none, + cycle.feed.notFound, cycle.feed.rateLimited, cycle.period.invalid, cycle.symptom.custom.invalid, + cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; + disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — + dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, + dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — + documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, + documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, + documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, + documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, + documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, + documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, + documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, + documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfRendererUnavailable, + documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, + documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, + documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, + documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, + documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, + documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -40216,6 +40425,12 @@ components: $ref: "#/components/schemas/CycleVerdictDTO" stillLearning: type: boolean + projection: + type: array + items: + $ref: "#/components/schemas/ProjectedCycleDTO" + projectionState: + $ref: "#/components/schemas/CycleProjectionState" days: type: array items: @@ -40358,6 +40573,8 @@ components: - prediction - verdict - stillLearning + - projection + - projectionState - days - meta additionalProperties: false @@ -40541,6 +40758,53 @@ components: - FOLLICULAR - OVULATORY - LUTEAL + ProjectedCycleDTO: + type: object + properties: + k: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: 1 = the next period (the engine's own forecast), 2 = the one after it, … + start: + type: string + format: date + example: 2026-11-12 + description: The most likely first day. + periodLength: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Predicted bleeding days. + bandStart: + type: string + format: date + example: 2026-11-12 + description: The earliest likely first day. + bandEnd: + type: string + format: date + example: 2026-11-12 + description: The latest likely first day. + required: + - k + - start + - periodLength + - bandStart + - bandEnd + additionalProperties: false + description: A forecast period start with its band. The band widens from one cycle to the next and the list stops itself + where it would pass ±10 days or half a cycle. Render it as given; never extrapolate further cycles on a client. + CycleProjectionState: + type: string + enum: + - OK + - LEARNING + - LATE + - OFF + description: "Why `projection` holds what it holds. `OK`: the projected cycles. `LEARNING`: fewer than three observed + cycles, so the next period only (or nothing with no logged cycle). `LATE`: the next period's band has passed + with no new period logged; no forecast until one is. `OFF`: prediction is off or raw-chart mode is on." CycleHistoryEnvelope: type: object properties: @@ -41008,6 +41272,186 @@ components: - data - error additionalProperties: false + CycleFeedStatus: + type: object + properties: + data: + type: object + properties: + feed: + anyOf: + - $ref: "#/components/schemas/CycleFeedSummary" + - type: "null" + projectionState: + anyOf: + - $ref: "#/components/schemas/CycleProjectionState" + - type: "null" + fertileAvailable: + type: boolean + description: Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only). + required: + - feed + - projectionState + - fertileAvailable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleFeedSummary: + type: object + properties: + tokenPrefix: + type: string + description: The first 8 characters of the link's token, to tell links apart. + createdAt: + 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)))$ + lastFetchedAt: + 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" + fetchCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + lastFetcher: + anyOf: + - type: string + enum: + - GOOGLE + - APPLE + - MICROSOFT + - OTHER + - type: "null" + description: Which calendar app fetched last, as a family; never the raw User-Agent. + horizonCycles: + type: integer + minimum: 1 + maximum: 6 + historyCycles: + type: integer + minimum: 0 + maximum: 12 + includeFertile: + type: boolean + titleMode: + type: string + enum: + - DISCREET + - DESCRIPTIVE + required: + - tokenPrefix + - createdAt + - lastFetchedAt + - fetchCount + - lastFetcher + - horizonCycles + - historyCycles + - includeFertile + - titleMode + additionalProperties: false + description: "The live calendar subscription, without its secret: never the token hash and never the URL." + CycleFeedMinted: + type: object + properties: + data: + type: object + properties: + url: + type: string + description: The feed URL. Returned exactly once; it cannot be read back later. + webcalUrl: + type: string + description: The same URL with the `webcal:` scheme, which opens a calendar app's subscribe dialog. + feed: + $ref: "#/components/schemas/CycleFeedSummary" + required: + - url + - webcalUrl + - feed + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleFeedStatusAfterPatch: + type: object + properties: + data: + type: object + properties: + feed: + anyOf: + - $ref: "#/components/schemas/CycleFeedSummary" + - type: "null" + projectionState: + anyOf: + - $ref: "#/components/schemas/CycleProjectionState" + - type: "null" + fertileAvailable: + type: boolean + description: Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only). + required: + - feed + - projectionState + - fertileAvailable + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CycleFeedRevoked: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + const: true + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false MeasurementCategoriesEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index c958b77cb..6bee77d3c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Periode hat an diesem Tag begonnen", "endedPeriodOnDate": "Periode an diesem Tag beendet", "startAbsorbed": "Periodenbeginn gespeichert. Dein Beginn am {absorbed} wurde mit dem {date} zusammengeführt.", - "startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da." + "startRestored": "Gespeichert. Der Beginn am {dates} ist wieder da.", + "feed": { + "title": "Kalender-Abo", + "description": "Deine nächsten Perioden als ganztägige Termine in der Kalender-App, die du schon nutzt.", + "disclosure": "Dein Kalenderanbieter (Google, iCloud, Outlook) speichert diese Daten auf seinen Servern. Schaltest du das Abo später ab, bleiben Kopien, die er schon hat, dort liegen.", + "turnOn": "Abo einschalten", + "error": "Das Kalender-Abo konnte nicht geändert werden. Versuch es noch einmal.", + "linkHidden": "Ein Link, der mit {prefix} beginnt, ist aktiv. Er wird nur einmal gezeigt, beim Erstellen; erneuere ihn, wenn du einen neuen brauchst.", + "linkLabel": "Abo-Link", + "linkOnce": "Nur jetzt sichtbar. Füge ihn in deine Kalender-App ein oder erneuere ihn später für einen neuen.", + "copy": "Link kopieren", + "copied": "Link kopiert.", + "copyFailed": "Kopieren hat nicht geklappt. Markiere den Link und kopiere ihn von Hand.", + "openInCalendar": "In Kalender-App öffnen", + "notFetched": "Noch nicht abgerufen.", + "lastFetched": "Zuletzt {when} von {fetcher} abgerufen.", + "fetcher": { + "GOOGLE": "Google Kalender", + "APPLE": "Apple Kalender", + "MICROSOFT": "Outlook", + "OTHER": "einer Kalender-App" + }, + "horizon": "Zyklen voraus", + "history": "Vergangene Zyklen", + "historyNone": "Keine", + "titles": "Termintitel", + "titleMode": { + "DISCREET": "Diskret (●)", + "DESCRIPTIVE": "Beschreibend" + }, + "fertile": "Fruchtbares Fenster", + "fertileDescription": "Fügt das geschätzte fruchtbare Fenster des nächsten Zyklus hinzu. Eine Schätzung, keine Verhütungsmethode.", + "rotate": "Link erneuern", + "rotateTitle": "Link erneuern?", + "rotateBody": "Kalender mit dem alten Link werden nicht mehr aktualisiert. Du bekommst einen neuen Link, den du stattdessen einträgst.", + "turnOff": "Ausschalten", + "turnOffTitle": "Kalender-Abo ausschalten?", + "turnOffBody": "Der Link funktioniert nicht mehr. Kopien bei deinem Kalenderanbieter bleiben, bis du den Kalender dort entfernst.", + "help": { + "title": "So trägst du es ein", + "google": "Google Kalender: Weitere Kalender, Per URL, dann den Link einfügen. Google aktualisiert etwa alle 12 bis 24 Stunden.", + "apple": "Apple Kalender: am Mac Ablage, Neues Kalenderabonnement; am iPhone Einstellungen, Kalender, Accounts, Account hinzufügen, Andere, Kalenderabo hinzufügen.", + "outlook": "Outlook: Kalender hinzufügen, Aus dem Internet abonnieren, dann den Link einfügen. Outlook aktualisiert alle paar Stunden." + }, + "ics": { + "calendarName": "Zyklus (HealthLog)", + "periodEstimate": "Periode (Schätzung)", + "periodLogged": "Periode (erfasst)", + "fertileEstimate": "Fruchtbares Fenster (Schätzung)", + "likelyStart": "Wahrscheinlicher Beginn {date}, {band}", + "bandOne": "±{count} Tag", + "bandFew": "±{count} Tage", + "bandOther": "±{count} Tage" + } + }, + "projection": { + "title": "Spätere Zyklen", + "around": "Um den {date}", + "state": { + "LEARNING": "Bis 3 Zyklen erfasst sind, ist nur die nächste Periode enthalten.", + "LATE": "Keine Prognose, solange diese Periode später kommt als erwartet; sie ist zurück, sobald du sie erfasst.", + "OFF": "Prognosen sind ausgeschaltet; enthalten sind nur vergangene Zyklen, falls gewählt." + } + } }, "cycleReminders": { "periodSoonTitle": "Periode steht wahrscheinlich bevor", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} hat Biomarker zusammengeführt", "medicationIngredientsUpdate": "{name} hat die Zusammensetzung eines Präparats geändert", "auditCCreate": "{name} hat den AUDIT-C-Fragebogen ausgefüllt", - "auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht" + "auditCDelete": "{name} hat einen AUDIT-C-Eintrag gelöscht", + "cycleFeedCreate": "{name} hat das Kalender-Abo eingeschaltet", + "cycleFeedUpdate": "{name} hat das Kalender-Abo geändert", + "cycleFeedRotate": "{name} hat den Link des Kalender-Abos erneuert", + "cycleFeedRevoke": "{name} hat das Kalender-Abo ausgeschaltet" }, "section": { "measurements": { diff --git a/messages/en.json b/messages/en.json index 9e061c059..c8895686e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "My period started this day", "endedPeriodOnDate": "My period ended this day", "startAbsorbed": "Period start saved. Your start on {absorbed} was merged into {date}.", - "startRestored": "Saved. The start on {dates} is back." + "startRestored": "Saved. The start on {dates} is back.", + "feed": { + "title": "Calendar subscription", + "description": "Your next periods as all-day events in the calendar app you already use.", + "disclosure": "Your calendar provider (Google, iCloud, Outlook) stores these dates on its servers. Turning this off later does not remove copies it already holds.", + "turnOn": "Turn on subscription", + "error": "The calendar subscription could not be changed. Try again.", + "linkHidden": "A link starting with {prefix} is active. It is shown only once, when it is made; rotate it to get a new one.", + "linkLabel": "Subscription link", + "linkOnce": "Shown only now. Paste it into your calendar app, or rotate it later for a new one.", + "copy": "Copy link", + "copied": "Link copied.", + "copyFailed": "Copying did not work. Select the link and copy it by hand.", + "openInCalendar": "Open in calendar app", + "notFetched": "Not fetched yet.", + "lastFetched": "Last fetched {when} by {fetcher}.", + "fetcher": { + "GOOGLE": "Google Calendar", + "APPLE": "Apple Calendar", + "MICROSOFT": "Outlook", + "OTHER": "a calendar app" + }, + "horizon": "Cycles ahead", + "history": "Past cycles", + "historyNone": "None", + "titles": "Event titles", + "titleMode": { + "DISCREET": "Discreet (●)", + "DESCRIPTIVE": "Descriptive" + }, + "fertile": "Fertile window", + "fertileDescription": "Adds the estimated fertile window of the next cycle. An estimate, not a contraceptive method.", + "rotate": "Rotate link", + "rotateTitle": "Rotate the link?", + "rotateBody": "Calendars using the old link stop updating. You get a new link to add instead.", + "turnOff": "Turn off", + "turnOffTitle": "Turn off the calendar subscription?", + "turnOffBody": "The link stops working. Copies your calendar provider already holds stay until you remove the calendar there.", + "help": { + "title": "Adding it to your calendar", + "google": "Google Calendar: Other calendars, From URL, then paste the link. Google refreshes about every 12 to 24 hours.", + "apple": "Apple Calendar: on a Mac, File, New Calendar Subscription; on iPhone, Settings, Calendar, Accounts, Add Account, Other, Add Subscribed Calendar.", + "outlook": "Outlook: Add calendar, Subscribe from web, then paste the link. Outlook refreshes every few hours." + }, + "ics": { + "calendarName": "Cycle (HealthLog)", + "periodEstimate": "Period (estimate)", + "periodLogged": "Period (logged)", + "fertileEstimate": "Fertile window (estimate)", + "likelyStart": "Most likely start {date}, {band}", + "bandOne": "±{count} day", + "bandFew": "±{count} days", + "bandOther": "±{count} days" + } + }, + "projection": { + "title": "Later cycles", + "around": "Around {date}", + "state": { + "LEARNING": "Until 3 cycles are logged, only the next period is included.", + "LATE": "No forecast while this period is later than expected; it returns once you log it.", + "OFF": "Forecasts are turned off; only past cycles are included, if chosen." + } + } }, "cycleReminders": { "periodSoonTitle": "Period likely soon", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} merged biomarkers", "medicationIngredientsUpdate": "{name} changed what a supplement contains", "auditCCreate": "{name} answered the AUDIT-C questions", - "auditCDelete": "{name} removed an AUDIT-C entry" + "auditCDelete": "{name} removed an AUDIT-C entry", + "cycleFeedCreate": "{name} turned on the calendar subscription", + "cycleFeedUpdate": "{name} changed the calendar subscription", + "cycleFeedRotate": "{name} replaced the calendar subscription link", + "cycleFeedRevoke": "{name} turned off the calendar subscription" }, "section": { "measurements": { diff --git a/messages/es.json b/messages/es.json index b8abc50ff..b35004a26 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Mi periodo empezó este día", "endedPeriodOnDate": "Mi periodo terminó este día", "startAbsorbed": "Inicio del periodo guardado. Tu inicio del {absorbed} se ha unido al del {date}.", - "startRestored": "Guardado. El inicio del {dates} vuelve a estar." + "startRestored": "Guardado. El inicio del {dates} vuelve a estar.", + "feed": { + "title": "Suscripción de calendario", + "description": "Tus próximos periodos como eventos de todo el día en la app de calendario que ya usas.", + "disclosure": "Tu proveedor de calendario (Google, iCloud, Outlook) guarda estas fechas en sus servidores. Desactivarlo más tarde no borra las copias que ya tiene.", + "turnOn": "Activar suscripción", + "error": "No se pudo cambiar la suscripción de calendario. Inténtalo de nuevo.", + "linkHidden": "Hay activo un enlace que empieza por {prefix}. Solo se muestra una vez, al crearlo; renuévalo para obtener uno nuevo.", + "linkLabel": "Enlace de suscripción", + "linkOnce": "Solo se muestra ahora. Pégalo en tu app de calendario o renuévalo más tarde para obtener uno nuevo.", + "copy": "Copiar enlace", + "copied": "Enlace copiado.", + "copyFailed": "No se pudo copiar. Selecciona el enlace y cópialo a mano.", + "openInCalendar": "Abrir en la app de calendario", + "notFetched": "Aún no se ha consultado.", + "lastFetched": "Última consulta {when} por {fetcher}.", + "fetcher": { + "GOOGLE": "Google Calendar", + "APPLE": "Calendario de Apple", + "MICROSOFT": "Outlook", + "OTHER": "una app de calendario" + }, + "horizon": "Ciclos por delante", + "history": "Ciclos pasados", + "historyNone": "Ninguno", + "titles": "Títulos de los eventos", + "titleMode": { + "DISCREET": "Discreto (●)", + "DESCRIPTIVE": "Descriptivo" + }, + "fertile": "Ventana fértil", + "fertileDescription": "Añade la ventana fértil estimada del próximo ciclo. Una estimación, no un método anticonceptivo.", + "rotate": "Renovar enlace", + "rotateTitle": "¿Renovar el enlace?", + "rotateBody": "Los calendarios que usan el enlace antiguo dejan de actualizarse. Recibes un enlace nuevo para añadirlo en su lugar.", + "turnOff": "Desactivar", + "turnOffTitle": "¿Desactivar la suscripción de calendario?", + "turnOffBody": "El enlace deja de funcionar. Las copias que ya tiene tu proveedor de calendario se quedan hasta que elimines allí el calendario.", + "help": { + "title": "Añadirlo a tu calendario", + "google": "Google Calendar: Otros calendarios, Desde URL y pega el enlace. Google actualiza más o menos cada 12 a 24 horas.", + "apple": "Calendario de Apple: en Mac, Archivo, Nueva suscripción a calendario; en iPhone, Ajustes, Calendario, Cuentas, Añadir cuenta, Otra, Añadir calendario suscrito.", + "outlook": "Outlook: Agregar calendario, Suscribirse desde la web y pega el enlace. Outlook actualiza cada pocas horas." + }, + "ics": { + "calendarName": "Ciclo (HealthLog)", + "periodEstimate": "Periodo (estimación)", + "periodLogged": "Periodo (registrado)", + "fertileEstimate": "Ventana fértil (estimación)", + "likelyStart": "Inicio más probable {date}, {band}", + "bandOne": "±{count} día", + "bandFew": "±{count} días", + "bandOther": "±{count} días" + } + }, + "projection": { + "title": "Ciclos siguientes", + "around": "Hacia el {date}", + "state": { + "LEARNING": "Hasta que haya 3 ciclos registrados, solo se incluye el próximo periodo.", + "LATE": "Sin previsión mientras este periodo llegue más tarde de lo esperado; vuelve en cuanto lo registres.", + "OFF": "Las previsiones están desactivadas; solo se incluyen ciclos pasados, si los elegiste." + } + } }, "cycleReminders": { "periodSoonTitle": "El periodo llegará pronto", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} combinó biomarcadores", "medicationIngredientsUpdate": "{name} cambió la composición de un suplemento", "auditCCreate": "{name} respondió el cuestionario AUDIT-C", - "auditCDelete": "{name} eliminó una entrada del AUDIT-C" + "auditCDelete": "{name} eliminó una entrada del AUDIT-C", + "cycleFeedCreate": "{name} activó la suscripción de calendario", + "cycleFeedUpdate": "{name} cambió la suscripción de calendario", + "cycleFeedRotate": "{name} renovó el enlace de la suscripción de calendario", + "cycleFeedRevoke": "{name} desactivó la suscripción de calendario" }, "section": { "measurements": { diff --git a/messages/fr.json b/messages/fr.json index 8a5e789cc..ea6b664bb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Mes règles ont commencé ce jour-là", "endedPeriodOnDate": "Mes règles se sont terminées ce jour-là", "startAbsorbed": "Début des règles enregistré. Votre début du {absorbed} a été fusionné avec celui du {date}.", - "startRestored": "Enregistré. Le début du {dates} est de retour." + "startRestored": "Enregistré. Le début du {dates} est de retour.", + "feed": { + "title": "Abonnement au calendrier", + "description": "Vos prochaines règles en événements sur la journée, dans l'application de calendrier que vous utilisez déjà.", + "disclosure": "Votre fournisseur de calendrier (Google, iCloud, Outlook) conserve ces dates sur ses serveurs. Désactiver l'abonnement plus tard ne supprime pas les copies qu'il détient déjà.", + "turnOn": "Activer l'abonnement", + "error": "L'abonnement au calendrier n'a pas pu être modifié. Réessayez.", + "linkHidden": "Un lien commençant par {prefix} est actif. Il n'est affiché qu'une fois, à sa création ; renouvelez-le pour en obtenir un nouveau.", + "linkLabel": "Lien d'abonnement", + "linkOnce": "Affiché uniquement maintenant. Collez-le dans votre application de calendrier, ou renouvelez-le plus tard pour en obtenir un nouveau.", + "copy": "Copier le lien", + "copied": "Lien copié.", + "copyFailed": "La copie n'a pas fonctionné. Sélectionnez le lien et copiez-le manuellement.", + "openInCalendar": "Ouvrir dans l'application de calendrier", + "notFetched": "Pas encore récupéré.", + "lastFetched": "Dernière récupération {when} par {fetcher}.", + "fetcher": { + "GOOGLE": "Google Agenda", + "APPLE": "Calendrier Apple", + "MICROSOFT": "Outlook", + "OTHER": "une application de calendrier" + }, + "horizon": "Cycles à venir", + "history": "Cycles passés", + "historyNone": "Aucun", + "titles": "Titres des événements", + "titleMode": { + "DISCREET": "Discret (●)", + "DESCRIPTIVE": "Explicite" + }, + "fertile": "Fenêtre fertile", + "fertileDescription": "Ajoute la fenêtre fertile estimée du prochain cycle. Une estimation, pas une méthode contraceptive.", + "rotate": "Renouveler le lien", + "rotateTitle": "Renouveler le lien ?", + "rotateBody": "Les calendriers qui utilisent l'ancien lien ne sont plus mis à jour. Vous recevez un nouveau lien à ajouter à la place.", + "turnOff": "Désactiver", + "turnOffTitle": "Désactiver l'abonnement au calendrier ?", + "turnOffBody": "Le lien ne fonctionne plus. Les copies que détient déjà votre fournisseur de calendrier restent jusqu'à ce que vous y supprimiez le calendrier.", + "help": { + "title": "L'ajouter à votre calendrier", + "google": "Google Agenda : Autres agendas, À partir de l'URL, puis collez le lien. Google actualise environ toutes les 12 à 24 heures.", + "apple": "Calendrier Apple : sur Mac, Fichier, Nouvel abonnement à un calendrier ; sur iPhone, Réglages, Calendrier, Comptes, Ajouter un compte, Autre, Ajouter un calendrier avec abonnement.", + "outlook": "Outlook : Ajouter un calendrier, S'abonner à partir du web, puis collez le lien. Outlook actualise toutes les quelques heures." + }, + "ics": { + "calendarName": "Cycle (HealthLog)", + "periodEstimate": "Règles (estimation)", + "periodLogged": "Règles (enregistrées)", + "fertileEstimate": "Fenêtre fertile (estimation)", + "likelyStart": "Début le plus probable {date}, {band}", + "bandOne": "±{count} jour", + "bandFew": "±{count} jours", + "bandOther": "±{count} jours" + } + }, + "projection": { + "title": "Cycles suivants", + "around": "Vers le {date}", + "state": { + "LEARNING": "Tant que 3 cycles ne sont pas enregistrés, seules les prochaines règles sont incluses.", + "LATE": "Pas de prévision tant que ces règles sont plus tardives que prévu ; elle revient dès que vous les enregistrez.", + "OFF": "Les prévisions sont désactivées ; seuls les cycles passés sont inclus, si vous les avez choisis." + } + } }, "cycleReminders": { "periodSoonTitle": "Règles probablement bientôt", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} a fusionné des biomarqueurs", "medicationIngredientsUpdate": "{name} a modifié la composition d’un complément", "auditCCreate": "{name} a répondu au questionnaire AUDIT-C", - "auditCDelete": "{name} a supprimé une entrée AUDIT-C" + "auditCDelete": "{name} a supprimé une entrée AUDIT-C", + "cycleFeedCreate": "{name} a activé l'abonnement au calendrier", + "cycleFeedUpdate": "{name} a modifié l'abonnement au calendrier", + "cycleFeedRotate": "{name} a renouvelé le lien de l'abonnement au calendrier", + "cycleFeedRevoke": "{name} a désactivé l'abonnement au calendrier" }, "section": { "measurements": { diff --git a/messages/it.json b/messages/it.json index 545294812..f077ab4f2 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Le mestruazioni sono iniziate questo giorno", "endedPeriodOnDate": "Le mestruazioni sono finite questo giorno", "startAbsorbed": "Inizio delle mestruazioni salvato. Il tuo inizio del {absorbed} è stato unito a quello del {date}.", - "startRestored": "Salvato. L’inizio del {dates} è di nuovo presente." + "startRestored": "Salvato. L’inizio del {dates} è di nuovo presente.", + "feed": { + "title": "Abbonamento al calendario", + "description": "Le tue prossime mestruazioni come eventi di un'intera giornata nell'app di calendario che usi già.", + "disclosure": "Il tuo fornitore di calendario (Google, iCloud, Outlook) conserva queste date sui suoi server. Disattivare l'abbonamento in seguito non elimina le copie che ha già.", + "turnOn": "Attiva abbonamento", + "error": "Non è stato possibile modificare l'abbonamento al calendario. Riprova.", + "linkHidden": "È attivo un link che inizia con {prefix}. Viene mostrato una sola volta, quando viene creato; rinnovalo per averne uno nuovo.", + "linkLabel": "Link di abbonamento", + "linkOnce": "Visibile solo adesso. Incollalo nella tua app di calendario, oppure rinnovalo più tardi per averne uno nuovo.", + "copy": "Copia link", + "copied": "Link copiato.", + "copyFailed": "La copia non è riuscita. Seleziona il link e copialo a mano.", + "openInCalendar": "Apri nell'app di calendario", + "notFetched": "Non ancora scaricato.", + "lastFetched": "Ultimo scaricamento {when} da {fetcher}.", + "fetcher": { + "GOOGLE": "Google Calendar", + "APPLE": "Calendario di Apple", + "MICROSOFT": "Outlook", + "OTHER": "un'app di calendario" + }, + "horizon": "Cicli in avanti", + "history": "Cicli passati", + "historyNone": "Nessuno", + "titles": "Titoli degli eventi", + "titleMode": { + "DISCREET": "Discreto (●)", + "DESCRIPTIVE": "Descrittivo" + }, + "fertile": "Finestra fertile", + "fertileDescription": "Aggiunge la finestra fertile stimata del prossimo ciclo. Una stima, non un metodo contraccettivo.", + "rotate": "Rinnova link", + "rotateTitle": "Rinnovare il link?", + "rotateBody": "I calendari che usano il vecchio link smettono di aggiornarsi. Ricevi un nuovo link da aggiungere al suo posto.", + "turnOff": "Disattiva", + "turnOffTitle": "Disattivare l'abbonamento al calendario?", + "turnOffBody": "Il link smette di funzionare. Le copie che il tuo fornitore di calendario ha già restano finché non rimuovi lì il calendario.", + "help": { + "title": "Aggiungerlo al calendario", + "google": "Google Calendar: Altri calendari, Da URL, poi incolla il link. Google aggiorna circa ogni 12-24 ore.", + "apple": "Calendario di Apple: sul Mac, File, Nuovo abbonamento a calendario; su iPhone, Impostazioni, Calendario, Account, Aggiungi account, Altro, Aggiungi calendario sottoscritto.", + "outlook": "Outlook: Aggiungi calendario, Sottoscrivi dal Web, poi incolla il link. Outlook aggiorna ogni poche ore." + }, + "ics": { + "calendarName": "Ciclo (HealthLog)", + "periodEstimate": "Mestruazioni (stima)", + "periodLogged": "Mestruazioni (registrate)", + "fertileEstimate": "Finestra fertile (stima)", + "likelyStart": "Inizio più probabile {date}, {band}", + "bandOne": "±{count} giorno", + "bandFew": "±{count} giorni", + "bandOther": "±{count} giorni" + } + }, + "projection": { + "title": "Cicli successivi", + "around": "Intorno al {date}", + "state": { + "LEARNING": "Finché non sono registrati 3 cicli, è incluso solo il prossimo ciclo mestruale.", + "LATE": "Nessuna previsione finché queste mestruazioni sono più in ritardo del previsto; torna appena le registri.", + "OFF": "Le previsioni sono disattivate; sono inclusi solo i cicli passati, se li hai scelti." + } + } }, "cycleReminders": { "periodSoonTitle": "Ciclo probabilmente in arrivo", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} ha unito dei biomarcatori", "medicationIngredientsUpdate": "{name} ha modificato la composizione di un integratore", "auditCCreate": "{name} ha compilato il questionario AUDIT-C", - "auditCDelete": "{name} ha eliminato una voce AUDIT-C" + "auditCDelete": "{name} ha eliminato una voce AUDIT-C", + "cycleFeedCreate": "{name} ha attivato l'abbonamento al calendario", + "cycleFeedUpdate": "{name} ha modificato l'abbonamento al calendario", + "cycleFeedRotate": "{name} ha rinnovato il link dell'abbonamento al calendario", + "cycleFeedRevoke": "{name} ha disattivato l'abbonamento al calendario" }, "section": { "measurements": { diff --git a/messages/ko.json b/messages/ko.json index 4ae094853..05df2c868 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "이 날 생리를 시작했어요", "endedPeriodOnDate": "이 날 생리가 끝났어요", "startAbsorbed": "생리 시작을 저장했어요. {absorbed}의 시작은 {date}로 합쳐졌어요.", - "startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요." + "startRestored": "저장했어요. {dates}의 시작이 다시 표시돼요.", + "feed": { + "title": "캘린더 구독", + "description": "다음 생리 예정일을 지금 쓰는 캘린더 앱에 종일 일정으로 보여줘요.", + "disclosure": "캘린더 제공업체(Google, iCloud, Outlook)는 이 날짜를 자사 서버에 저장해요. 나중에 구독을 꺼도 이미 가져간 사본은 지워지지 않아요.", + "turnOn": "구독 켜기", + "error": "캘린더 구독을 변경하지 못했어요. 다시 시도해 주세요.", + "linkHidden": "{prefix}(으)로 시작하는 링크가 활성 상태예요. 링크는 만들 때 한 번만 보여요. 새 링크가 필요하면 다시 발급하세요.", + "linkLabel": "구독 링크", + "linkOnce": "지금만 보여요. 캘린더 앱에 붙여 넣거나, 나중에 다시 발급해 새 링크를 받으세요.", + "copy": "링크 복사", + "copied": "링크를 복사했어요.", + "copyFailed": "복사하지 못했어요. 링크를 선택해 직접 복사하세요.", + "openInCalendar": "캘린더 앱에서 열기", + "notFetched": "아직 가져가지 않았어요.", + "lastFetched": "{fetcher}에서 마지막으로 가져감: {when}.", + "fetcher": { + "GOOGLE": "Google 캘린더", + "APPLE": "Apple 캘린더", + "MICROSOFT": "Outlook", + "OTHER": "캘린더 앱" + }, + "horizon": "앞으로의 주기", + "history": "지난 주기", + "historyNone": "없음", + "titles": "일정 제목", + "titleMode": { + "DISCREET": "드러나지 않게 (●)", + "DESCRIPTIVE": "내용 표시" + }, + "fertile": "가임기", + "fertileDescription": "다음 주기의 예상 가임기를 추가해요. 추정일 뿐 피임 방법이 아니에요.", + "rotate": "링크 다시 발급", + "rotateTitle": "링크를 다시 발급할까요?", + "rotateBody": "이전 링크를 쓰는 캘린더는 더 이상 업데이트되지 않아요. 대신 추가할 새 링크를 받게 돼요.", + "turnOff": "끄기", + "turnOffTitle": "캘린더 구독을 끌까요?", + "turnOffBody": "링크가 더 이상 작동하지 않아요. 캘린더 제공업체가 이미 가진 사본은 거기서 캘린더를 삭제할 때까지 남아 있어요.", + "help": { + "title": "캘린더에 추가하는 방법", + "google": "Google 캘린더: 다른 캘린더, URL로 추가에서 링크를 붙여 넣으세요. Google은 대략 12~24시간마다 새로 고쳐요.", + "apple": "Apple 캘린더: Mac에서는 파일, 새로운 캘린더 구독. iPhone에서는 설정, 캘린더, 계정, 계정 추가, 기타, 구독 캘린더 추가.", + "outlook": "Outlook: 캘린더 추가, 웹에서 구독에서 링크를 붙여 넣으세요. Outlook은 몇 시간마다 새로 고쳐요." + }, + "ics": { + "calendarName": "주기 (HealthLog)", + "periodEstimate": "생리 (예상)", + "periodLogged": "생리 (기록)", + "fertileEstimate": "가임기 (예상)", + "likelyStart": "가장 가능성 높은 시작일 {date}, {band}", + "bandOne": "±{count}일", + "bandFew": "±{count}일", + "bandOther": "±{count}일" + } + }, + "projection": { + "title": "이후 주기", + "around": "{date}쯤", + "state": { + "LEARNING": "주기가 3번 기록될 때까지는 다음 생리만 포함돼요.", + "LATE": "이번 생리가 예상보다 늦어지는 동안에는 예측이 없어요. 기록하면 다시 나타나요.", + "OFF": "예측이 꺼져 있어요. 선택한 경우 지난 주기만 포함돼요." + } + } }, "cycleReminders": { "periodSoonTitle": "생리가 곧 시작될 것 같아요", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} 님이 바이오마커를 합쳤어요", "medicationIngredientsUpdate": "{name} 님이 보충제 성분을 수정했어요", "auditCCreate": "{name} 님이 AUDIT-C 문항에 답했어요", - "auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요" + "auditCDelete": "{name} 님이 AUDIT-C 기록을 삭제했어요", + "cycleFeedCreate": "{name} 님이 캘린더 구독을 켰어요", + "cycleFeedUpdate": "{name} 님이 캘린더 구독을 변경했어요", + "cycleFeedRotate": "{name} 님이 캘린더 구독 링크를 다시 발급했어요", + "cycleFeedRevoke": "{name} 님이 캘린더 구독을 껐어요" }, "section": { "measurements": { diff --git a/messages/nl.json b/messages/nl.json index 9df3a49be..6b34b6ecf 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Mijn menstruatie begon op deze dag", "endedPeriodOnDate": "Mijn menstruatie eindigde op deze dag", "startAbsorbed": "Begin van menstruatie opgeslagen. Je begin op {absorbed} is samengevoegd met {date}.", - "startRestored": "Opgeslagen. Het begin op {dates} is terug." + "startRestored": "Opgeslagen. Het begin op {dates} is terug.", + "feed": { + "title": "Agenda-abonnement", + "description": "Je volgende menstruaties als afspraken voor de hele dag in de agenda-app die je al gebruikt.", + "disclosure": "Je agendaprovider (Google, iCloud, Outlook) bewaart deze datums op zijn servers. Als je dit later uitzet, blijven kopieën die hij al heeft daar staan.", + "turnOn": "Abonnement aanzetten", + "error": "Het agenda-abonnement kon niet worden gewijzigd. Probeer het opnieuw.", + "linkHidden": "Er is een link actief die begint met {prefix}. Die wordt maar één keer getoond, bij het aanmaken; vernieuw hem voor een nieuwe.", + "linkLabel": "Abonnementslink", + "linkOnce": "Alleen nu zichtbaar. Plak hem in je agenda-app, of vernieuw hem later voor een nieuwe.", + "copy": "Link kopiëren", + "copied": "Link gekopieerd.", + "copyFailed": "Kopiëren is niet gelukt. Selecteer de link en kopieer hem zelf.", + "openInCalendar": "Openen in agenda-app", + "notFetched": "Nog niet opgehaald.", + "lastFetched": "Laatst opgehaald {when} door {fetcher}.", + "fetcher": { + "GOOGLE": "Google Agenda", + "APPLE": "Apple Agenda", + "MICROSOFT": "Outlook", + "OTHER": "een agenda-app" + }, + "horizon": "Cycli vooruit", + "history": "Eerdere cycli", + "historyNone": "Geen", + "titles": "Titels van afspraken", + "titleMode": { + "DISCREET": "Discreet (●)", + "DESCRIPTIVE": "Beschrijvend" + }, + "fertile": "Vruchtbare periode", + "fertileDescription": "Voegt de geschatte vruchtbare periode van de volgende cyclus toe. Een schatting, geen anticonceptiemethode.", + "rotate": "Link vernieuwen", + "rotateTitle": "Link vernieuwen?", + "rotateBody": "Agenda's met de oude link worden niet meer bijgewerkt. Je krijgt een nieuwe link om in plaats daarvan toe te voegen.", + "turnOff": "Uitzetten", + "turnOffTitle": "Agenda-abonnement uitzetten?", + "turnOffBody": "De link werkt niet meer. Kopieën die je agendaprovider al heeft, blijven staan tot je de agenda daar verwijdert.", + "help": { + "title": "Toevoegen aan je agenda", + "google": "Google Agenda: Andere agenda's, Via URL, en plak de link. Google ververst ongeveer elke 12 tot 24 uur.", + "apple": "Apple Agenda: op de Mac Archief, Nieuw agenda-abonnement; op de iPhone Instellingen, Agenda, Accounts, Voeg account toe, Andere, Voeg agenda-abonnement toe.", + "outlook": "Outlook: Agenda toevoegen, Abonneren vanaf internet, en plak de link. Outlook ververst om de paar uur." + }, + "ics": { + "calendarName": "Cyclus (HealthLog)", + "periodEstimate": "Menstruatie (schatting)", + "periodLogged": "Menstruatie (geregistreerd)", + "fertileEstimate": "Vruchtbare periode (schatting)", + "likelyStart": "Meest waarschijnlijke begin {date}, {band}", + "bandOne": "±{count} dag", + "bandFew": "±{count} dagen", + "bandOther": "±{count} dagen" + } + }, + "projection": { + "title": "Latere cycli", + "around": "Rond {date}", + "state": { + "LEARNING": "Tot er 3 cycli zijn geregistreerd, is alleen de volgende menstruatie opgenomen.", + "LATE": "Geen voorspelling zolang deze menstruatie later is dan verwacht; die komt terug zodra je haar registreert.", + "OFF": "Voorspellingen staan uit; alleen eerdere cycli zijn opgenomen, als je die hebt gekozen." + } + } }, "cycleReminders": { "periodSoonTitle": "Menstruatie waarschijnlijk binnenkort", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} heeft biomarkers samengevoegd", "medicationIngredientsUpdate": "{name} heeft de samenstelling van een supplement gewijzigd", "auditCCreate": "{name} heeft de AUDIT-C-vragen beantwoord", - "auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd" + "auditCDelete": "{name} heeft een AUDIT-C-registratie verwijderd", + "cycleFeedCreate": "{name} heeft het agenda-abonnement aangezet", + "cycleFeedUpdate": "{name} heeft het agenda-abonnement gewijzigd", + "cycleFeedRotate": "{name} heeft de link van het agenda-abonnement vernieuwd", + "cycleFeedRevoke": "{name} heeft het agenda-abonnement uitgezet" }, "section": { "measurements": { diff --git a/messages/pl.json b/messages/pl.json index 7caba6896..ac0f835c3 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Miesiączka zaczęła się tego dnia", "endedPeriodOnDate": "Miesiączka skończyła się tego dnia", "startAbsorbed": "Początek miesiączki zapisany. Twój początek z {absorbed} został połączony z {date}.", - "startRestored": "Zapisano. Początek z {dates} jest z powrotem." + "startRestored": "Zapisano. Początek z {dates} jest z powrotem.", + "feed": { + "title": "Subskrypcja kalendarza", + "description": "Twoje kolejne miesiączki jako wydarzenia całodniowe w aplikacji kalendarza, której już używasz.", + "disclosure": "Twój dostawca kalendarza (Google, iCloud, Outlook) przechowuje te daty na swoich serwerach. Późniejsze wyłączenie nie usuwa kopii, które już ma.", + "turnOn": "Włącz subskrypcję", + "error": "Nie udało się zmienić subskrypcji kalendarza. Spróbuj ponownie.", + "linkHidden": "Aktywny jest link zaczynający się od {prefix}. Jest pokazywany tylko raz, przy utworzeniu; odnów go, aby dostać nowy.", + "linkLabel": "Link subskrypcji", + "linkOnce": "Widoczny tylko teraz. Wklej go do aplikacji kalendarza albo odnów go później, aby dostać nowy.", + "copy": "Kopiuj link", + "copied": "Link skopiowany.", + "copyFailed": "Kopiowanie nie zadziałało. Zaznacz link i skopiuj go ręcznie.", + "openInCalendar": "Otwórz w aplikacji kalendarza", + "notFetched": "Jeszcze nie pobrano.", + "lastFetched": "Ostatnio pobrane {when} przez: {fetcher}.", + "fetcher": { + "GOOGLE": "Kalendarz Google", + "APPLE": "Kalendarz Apple", + "MICROSOFT": "Outlook", + "OTHER": "aplikacja kalendarza" + }, + "horizon": "Cykle do przodu", + "history": "Poprzednie cykle", + "historyNone": "Brak", + "titles": "Tytuły wydarzeń", + "titleMode": { + "DISCREET": "Dyskretne (●)", + "DESCRIPTIVE": "Opisowe" + }, + "fertile": "Okno płodne", + "fertileDescription": "Dodaje szacowane okno płodne następnego cyklu. To szacunek, nie metoda antykoncepcji.", + "rotate": "Odnów link", + "rotateTitle": "Odnowić link?", + "rotateBody": "Kalendarze ze starym linkiem przestaną się aktualizować. Dostaniesz nowy link, który dodasz zamiast niego.", + "turnOff": "Wyłącz", + "turnOffTitle": "Wyłączyć subskrypcję kalendarza?", + "turnOffBody": "Link przestanie działać. Kopie, które ma już Twój dostawca kalendarza, zostaną, dopóki nie usuniesz tam kalendarza.", + "help": { + "title": "Jak dodać go do kalendarza", + "google": "Kalendarz Google: Inne kalendarze, Z adresu URL, potem wklej link. Google odświeża mniej więcej co 12 do 24 godzin.", + "apple": "Kalendarz Apple: na Macu Plik, Nowa subskrypcja kalendarza; na iPhonie Ustawienia, Kalendarz, Konta, Dodaj konto, Inne, Dodaj subskrybowany kalendarz.", + "outlook": "Outlook: Dodaj kalendarz, Subskrybuj z sieci Web, potem wklej link. Outlook odświeża co kilka godzin." + }, + "ics": { + "calendarName": "Cykl (HealthLog)", + "periodEstimate": "Miesiączka (szacunek)", + "periodLogged": "Miesiączka (zapisana)", + "fertileEstimate": "Okno płodne (szacunek)", + "likelyStart": "Najbardziej prawdopodobny początek {date}, {band}", + "bandOne": "±{count} dzień", + "bandFew": "±{count} dni", + "bandOther": "±{count} dni" + } + }, + "projection": { + "title": "Kolejne cykle", + "around": "Około {date}", + "state": { + "LEARNING": "Dopóki nie zapiszesz 3 cykli, uwzględniana jest tylko najbliższa miesiączka.", + "LATE": "Brak prognozy, dopóki ta miesiączka spóźnia się bardziej niż oczekiwano; wróci, gdy ją zapiszesz.", + "OFF": "Prognozy są wyłączone; uwzględniane są tylko poprzednie cykle, jeśli je wybrano." + } + } }, "cycleReminders": { "periodSoonTitle": "Miesiączka prawdopodobnie wkrótce", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} scalił biomarkery", "medicationIngredientsUpdate": "{name} zmienił skład suplementu", "auditCCreate": "{name} wypełnił kwestionariusz AUDIT-C", - "auditCDelete": "{name} usunął wpis AUDIT-C" + "auditCDelete": "{name} usunął wpis AUDIT-C", + "cycleFeedCreate": "{name} włączył subskrypcję kalendarza", + "cycleFeedUpdate": "{name} zmienił subskrypcję kalendarza", + "cycleFeedRotate": "{name} odnowił link subskrypcji kalendarza", + "cycleFeedRevoke": "{name} wyłączył subskrypcję kalendarza" }, "section": { "measurements": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 903c659d5..192b8408d 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "Minha menstruação começou neste dia", "endedPeriodOnDate": "Minha menstruação terminou neste dia", "startAbsorbed": "Início da menstruação salvo. Seu início em {absorbed} foi mesclado em {date}.", - "startRestored": "Salvo. O início em {dates} está de volta." + "startRestored": "Salvo. O início em {dates} está de volta.", + "feed": { + "title": "Assinatura de calendário", + "description": "Suas próximas menstruações como eventos de dia inteiro no app de calendário que você já usa.", + "disclosure": "Seu provedor de calendário (Google, iCloud, Outlook) guarda essas datas nos servidores dele. Desativar depois não apaga as cópias que ele já tem.", + "turnOn": "Ativar assinatura", + "error": "Não foi possível alterar a assinatura de calendário. Tente de novo.", + "linkHidden": "Um link que começa com {prefix} está ativo. Ele só aparece uma vez, ao ser criado; renove-o para ter um novo.", + "linkLabel": "Link da assinatura", + "linkOnce": "Só aparece agora. Cole no seu app de calendário ou renove depois para ter um novo.", + "copy": "Copiar link", + "copied": "Link copiado.", + "copyFailed": "Não deu para copiar. Selecione o link e copie manualmente.", + "openInCalendar": "Abrir no app de calendário", + "notFetched": "Ainda não buscado.", + "lastFetched": "Última busca {when} por {fetcher}.", + "fetcher": { + "GOOGLE": "Google Agenda", + "APPLE": "Calendário da Apple", + "MICROSOFT": "Outlook", + "OTHER": "um app de calendário" + }, + "horizon": "Ciclos à frente", + "history": "Ciclos anteriores", + "historyNone": "Nenhum", + "titles": "Títulos dos eventos", + "titleMode": { + "DISCREET": "Discreto (●)", + "DESCRIPTIVE": "Descritivo" + }, + "fertile": "Janela fértil", + "fertileDescription": "Adiciona a janela fértil estimada do próximo ciclo. Uma estimativa, não um método contraceptivo.", + "rotate": "Renovar link", + "rotateTitle": "Renovar o link?", + "rotateBody": "Calendários com o link antigo deixam de ser atualizados. Você recebe um novo link para adicionar no lugar.", + "turnOff": "Desativar", + "turnOffTitle": "Desativar a assinatura de calendário?", + "turnOffBody": "O link para de funcionar. As cópias que seu provedor de calendário já tem ficam lá até você remover o calendário.", + "help": { + "title": "Como adicionar ao seu calendário", + "google": "Google Agenda: Outras agendas, Do URL, depois cole o link. O Google atualiza mais ou menos a cada 12 a 24 horas.", + "apple": "Calendário da Apple: no Mac, Arquivo, Nova Assinatura de Calendário; no iPhone, Ajustes, Calendário, Contas, Adicionar Conta, Outra, Adicionar Calendário Assinado.", + "outlook": "Outlook: Adicionar calendário, Assinar da Web, depois cole o link. O Outlook atualiza a cada poucas horas." + }, + "ics": { + "calendarName": "Ciclo (HealthLog)", + "periodEstimate": "Menstruação (estimativa)", + "periodLogged": "Menstruação (registrada)", + "fertileEstimate": "Janela fértil (estimativa)", + "likelyStart": "Início mais provável {date}, {band}", + "bandOne": "±{count} dia", + "bandFew": "±{count} dias", + "bandOther": "±{count} dias" + } + }, + "projection": { + "title": "Ciclos seguintes", + "around": "Por volta de {date}", + "state": { + "LEARNING": "Até haver 3 ciclos registrados, só a próxima menstruação é incluída.", + "LATE": "Sem previsão enquanto esta menstruação estiver mais atrasada que o esperado; ela volta assim que você a registrar.", + "OFF": "As previsões estão desativadas; só ciclos anteriores são incluídos, se escolhidos." + } + } }, "cycleReminders": { "periodSoonTitle": "Menstruação provavelmente em breve", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} mesclou biomarcadores", "medicationIngredientsUpdate": "{name} alterou o que um suplemento contém", "auditCCreate": "{name} respondeu às perguntas AUDIT-C", - "auditCDelete": "{name} removeu uma entrada AUDIT-C" + "auditCDelete": "{name} removeu uma entrada AUDIT-C", + "cycleFeedCreate": "{name} ativou a assinatura de calendário", + "cycleFeedUpdate": "{name} alterou a assinatura de calendário", + "cycleFeedRotate": "{name} renovou o link da assinatura de calendário", + "cycleFeedRevoke": "{name} desativou a assinatura de calendário" }, "section": { "measurements": { diff --git a/messages/ru.json b/messages/ru.json index cd0a0418c..4cbc65302 100644 --- a/messages/ru.json +++ b/messages/ru.json @@ -10026,7 +10026,70 @@ "startedPeriodOnDate": "У меня началась менструация в этот день", "endedPeriodOnDate": "У меня закончилась менструация в этот день", "startAbsorbed": "Начало менструации сохранено. Ваше начало {absorbed} объединено с {date}.", - "startRestored": "Сохранено. Начало {dates} восстановлено." + "startRestored": "Сохранено. Начало {dates} восстановлено.", + "feed": { + "title": "Подписка на календарь", + "description": "Ваши следующие месячные в виде событий на весь день в календаре, которым вы уже пользуетесь.", + "disclosure": "Ваш поставщик календаря (Google, iCloud, Outlook) хранит эти даты на своих серверах. Если позже отключить подписку, уже полученные им копии не удалятся.", + "turnOn": "Включить подписку", + "error": "Не удалось изменить подписку на календарь. Попробуйте ещё раз.", + "linkHidden": "Активна ссылка, начинающаяся с {prefix}. Она показывается только один раз, при создании; обновите её, чтобы получить новую.", + "linkLabel": "Ссылка подписки", + "linkOnce": "Видна только сейчас. Вставьте её в приложение календаря или обновите позже, чтобы получить новую.", + "copy": "Копировать ссылку", + "copied": "Ссылка скопирована.", + "copyFailed": "Скопировать не удалось. Выделите ссылку и скопируйте её вручную.", + "openInCalendar": "Открыть в календаре", + "notFetched": "Ещё не загружалась.", + "lastFetched": "Последняя загрузка {when}, клиент: {fetcher}.", + "fetcher": { + "GOOGLE": "Google Календарь", + "APPLE": "Календарь Apple", + "MICROSOFT": "Outlook", + "OTHER": "приложение календаря" + }, + "horizon": "Циклов вперёд", + "history": "Прошлые циклы", + "historyNone": "Нет", + "titles": "Названия событий", + "titleMode": { + "DISCREET": "Незаметно (●)", + "DESCRIPTIVE": "Подробно" + }, + "fertile": "Фертильное окно", + "fertileDescription": "Добавляет предполагаемое фертильное окно следующего цикла. Это оценка, а не метод контрацепции.", + "rotate": "Обновить ссылку", + "rotateTitle": "Обновить ссылку?", + "rotateBody": "Календари со старой ссылкой перестанут обновляться. Вы получите новую ссылку, которую нужно добавить вместо неё.", + "turnOff": "Отключить", + "turnOffTitle": "Отключить подписку на календарь?", + "turnOffBody": "Ссылка перестанет работать. Копии, которые уже есть у поставщика календаря, останутся, пока вы не удалите там календарь.", + "help": { + "title": "Как добавить в календарь", + "google": "Google Календарь: Другие календари, Добавить по URL, затем вставьте ссылку. Google обновляет примерно раз в 12-24 часа.", + "apple": "Календарь Apple: на Mac Файл, Новая подписка на календарь; на iPhone Настройки, Календарь, Учетные записи, Добавить учетную запись, Другое, Добавить подписной календарь.", + "outlook": "Outlook: Добавить календарь, Подписаться из Интернета, затем вставьте ссылку. Outlook обновляет каждые несколько часов." + }, + "ics": { + "calendarName": "Цикл (HealthLog)", + "periodEstimate": "Месячные (оценка)", + "periodLogged": "Месячные (записаны)", + "fertileEstimate": "Фертильное окно (оценка)", + "likelyStart": "Наиболее вероятное начало {date}, {band}", + "bandOne": "±{count} день", + "bandFew": "±{count} дня", + "bandOther": "±{count} дней" + } + }, + "projection": { + "title": "Следующие циклы", + "around": "Около {date}", + "state": { + "LEARNING": "Пока не записаны 3 цикла, включаются только ближайшие месячные.", + "LATE": "Прогноза нет, пока эти месячные запаздывают сильнее ожидаемого; он вернётся, как только вы их запишете.", + "OFF": "Прогнозы отключены; включаются только прошлые циклы, если они выбраны." + } + } }, "cycleReminders": { "periodSoonTitle": "Скоро вероятная менструация", @@ -13182,7 +13245,11 @@ "biomarkerMerge": "{name} объединил(а) показатели", "medicationIngredientsUpdate": "{name} изменил(а) состав добавки", "auditCCreate": "{name} ответил(а) на вопросы AUDIT-C", - "auditCDelete": "{name} убрал(а) запись AUDIT-C" + "auditCDelete": "{name} убрал(а) запись AUDIT-C", + "cycleFeedCreate": "{name} включил(а) подписку на календарь", + "cycleFeedUpdate": "{name} изменил(а) подписку на календарь", + "cycleFeedRotate": "{name} обновил(а) ссылку подписки на календарь", + "cycleFeedRevoke": "{name} отключил(а) подписку на календарь" }, "section": { "measurements": { diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index ca838ad10..1770cb655 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -42,7 +42,7 @@ "lib/dashboard-layout.ts": 976, "lib/dashboard/snapshot.ts": 1550, "lib/day/records.ts": 1174, - "lib/export/backup-plan.ts": 853, + "lib/export/backup-plan.ts": 838, "lib/export/coach-backup.ts": 1070, "lib/export/profile-backup.ts": 1019, "lib/fitbit/client.ts": 1262, @@ -86,6 +86,6 @@ "lib/validations/inbound-documents.ts": 1018, "lib/withings/sync-sleep.ts": 884, "lib/withings/sync.ts": 933, - "proxy.ts": 865 + "proxy.ts": 846 } } diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 1e4ab45ca..dd83b4bdc 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -12,7 +12,6 @@ "app/api/auth/oidc/callback/route.ts :: apiHandler(arg)": 258, "app/api/auth/register/route.ts :: apiHandler(arg)": 266, "app/api/coach/about-me/route.ts :: apiHandler(arg) #2": 309, - "app/api/cycle/calendar/route.ts :: apiHandler(arg)": 230, "app/api/cycle/day-logs/bulk/route.ts :: postBulk": 213, "app/api/cycle/insights/route.ts :: apiHandler(arg)": 226, "app/api/devices/route.ts :: apiHandler(arg)": 226, @@ -81,7 +80,7 @@ "components/cycle/bbt-chart.tsx :: BbtChart": 244, "components/cycle/cycle-calendar.tsx :: CycleCalendar": 292, "components/cycle/cycle-settings.tsx :: CycleSettings": 228, - "components/cycle/cycle-view.tsx :: CycleView": 408, + "components/cycle/cycle-view.tsx :: CycleView": 407, "components/cycle/log-day-sheet.tsx :: LogDaySheet": 753, "components/daily/today-hero.tsx :: TodayHero": 488, "components/dashboard/dashboard-header.tsx :: DashboardHeader": 215, @@ -242,7 +241,7 @@ "hooks/use-sheet-drag.ts :: createSheetDrag": 312, "lib/activity/calendar-math.ts :: buildActivityCalendar": 204, "lib/ai/anthropic-client.ts :: generateCompletion": 294, - "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 218, + "lib/ai/coach/cycle-snapshot.ts :: buildCycleSnapshotBlock": 202, "lib/ai/coach/glp1-snapshot.ts :: buildGlp1SnapshotBlock": 218, "lib/ai/coach/snapshot.ts :: buildCoachSnapshotImpl": 880, "lib/ai/coach/tools/loop.ts :: runCoachToolLoop": 438, @@ -335,7 +334,7 @@ "lib/notifications/senders/web-push.ts :: sendViaWebPush": 268, "lib/personal-records/pr-detection-worker.ts :: detectPersonalRecordsForUser": 239, "lib/personal-records/pr-direction.ts :: getPRDirection": 208, - "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 272, + "lib/record-activity/activity-verb.ts :: recordActivityVerbLine": 261, "lib/strava/sync.ts :: runStravaSync": 219, "lib/support/report.ts :: collectSupportReportFacts": 332, "lib/targets/build-response.ts :: buildTargetsResponse": 330, @@ -346,6 +345,6 @@ "lib/withings/sync-ecg.ts :: syncUserEcg": 202, "lib/withings/sync-sleep.ts :: syncUserSleep": 414, "lib/withings/sync.ts :: syncUserMeasurements": 337, - "proxy.ts :: proxy": 493 + "proxy.ts :: proxy": 471 } } diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index f6105ed7e..fc64b4a07 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -2892,7 +2892,7 @@ "lib/withings/sync.ts": 6, "lib/withings/webhook-handler.ts": 3, "lib/workouts/list-read.ts": 1, - "proxy.ts": 29, + "proxy.ts": 27, "types/analytics.ts": 8, "types/backups.ts": 1 } diff --git a/src/__tests__/cycle-feed-content-guard.test.ts b/src/__tests__/cycle-feed-content-guard.test.ts new file mode 100644 index 000000000..d250d0116 --- /dev/null +++ b/src/__tests__/cycle-feed-content-guard.test.ts @@ -0,0 +1,185 @@ +/** + * #1350 — the calendar feed carries dates and a title, and nothing else. + * + * A calendar provider keeps everything it fetches, beyond the reach of any + * revoke. So this seeds every kind of day-log content the record can hold — + * a note, custom symptom labels, a basal temperature, an ovulation test, the + * mucus and cervix signs, intercourse, contraception, a pregnancy test — as + * recognisable sentinels on the very rows the feed reads, as if a select had + * been widened by mistake, and asserts that no rendered body contains any of + * them, in either title mode, with history and the fertile window on. + * + * Written, then broken once on purpose (a description that interpolated the + * day's note) to prove it fails. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + }, +})); + +import { prisma } from "@/lib/db"; +import { addDays } from "@/lib/cycle/day-math"; +import { buildCycleFeed } from "@/lib/cycle/feed/build"; +import { renderIcs } from "@/lib/cycle/ics"; + +import { + FEED, + SUBJECT, + profileRow, + regularCycles, +} from "@/lib/cycle/feed/__tests__/fixtures"; + +const db = prisma as unknown as { + cycleProfile: { findUnique: ReturnType }; + menstrualCycle: { findMany: ReturnType }; + cycleDayLog: { findMany: ReturnType }; + measurement: { findMany: ReturnType }; +}; + +const SENTINELS = { + note: "SENTINEL-NOTE-zebra", + noteEncrypted: "SENTINEL-CIPHER-okapi", + symptom: "SENTINEL-SYMPTOM-quokka", + symptomKey: "sentinel_symptom_key", + bbt: "36.987", + wrist: "35.4321", + opk: "POSITIVE_LH_SURGE", + mucus: "EGG_WHITE", + cervix: "HIGH", + sex: "SENTINEL-SEX-tapir", + contraceptive: "SENTINEL-CONTRACEPTIVE-ibis", + pregnancyTest: "SENTINEL-PREGNANCY-lynx", + userId: "subject", +}; + +const FIRST = "2025-11-18"; +const NOW = new Date("2026-11-06T12:00:00.000Z"); + +function stuffedDayLogs() { + const cycles = regularCycles(FIRST); + const out = []; + for (const c of cycles) { + for (let i = 0; i < 6; i++) { + out.push({ + id: `log-${c.startDate}-${i}`, + userId: SENTINELS.userId, + date: addDays(c.startDate, i), + flow: i < 4 ? "MEDIUM" : "NONE", + basalBodyTempC: Number(SENTINELS.bbt), + temperatureExcluded: false, + ovulationTest: SENTINELS.opk, + cervicalMucus: SENTINELS.mucus, + cervixPosition: SENTINELS.cervix, + cervixFirmness: null, + cervixOpening: null, + intermenstrualBleeding: true, + sexualActivity: SENTINELS.sex, + protectedSex: false, + pregnancyTest: SENTINELS.pregnancyTest, + progesteroneTest: SENTINELS.pregnancyTest, + contraceptive: SENTINELS.contraceptive, + notes: SENTINELS.note, + notesEncrypted: SENTINELS.noteEncrypted, + sensitiveEncrypted: SENTINELS.noteEncrypted, + symptomLinks: [ + { + symptom: { + key: SENTINELS.symptomKey, + label: SENTINELS.symptom, + }, + }, + ], + _count: { symptomLinks: 1 }, + }); + } + } + return out; +} + +beforeEach(() => { + vi.clearAllMocks(); + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("TRYING_TO_CONCEIVE"), + ); + db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST)); + db.cycleDayLog.findMany.mockResolvedValue(stuffedDayLogs()); + db.measurement.findMany.mockResolvedValue([ + { + measuredAt: new Date("2026-11-01T03:00:00Z"), + value: Number(SENTINELS.wrist), + }, + ]); +}); + +describe("the calendar feed body", () => { + it.each(["DISCREET", "DESCRIPTIVE"] as const)( + "carries no day-log content in %s mode", + async (titleMode) => { + const built = await buildCycleFeed( + { + ...FEED, + titleMode, + historyCycles: 12, + horizonCycles: 6, + includeFertile: true, + }, + SUBJECT, + NOW, + ); + // Non-empty, or the assertion below would hold for nothing. + expect(built.calendar.events.length).toBeGreaterThan(10); + const body = renderIcs(built.calendar, NOW); + for (const [name, value] of Object.entries(SENTINELS)) { + expect(body, `${name} leaked into the feed`).not.toContain(value); + } + }, + ); + + it("carries only the properties an all-day date event needs", async () => { + const built = await buildCycleFeed( + { + ...FEED, + titleMode: "DESCRIPTIVE", + historyCycles: 6, + includeFertile: true, + }, + SUBJECT, + NOW, + ); + const body = renderIcs(built.calendar, NOW); + const properties = new Set( + body + .split("\r\n") + .filter((line) => line && !line.startsWith(" ")) + .map((line) => line.split(/[:;]/)[0]), + ); + expect([...properties].sort()).toEqual( + [ + "BEGIN", + "CALSCALE", + "CLASS", + "DESCRIPTION", + "DTEND", + "DTSTAMP", + "DTSTART", + "END", + "LAST-MODIFIED", + "METHOD", + "PRODID", + "REFRESH-INTERVAL", + "SUMMARY", + "TRANSP", + "UID", + "VERSION", + "X-PUBLISHED-TTL", + "X-WR-CALNAME", + ].sort(), + ); + }); +}); diff --git a/src/__tests__/destructive-control-guard.test.ts b/src/__tests__/destructive-control-guard.test.ts index 720f1be30..1dd48b750 100644 --- a/src/__tests__/destructive-control-guard.test.ts +++ b/src/__tests__/destructive-control-guard.test.ts @@ -511,6 +511,14 @@ const REGISTRY: DestructiveEntry[] = [ recovery: "tombstoned-no-restore", confirm: ["ConfirmButton", "ConfirmDialog"], }, + { + file: "components/cycle/use-cycle-feed.ts", + destroys: + "the record's calendar-feed link (#1350); calendars subscribed to it stop updating, and turning it on again issues a new link", + triggers: ["components/cycle/cycle-calendar-feed-card.tsx"], + recovery: "permanent", + confirm: ["ConfirmButton"], + }, // ── Medications ───────────────────────────────────────────────────────── { diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 9ed7c49f2..70faa52e6 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -75,6 +75,10 @@ function isMaskPlaceholder(words: string[]): boolean { * reason. Prune an entry only when the value genuinely diverges in a locale. */ const LEGIT_IDENTICAL = new Set([ + // #1350 — "Google Calendar" is the product's own name in the Spanish and + // Italian UI, and "Cycle (HealthLog)" is the French word for the same thing. + "cycle.feed.fetcher.GOOGLE", + "cycle.feed.ics.calendarName", // Vaccine catalogue: clinical proper nouns whose accepted written form is the // same across these locales. "Hib" carries its Latin binomial verbatim, and // "Hepatitis A" / "Hepatitis B" read identically in Spanish. The catalogue diff --git a/src/__tests__/openapi-security-declaration-guard.test.ts b/src/__tests__/openapi-security-declaration-guard.test.ts index a5898affc..1162d5028 100644 --- a/src/__tests__/openapi-security-declaration-guard.test.ts +++ b/src/__tests__/openapi-security-declaration-guard.test.ts @@ -77,6 +77,8 @@ const UNAUTHENTICATED: Readonly> = { "Starts the identity-provider handoff, for the browser and for the native client.", "/api/auth/oidc/status": "Says whether single sign-on is configured, so the sign-in screen can offer it.", + "/api/cycle/feed/{token}": + "A calendar provider polls it from its own servers with no session; the `hlf_` path token is the credential.", "/api/notifications/vapid": "The instance's public Web Push key. Public by definition.", "/api/auth/claim/preview": diff --git a/src/__tests__/proxy-cycle-feed-public.test.ts b/src/__tests__/proxy-cycle-feed-public.test.ts new file mode 100644 index 000000000..4c9faa620 --- /dev/null +++ b/src/__tests__/proxy-cycle-feed-public.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * #1350 — the cycle calendar feed at `/api/cycle/feed/.ics`. + * + * A calendar provider polls it from its own servers with no cookie, so the + * proxy must pass the request through untouched, keep it out of indexes and + * referrers, and leave its caching to the route (`private, no-cache` plus an + * ETag) rather than stamping `no-store` over the validator path. The + * management routes beside it stay cookie-only and are not on the list. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { PUBLIC_PATHS, proxy } from "../proxy"; + +// Built at run time so no token-shaped literal sits in the source. +const TOKEN = `hlf_${"7d".repeat(24)}`; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("proxy on /api/cycle/feed/", () => { + it("lists the feed prefix, trailing slash included, and not the management namespace", () => { + expect(PUBLIC_PATHS).toContain("/api/cycle/feed/"); + expect( + PUBLIC_PATHS.some((p) => "/api/cycle/feed-subscription".startsWith(p)), + ).toBe(false); + }); + + it("passes a cookie-less GET through to the route", () => { + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`, { + headers: { "user-agent": "Google-Calendar-Importer" }, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("x-middleware-next")).toBe("1"); + expect(res.headers.get("location")).toBeNull(); + }); + + it("keeps the token out of indexes and referrers", () => { + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`), + ); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + }); + + it("leaves the route's Cache-Control alone, so the ETag path survives", () => { + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`), + ); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("serves a cookie-less GET in demo mode too: a read, not a mutation", () => { + vi.stubEnv("DEMO_MODE", "true"); + const res = proxy( + new NextRequest(`http://localhost/api/cycle/feed/${TOKEN}.ics`), + ); + expect(res.status).toBe(200); + }); + + it("refuses a demo-mode mint at the edge", () => { + vi.stubEnv("DEMO_MODE", "true"); + const res = proxy( + new NextRequest("http://localhost/api/cycle/feed-subscription", { + method: "POST", + }), + ); + expect(res.status).toBe(403); + }); +}); diff --git a/src/__tests__/recent-proof-surface-guard.test.ts b/src/__tests__/recent-proof-surface-guard.test.ts index 4a5ac9287..459d7a0c4 100644 --- a/src/__tests__/recent-proof-surface-guard.test.ts +++ b/src/__tests__/recent-proof-surface-guard.test.ts @@ -97,6 +97,17 @@ const TOKEN_ARM_COOKIE_PROOF_ROUTES = [ "app/api/fhir/Patient/$everything/route.ts", ].sort(); +/** + * Routes that resolve through `requireRecordAuth` and are cookie-only: they + * refuse a Bearer caller outright, then take the cookie arm. The cycle + * calendar feed's mint and rotate (#1350) hand out a standing URL credential, + * so no token may reach them and the session needs a fresh proof. + */ +const RECORD_COOKIE_ONLY_PROOF_ROUTES = [ + "app/api/cycle/feed-subscription/route.ts", + "app/api/cycle/feed-subscription/rotate/route.ts", +].sort(); + /** * The connection consent. It resolves the session through `getSession` * (OAuth answers in its own error format, not the envelope) and asks for the @@ -143,11 +154,30 @@ describe("the recent-proof gate", () => { [ ...COOKIE_PROOF_ROUTES, ...TOKEN_ARM_COOKIE_PROOF_ROUTES, + ...RECORD_COOKIE_ONLY_PROOF_ROUTES, ...CONSENT_PROOF_ROUTES, ].sort(), ); }); + it("a cookie-only record route refuses a token before it asks", () => { + for (const rel of RECORD_COOKIE_ONLY_PROOF_ROUTES) { + const src = read(rel); + expect(importedAs(rel, "requireRecordAuth"), rel).toHaveLength(1); + // Every proof call sits after a Bearer refusal in the same handler. + const proofs = [...src.matchAll(/await assertRecentCookieProof\(/g)]; + expect(proofs.length, rel).toBeGreaterThan(0); + for (const proof of proofs) { + const before = src.slice(0, proof.index); + const handlerStart = before.lastIndexOf("apiHandler("); + expect(handlerStart, rel).toBeGreaterThan(-1); + expect(before.slice(handlerStart), rel).toMatch( + /if \(auth\.authMethod !== "cookie"\) return cookieOnlyRefusal\(\);/, + ); + } + } + }); + it("a route that resolves either transport asks only on the cookie", () => { for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) { expect(read(rel), rel).toMatch( diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 62a5b6fd2..d338495d4 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -294,6 +294,14 @@ describe("S2 — the URL-token authentication set is frozen", () => { // action-bound, short-lived token in the path is the credential. Rate // limited before verification, single-use through the mood entry's unique // key, and scrubbed from logs by `PATH_SECRET_PATHS`. + // #1350 — the cycle calendar feed. A calendar provider polls it from its + // own servers with no session, so the `hlf_` path segment is the + // credential. Rate limited before the lookup, HMAC-hashed at rest, every + // gate (account, operator switch, cycle module, a carrier's MANAGE grant) + // re-read per fetch behind one 404, dates-only content, and scrubbed from + // logs by `PATH_SECRET_PATHS`. + "app/api/cycle/feed/[token]/route.ts": + "calendar provider polls without a session; the hlf_ path token is the credential", "app/api/notifications/action/[token]/route.ts": "signed single-use quick-answer token from a notification action", }; @@ -329,6 +337,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { expect(bySegment.length).toBeGreaterThan(0); expect(bySegment).toEqual([ "app/api/c/[token]/unlock/route.ts", + "app/api/cycle/feed/[token]/route.ts", "app/api/notifications/action/[token]/route.ts", "app/api/whoop/webhook/[token]/route.ts", "app/api/withings/webhook/[token]/route.ts", diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 0d4cbf032..8e2f9537b 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1403,6 +1403,14 @@ const DELEGABLE_ROUTES: Record = { domain: "cycle", why: "Setting a period boundary, which re-anchors the neighbouring cycles. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", }, + "app/api/cycle/feed-subscription/route.ts": { + domain: "cycle", + why: "The record's calendar subscription (#1350): its status, turning it on, its options and turning it off. Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, + "app/api/cycle/feed-subscription/rotate/route.ts": { + domain: "cycle", + why: "A new link for the record's calendar subscription, the old one revoked in the same transaction (#1350). Reached only through this module's MANAGE arm, and only over a cookie; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + }, "app/api/medications/[id]/schedule-revisions/[revisionId]/route.ts": { domain: "medications", why: "Editing and deleting an era; the delete is hard, so C3 carries its dates and dose. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", @@ -1879,6 +1887,16 @@ const DELEGABLE_MANAGE_ROUTES: Record = { conditions: ["C4"], why: "Setting a period boundary, which re-anchors the neighbouring cycles. C4 on the dates it moves.", }, + "app/api/cycle/feed-subscription/route.ts": { + domain: "cycle", + conditions: ["C1", "C3", "C4"], + why: "The record's calendar subscription (#1350), the one credential a MANAGE delegate may mint. It extends no reach: the feed serves dates the grant already lets the manager read, and every fetch re-reads the manager's live MANAGE grant over the cycle section, so revoking or narrowing the grant ends the link on the provider's next poll, and a handover claim revokes it outright. Cookie-only with a fresh proof on the mint, so a Bearer token cannot leave one behind. C1 on the mint bucket, C3 on the revoke (the credential is unusable after it), C4 on the options.", + }, + "app/api/cycle/feed-subscription/rotate/route.ts": { + domain: "cycle", + conditions: ["C1"], + why: "Replacing the subscription's link in one transaction (#1350). Same argument as the subscription route: no reach beyond the grant, re-checked per fetch, cookie-only with a fresh proof. C1 on the shared mint bucket.", + }, "app/api/cycle/symptoms/custom/route.ts": { domain: "cycle", conditions: ["C1"], @@ -2295,8 +2313,12 @@ const ACTOR_ROUTES: Record = { * Ending a running course (#1280) joins the record list and the * manage literal: it was refused under a switch while the medication's PUT, * which used to carry the end, admitted a manager. 272 -> 274. + * + * The cycle calendar subscription (#1350) and its rotate join the record list + * and the manage literal: the owner and a MANAGE delegate over the cycle + * section may turn it on, change it, rotate it and turn it off. 276 -> 280. */ -const FROZEN_ENTRY_COUNT = 276; +const FROZEN_ENTRY_COUNT = 280; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — @@ -3074,7 +3096,7 @@ describe("(g) the MANAGE route set is frozen", () => { it("keeps the admitted mutation inventory complete and discoverable", () => { expect(ADMITTED_MUTATING_HANDLERS.length).toBeGreaterThan(0); - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97); const expected = ADMITTED_MUTATING_HANDLERS.map( ({ handlerModule, action, level }) => diff --git a/src/__tests__/step-up-web-caller-guard.test.ts b/src/__tests__/step-up-web-caller-guard.test.ts index 9dd1cd427..24bc9c200 100644 --- a/src/__tests__/step-up-web-caller-guard.test.ts +++ b/src/__tests__/step-up-web-caller-guard.test.ts @@ -133,6 +133,8 @@ const GATED_ROUTES: Record = { "POST /api/account/grants": "web", "POST /api/admin/backups/[id]/restore": "web", "POST /api/admin/backups/upload": "web", + "POST /api/cycle/feed-subscription": "web", + "POST /api/cycle/feed-subscription/rotate": "web", "POST /api/admin/encryption/rotate": "web", "POST /api/admin/users/[id]/reset-password": "web", "POST /api/auth/me/mfa/disable": "web", diff --git a/src/__tests__/v137-wave-zero-fixtures.test.ts b/src/__tests__/v137-wave-zero-fixtures.test.ts index 1de074106..730299d84 100644 --- a/src/__tests__/v137-wave-zero-fixtures.test.ts +++ b/src/__tests__/v137-wave-zero-fixtures.test.ts @@ -26,7 +26,7 @@ describe("sharing and handler inventories", () => { }); it("keeps every admitted mutation uniquely addressable and fully controlled", () => { - expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(93); + expect(ADMITTED_MUTATING_HANDLERS).toHaveLength(97); expect( new Set( ADMITTED_MUTATING_HANDLERS.map( diff --git a/src/app/api/__tests__/module-route-gate-inventory.test.ts b/src/app/api/__tests__/module-route-gate-inventory.test.ts index 4ab806ca6..5c0dff21e 100644 --- a/src/app/api/__tests__/module-route-gate-inventory.test.ts +++ b/src/app/api/__tests__/module-route-gate-inventory.test.ts @@ -210,6 +210,14 @@ const MODULE_ROUTE_TREES: ReadonlyArray = [ * ungated. Each carries its reason inline per the bucket-4 contract. */ const EXEMPT_ROUTES: ReadonlyArray = [ + // ── CYCLE CALENDAR FEED (#1350) ─────────────────────────────────── + // Not ungated: the anonymous feed cannot answer the module's 403 (that + // would tell a prober the token is real), so `serveCycleFeed` re-reads + // `isCycleAvailableForUser` (the user toggle AND the operator switch) on + // every fetch and answers its blunt 404. `requireCycleEnabled` would also + // upsert a profile on an anonymous request. The behaviour is pinned in + // BEHAVIOURAL_GATE_TESTS below. + "src/app/api/cycle/feed/[token]/route.ts", // ── DATA LAYER (mood) ───────────────────────────────────────────── // Raw MoodEntry CRUD. The module gate governs whether mood SURFACES // (nav, dashboard tile, analysis, doctor report), not whether the row @@ -508,6 +516,11 @@ const REQUIRED_TREE_MODULE: Readonly> = { */ const BEHAVIOURAL_GATE_TESTS: ReadonlyArray<{ path: string; proves: string }> = [ + { + path: "src/lib/cycle/feed/__tests__/serve.test.ts", + proves: + "the anonymous cycle calendar feed answers its blunt 404, and builds nothing, when cycle tracking is unavailable for the record", + }, { path: "tests/integration/routes/fhir-module-gate.test.ts", proves: diff --git a/src/app/api/cycle/feed-subscription/rotate/route.ts b/src/app/api/cycle/feed-subscription/rotate/route.ts new file mode 100644 index 000000000..3e58f83bb --- /dev/null +++ b/src/app/api/cycle/feed-subscription/rotate/route.ts @@ -0,0 +1,115 @@ +/** + * `POST /api/cycle/feed-subscription/rotate` — a new link for the record's + * calendar subscription (#1350). + * + * One transaction: the live feed is revoked and its successor minted with the + * same options, so there is never a moment with two live links or with none. + * Calendars holding the old link stop updating on their next poll. The new + * URL is returned exactly once. + * + * Same gates as turning the subscription on: cookie session only, the owner or + * a MANAGE delegate over the cycle section, a fresh proof, the operator's + * external-access switch, and the per-actor mint bucket. + */ +import { NextRequest } from "next/server"; + +import { + apiHandler, + assertRecentCookieProof, + requireRecordAuth, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { auditLog } from "@/lib/auth/audit"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { + FEED_MINT_LIMIT_PER_HOUR, + FEED_MINT_WINDOW_MS, + cookieOnlyRefusal, + feedLinks, + noLiveFeed, +} from "@/lib/cycle/feed/manage"; +import { FEED_SUMMARY_SELECT, toFeedSummary } from "@/lib/cycle/feed/status"; +import { mintFeedToken } from "@/lib/cycle/feed/token"; +import { prisma } from "@/lib/db"; +import { resolveOrigin } from "@/lib/http/origin"; +import { annotate } from "@/lib/logging/context"; +import { checkRateLimit } from "@/lib/rate-limit"; + +export const POST = apiHandler(async (request: NextRequest) => { + const auth = await requireRecordAuth("manage", "cycle"); + if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); + const { user, actor } = auth; + await assertRecentCookieProof(actor, auth.session.id); + annotate({ action: { name: "cycle.feed.rotate" } }); + + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403, { + errorCode: "cycle.feed.api_disabled", + }); + } + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `cycle-feed:mint:${actor.id}`, + FEED_MINT_LIMIT_PER_HOUR, + FEED_MINT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many calendar links, try again later", 429, { + errorCode: "cycle.feed.rateLimited", + }); + } + + const minted = mintFeedToken(); + const rotated = await prisma.$transaction(async (tx) => { + const live = await tx.cycleCalendarFeed.findFirst({ + where: { userId: user.id, revokedAt: null }, + select: { + id: true, + horizonCycles: true, + historyCycles: true, + includeFertile: true, + titleMode: true, + }, + }); + if (!live) return null; + await tx.cycleCalendarFeed.update({ + where: { id: live.id }, + data: { revokedAt: new Date() }, + }); + const created = await tx.cycleCalendarFeed.create({ + data: { + userId: user.id, + createdById: actor.id, + tokenHash: minted.hash, + tokenPrefix: minted.prefix, + horizonCycles: live.horizonCycles, + historyCycles: live.historyCycles, + includeFertile: live.includeFertile, + titleMode: live.titleMode, + }, + select: FEED_SUMMARY_SELECT, + }); + await auditLog("cycle.feed.rotate", { + userId: user.id, + details: { + feedId: created.id, + previousFeedId: live.id, + tokenPrefix: created.tokenPrefix, + }, + client: tx, + }); + return created; + }); + if (!rotated) return noLiveFeed(); + + return apiSuccess( + { + ...feedLinks(resolveOrigin(request), minted.raw), + feed: toFeedSummary(rotated), + }, + 201, + ); +}); diff --git a/src/app/api/cycle/feed-subscription/route.ts b/src/app/api/cycle/feed-subscription/route.ts new file mode 100644 index 000000000..0b425bcf2 --- /dev/null +++ b/src/app/api/cycle/feed-subscription/route.ts @@ -0,0 +1,239 @@ +/** + * `/api/cycle/feed-subscription` — the record's calendar subscription + * (#1350). + * + * GET → the status: token prefix, options, fetch statistics, and what + * the feed serves right now. Never the hash, never the URL. + * POST → turn it on. Mints an `hlf_` token, stores only its HMAC, and + * returns the URL exactly once. + * PATCH → change the options. + * DELETE → turn it off. The row is revoked, never deleted. + * + * A separate namespace from the feed itself (`/api/cycle/feed/`), so + * neither the path redactor nor the public-path prefix can ever catch it. + * + * Auth: cookie session only, through `requireRecordAuth("manage", "cycle")`: + * the owner, or a MANAGE delegate whose grant covers the cycle section. A + * READ or WRITE delegate is refused. Turning it on also needs a fresh proof, + * because the link outlives the session that made it. A delegate's feed is + * re-checked against their grant on every fetch, and a handover claim ends it. + */ +import { NextRequest } from "next/server"; + +import { + apiHandler, + assertRecentCookieProof, + requireRecordAuth, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { parseJsonBody } from "@/lib/api/body"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { auditLog } from "@/lib/auth/audit"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { + FEED_MINT_LIMIT_PER_HOUR, + FEED_MINT_WINDOW_MS, + cookieOnlyRefusal, + feedExists, + feedLinks, + noLiveFeed, +} from "@/lib/cycle/feed/manage"; +import { + FEED_SUMMARY_SELECT, + cycleFeedStatus, + findLiveFeed, + toFeedSummary, +} from "@/lib/cycle/feed/status"; +import { mintFeedToken } from "@/lib/cycle/feed/token"; +import { prisma } from "@/lib/db"; +import { resolveOrigin } from "@/lib/http/origin"; +import { annotate } from "@/lib/logging/context"; +import { isP2002 } from "@/lib/prisma-errors"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { + destroyedDetails, + overwriteDetails, +} from "@/lib/sharing/audit-details"; +import { cycleFeedOptionsPatchSchema } from "@/lib/validations/cycle-feed"; + +export const GET = apiHandler(async () => { + const auth = await requireRecordAuth("manage", "cycle"); + if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); + const { user } = auth; + annotate({ action: { name: "cycle.feed.status" } }); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + return apiSuccess(await cycleFeedStatus(user, gate.profile.goal)); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const auth = await requireRecordAuth("manage", "cycle"); + if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); + const { user, actor } = auth; + // The proof is the person at the keyboard, so it is the actor's session. + await assertRecentCookieProof(actor, auth.session.id); + annotate({ action: { name: "cycle.feed.create" } }); + + // The operator's external-access switch covers this credential too. + if (!(await isApiGloballyEnabled())) { + return apiError("API is globally disabled", 403, { + errorCode: "cycle.feed.api_disabled", + }); + } + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const rl = await checkRateLimit( + `cycle-feed:mint:${actor.id}`, + FEED_MINT_LIMIT_PER_HOUR, + FEED_MINT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many calendar links, try again later", 429, { + errorCode: "cycle.feed.rateLimited", + }); + } + + if (await findLiveFeed(user.id)) return feedExists(); + + const minted = mintFeedToken(); + let created; + try { + created = await prisma.cycleCalendarFeed.create({ + data: { + userId: user.id, + createdById: actor.id, + tokenHash: minted.hash, + tokenPrefix: minted.prefix, + }, + select: FEED_SUMMARY_SELECT, + }); + } catch (err) { + // A second mint racing this one lost to the one-live-feed index. + if (isP2002(err)) return feedExists(); + throw err; + } + + await auditLog("cycle.feed.create", { + userId: user.id, + details: { feedId: created.id, tokenPrefix: created.tokenPrefix }, + }); + + return apiSuccess( + { + ...feedLinks(resolveOrigin(request), minted.raw), + feed: toFeedSummary(created), + }, + 201, + ); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const auth = await requireRecordAuth("manage", "cycle"); + if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); + const { user } = auth; + annotate({ action: { name: "cycle.feed.update" } }); + + const gate = await requireCycleEnabled(user.id, user.gender); + if (!gate.enabled) return gate.response; + + const parsed = await parseJsonBody(request, cycleFeedOptionsPatchSchema, { + maxBytes: 4 * 1024, + }); + if (!parsed.ok) return parsed.error; + const input = parsed.data; + + // The fertile window is a conception-goal option and nothing else. Refused + // here rather than stored and ignored, so a later goal change cannot switch + // on something the person never chose under that goal. + if ( + input.includeFertile === true && + gate.profile.goal !== "TRYING_TO_CONCEIVE" + ) { + return apiError( + "The fertile window is only offered when trying to conceive", + 422, + { + errorCode: "cycle.feed.fertile_goal", + }, + ); + } + + const live = await findLiveFeed(user.id); + if (!live) return noLiveFeed(); + + const data = { + ...(input.horizonCycles !== undefined + ? { horizonCycles: input.horizonCycles } + : {}), + ...(input.historyCycles !== undefined + ? { historyCycles: input.historyCycles } + : {}), + ...(input.includeFertile !== undefined + ? { includeFertile: input.includeFertile } + : {}), + ...(input.titleMode !== undefined ? { titleMode: input.titleMode } : {}), + }; + const updated = await prisma.cycleCalendarFeed.update({ + where: { id: live.id }, + data, + select: FEED_SUMMARY_SELECT, + }); + + await auditLog("cycle.feed.update", { + userId: user.id, + details: { + feedId: live.id, + ...overwriteDetails({ + before: { + horizonCycles: live.horizonCycles, + historyCycles: live.historyCycles, + includeFertile: live.includeFertile, + titleMode: live.titleMode, + }, + after: { + horizonCycles: updated.horizonCycles, + historyCycles: updated.historyCycles, + includeFertile: updated.includeFertile, + titleMode: updated.titleMode, + }, + }), + }, + }); + + return apiSuccess(await cycleFeedStatus(user, gate.profile.goal)); +}); + +export const DELETE = apiHandler(async () => { + const auth = await requireRecordAuth("manage", "cycle"); + if (auth.authMethod !== "cookie") return cookieOnlyRefusal(); + const { user } = auth; + annotate({ action: { name: "cycle.feed.revoke" } }); + + const live = await findLiveFeed(user.id); + if (!live) return noLiveFeed(); + + await prisma.cycleCalendarFeed.update({ + where: { id: live.id }, + data: { revokedAt: new Date() }, + }); + // The link stops working on the provider's next poll. What the provider + // already copied is beyond reach, which the settings card says before the + // link is ever made. + await auditLog("cycle.feed.revoke", { + userId: user.id, + details: { + feedId: live.id, + ...destroyedDetails({ + model: "CycleCalendarFeed", + id: live.id, + label: live.tokenPrefix, + effectiveAt: live.createdAt, + }), + }, + }); + + return apiSuccess({ revoked: true }); +}); diff --git a/src/app/api/cycle/feed/[token]/route.ts b/src/app/api/cycle/feed/[token]/route.ts new file mode 100644 index 000000000..9f141c4a7 --- /dev/null +++ b/src/app/api/cycle/feed/[token]/route.ts @@ -0,0 +1,25 @@ +/** + * `GET /api/cycle/feed/[.ics]` — the subscribable cycle forecast + * (#1350). + * + * Anonymous by design: a calendar provider polls this URL from its own + * servers, so the `hlf_` path segment is the only credential. Rate-limited + * before the lookup, one blunt 404 for every miss, every gate re-read on each + * fetch. The pipeline and its reasoning live in `serveCycleFeed`; this file + * only binds it to the route. + */ +import { NextRequest } from "next/server"; + +import { apiHandler } from "@/lib/api-handler"; +import { serveCycleFeed } from "@/lib/cycle/feed/serve"; + +type RouteParams = { params: Promise<{ token: string }> }; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { token } = await params; + return serveCycleFeed(request, token); + }, +); diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 4afffe33a..fdfa28c87 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -23,6 +23,7 @@ import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; import { parseJsonBody } from "@/lib/api/body"; import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; +import { resolveOrigin } from "@/lib/http/origin"; import { createShareLinkSchema, SHARE_LINK_FORBIDDEN_LEAVES, @@ -41,23 +42,6 @@ import { } from "@/lib/clinician-share/passphrase"; import type { Prisma } from "@/generated/prisma/client"; -/** - * The absolute origin to build the public share URL from. Prefers the - * forwarded host/proto a reverse proxy sets (the documented self-hosting path - * fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's - * own origin. The QR payload carries the passphrase in the FRAGMENT only. - */ -function resolveOrigin(request: NextRequest): string { - const proto = - request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || - new URL(request.url).protocol.replace(":", ""); - const host = - request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() || - request.headers.get("host") || - new URL(request.url).host; - return `${proto}://${host}`; -} - /** * The row selection every owner-facing projection reads. `_count.documents` * surfaces the size of the frozen document set without ever loading the ids diff --git a/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx new file mode 100644 index 000000000..6e30cf1ba --- /dev/null +++ b/src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +import { CycleCalendarFeedCard } from "../cycle-calendar-feed-card"; +import type { CycleFeedStatus } from "../use-cycle-feed"; + +function render(status: CycleFeedStatus) { + const client = new QueryClient(); + client.setQueryData(queryKeys.cycleFeedSubscription(), status); + return renderToStaticMarkup( + + + + + , + ); +} + +const FEED = { + tokenPrefix: "hlf_ab12", + createdAt: "2026-11-01T10:00:00.000Z", + lastFetchedAt: null, + fetchCount: 0, + lastFetcher: null, + horizonCycles: 3, + historyCycles: 0, + includeFertile: false, + titleMode: "DISCREET" as const, +}; + +describe("", () => { + it("says what a provider keeps before it offers the button", () => { + const html = render({ + feed: null, + projectionState: null, + fertileAvailable: false, + }); + const disclosure = html.indexOf("stores these dates on its servers"); + const button = html.indexOf("Turn on subscription"); + expect(disclosure).toBeGreaterThan(-1); + expect(button).toBeGreaterThan(disclosure); + }); + + it("shows only the prefix of a live link, and no URL", () => { + const html = render({ + feed: FEED, + projectionState: "OK", + fertileAvailable: false, + }); + expect(html).toContain("hlf_ab12"); + expect(html).not.toContain("/api/cycle/feed/"); + expect(html).toContain("Not fetched yet."); + // The fertile option exists only for a conception goal. + expect(html).not.toContain("Fertile window"); + }); + + it("explains a shorter feed in the muted tone", () => { + const html = render({ + feed: FEED, + projectionState: "LATE", + fertileAvailable: true, + }); + expect(html).toContain('data-slot="cycle-feed-state"'); + expect(html).toContain("later than expected"); + expect(html).toContain("Fertile window"); + }); +}); diff --git a/src/components/cycle/__tests__/predictions-panel.test.tsx b/src/components/cycle/__tests__/predictions-panel.test.tsx index a10a12819..9e2f70648 100644 --- a/src/components/cycle/__tests__/predictions-panel.test.tsx +++ b/src/components/cycle/__tests__/predictions-panel.test.tsx @@ -104,3 +104,59 @@ describe("", () => { expect(html).toContain("Prediction is off"); }); }); + +describe(" later cycles (#1350)", () => { + const projection = [ + { + k: 1, + start: "2026-07-01", + periodLength: 5, + bandStart: "2026-06-29", + bandEnd: "2026-07-03", + }, + { + k: 2, + start: "2026-07-29", + periodLength: 5, + bandStart: "2026-07-26", + bandEnd: "2026-08-01", + }, + { + k: 3, + start: "2026-08-26", + periodLength: 5, + bandStart: "2026-08-22", + bandEnd: "2026-08-30", + }, + ]; + + it("lists the cycles after the next one, as the server's windows", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-projection"'); + expect(html).toContain("Later cycles"); + // Two later rows; cycle 1 is the next-period row above them. + expect(html.match(/Around /g)).toHaveLength(2); + }); + + it("states a late period instead of listing later cycles", () => { + const html = render( + , + ); + expect(html).toContain('data-slot="cycle-projection-late"'); + expect(html).not.toContain('data-slot="cycle-projection"'); + }); +}); diff --git a/src/components/cycle/cycle-calendar-feed-card.tsx b/src/components/cycle/cycle-calendar-feed-card.tsx new file mode 100644 index 000000000..ec6bcf74e --- /dev/null +++ b/src/components/cycle/cycle-calendar-feed-card.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState } from "react"; +import { CalendarSync, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { ConfirmButton } from "@/components/ui/confirm-button"; +import { Label } from "@/components/ui/label"; +import { NativeSelect } from "@/components/ui/native-select"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { + recentProofErrorMessage, + useRecentProof, +} from "@/components/settings/security-section/use-recent-proof"; +import { formatRelativeTime } from "@/lib/i18n/relative-time"; +import { useTranslations } from "@/lib/i18n/context"; + +import { + useCycleFeedStatus, + useMintCycleFeed, + useRevokeCycleFeed, + useUpdateCycleFeed, + type CycleFeedMinted, + type CycleFeedOptionsPatch, + type CycleFeedStatus, + type FeedTitleMode, +} from "./use-cycle-feed"; + +/** + * #1350 — the calendar subscription card on the cycle settings tab. + * + * Off by default, and the disclosure comes before the button: a calendar + * provider keeps what it fetched, and turning the link off later reaches none + * of it. The link itself is shown once, right after it is made; after that the + * card knows only its prefix. Rendered only for the owner and a MANAGE + * delegate (the tab's own gate); the routes refuse everyone else regardless. + */ + +const HORIZONS = [1, 3, 6] as const; +const HISTORIES = [0, 3, 6, 12] as const; +const TITLE_MODES: FeedTitleMode[] = ["DISCREET", "DESCRIPTIVE"]; + +/** A long URL with its middle elided, so both ends stay recognisable. */ +function middleTruncate(url: string, keep = 28): string { + return url.length <= keep * 2 + 1 + ? url + : `${url.slice(0, keep)}…${url.slice(-keep / 2)}`; +} + +export function CycleCalendarFeedCard() { + const { t } = useTranslations(); + const status = useCycleFeedStatus(); + const proof = useRecentProof(); + const mint = useMintCycleFeed(proof); + const [minted, setMinted] = useState(null); + const [mintError, setMintError] = useState(null); + + async function handleMint(kind: "create" | "rotate") { + setMintError(null); + try { + setMinted(await mint.mutateAsync(kind)); + } catch (err) { + setMintError(recentProofErrorMessage(err, t("cycle.feed.error"))); + } + } + + return ( + + + {status.isError ? ( + void status.refetch()} + /> + ) : status.isLoading || !status.data ? ( +
+ +
+ ) : status.data.feed === null ? ( + void handleMint("create")} + /> + ) : ( + void handleMint("rotate")} + onRevoked={() => setMinted(null)} + /> + )} + {proof.dialog} +
+ ); +} + +function OffState({ + pending, + error, + onTurnOn, +}: { + pending: boolean; + error: string | null; + onTurnOn: () => void; +}) { + const { t } = useTranslations(); + return ( + <> +

{t("cycle.feed.disclosure")}

+ {error ? ( +

+ {error} +

+ ) : null} + + + + + ); +} + +function OnState({ + status, + minted, + pending, + error, + onRotate, + onRevoked, +}: { + status: CycleFeedStatus; + minted: CycleFeedMinted | null; + pending: boolean; + error: string | null; + onRotate: () => void; + onRevoked: () => void; +}) { + const { t } = useTranslations(); + const revoke = useRevokeCycleFeed(); + const feed = status.feed!; + const state = status.projectionState; + + return ( + <> + {minted ? ( + + ) : ( +

+ {t("cycle.feed.linkHidden", { prefix: feed.tokenPrefix })} +

+ )} + + {state === "LEARNING" || state === "LATE" || state === "OFF" ? ( +

+ {t(`cycle.projection.state.${state}`)} +

+ ) : null} + + + + {error || revoke.isError ? ( +

+ {error ?? t("cycle.feed.error")} +

+ ) : null} + + revoke.mutate(undefined, { onSuccess: onRevoked })} + pending={revoke.isPending} + variant="destructive" + destructive + size="sm" + className="min-h-11 w-full sm:w-auto" + /> + + + + ); +} + +/** The link, the one time it exists in the client. */ +function LinkPanel({ minted }: { minted: CycleFeedMinted }) { + const { t } = useTranslations(); + const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle"); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(minted.url); + setCopy("copied"); + } catch { + setCopy("failed"); + } + } + + return ( +
+
+ + + {middleTruncate(minted.url)} + +

+ {t("cycle.feed.linkOnce")} +

+
+
+ + +
+

+ {copy === "copied" + ? t("cycle.feed.copied") + : copy === "failed" + ? t("cycle.feed.copyFailed") + : ""} +

+
+ ); +} + +function FetchLine({ status }: { status: CycleFeedStatus }) { + const { t, locale } = useTranslations(); + const feed = status.feed!; + if (feed.lastFetchedAt === null) { + return ( +

+ {t("cycle.feed.notFetched")} +

+ ); + } + const when = formatRelativeTime(feed.lastFetchedAt, t, locale); + return ( +

+ {t("cycle.feed.lastFetched", { + when, + fetcher: t(`cycle.feed.fetcher.${feed.lastFetcher ?? "OTHER"}`), + })} +

+ ); +} + +function FeedOptions({ status }: { status: CycleFeedStatus }) { + const { t } = useTranslations(); + const update = useUpdateCycleFeed(); + const feed = status.feed!; + const save = (patch: CycleFeedOptionsPatch) => update.mutate(patch); + + return ( +
+
+
+ + save({ horizonCycles: Number(e.target.value) })} + > + {HORIZONS.map((n) => ( + + ))} + +
+
+ + save({ historyCycles: Number(e.target.value) })} + > + {HISTORIES.map((n) => ( + + ))} + +
+
+ + + save({ titleMode: e.target.value as FeedTitleMode }) + } + > + {TITLE_MODES.map((m) => ( + + ))} + +
+
+ {status.fertileAvailable ? ( +
+
+ +

+ {t("cycle.feed.fertileDescription")} +

+
+ save({ includeFertile: v })} + className="mt-0.5 shrink-0" + /> +
+ ) : null} + {update.isError ? ( +

+ {t("cycle.feed.error")} +

+ ) : null} +
+ ); +} + +function HelpList() { + const { t } = useTranslations(); + return ( +
+

{t("cycle.feed.help.title")}

+
    +
  • {t("cycle.feed.help.google")}
  • +
  • {t("cycle.feed.help.apple")}
  • +
  • {t("cycle.feed.help.outlook")}
  • +
+
+ ); +} diff --git a/src/components/cycle/cycle-settings-tab.tsx b/src/components/cycle/cycle-settings-tab.tsx new file mode 100644 index 000000000..9cb8c2227 --- /dev/null +++ b/src/components/cycle/cycle-settings-tab.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { CycleCalendarFeedCard } from "./cycle-calendar-feed-card"; +import { CycleSettings } from "./cycle-settings"; +import type { CycleProfileDTO } from "./types"; + +/** + * The cycle page's settings tab: the profile form, then the calendar + * subscription (#1350). The form remounts on a profile refetch (`key`), the + * subscription card keeps its own state, including a link it just showed. + */ +export function CycleSettingsTab({ profile }: { profile: CycleProfileDTO }) { + return ( +
+ + +
+ ); +} diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 2243644bb..586fe4b14 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -23,7 +23,7 @@ import { PhaseEducationCard } from "./phase-education-card"; import { PredictionsPanel } from "./predictions-panel"; import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab"; import { CycleSymptomPatterns } from "./cycle-symptom-patterns"; -import { CycleSettings } from "./cycle-settings"; +import { CycleSettingsTab } from "./cycle-settings-tab"; import { PHASE_HUE } from "./phase-tokens"; import { monthOf, monthWindowOutside } from "./calendar-window"; import { @@ -417,6 +417,8 @@ export function CycleView() { prediction={calendar.data?.prediction ?? null} rawChartMode={calendar.data?.profile.rawChartMode ?? false} history={history.data?.data} + projection={calendar.data?.projection} + projectionState={calendar.data?.projectionState ?? null} /> ) : profileQuery.data && canManageCycle ? ( - + ) : null} diff --git a/src/components/cycle/predictions-panel.tsx b/src/components/cycle/predictions-panel.tsx index b13ae226e..991e272d9 100644 --- a/src/components/cycle/predictions-panel.tsx +++ b/src/components/cycle/predictions-panel.tsx @@ -14,7 +14,13 @@ import { TileHeader } from "@/components/insights/tile-header"; import { useTranslations, useFormatters } from "@/lib/i18n/context"; import type { Formatters } from "@/lib/format-locale"; import { FERTILE_HUE, FLOW_HUE, OVULATION_HUE } from "./phase-tokens"; -import type { CyclePrediction, CycleHistoryResponse } from "./types"; +import type { + CyclePrediction, + CycleHistoryResponse, + ProjectedCycle, + ProjectionState, +} from "./types"; +import { UpcomingCycles } from "./upcoming-cycles"; import { EmptyState } from "@/components/ui/empty-state"; /** @@ -59,12 +65,17 @@ export interface PredictionsPanelProps { prediction: CyclePrediction | null; rawChartMode: boolean; history: CycleHistoryResponse | undefined; + /** #1350 — the server's projection of the cycles after the next one. */ + projection?: readonly ProjectedCycle[]; + projectionState?: ProjectionState | null; } export function PredictionsPanel({ prediction, rawChartMode, history, + projection = [], + projectionState = null, }: PredictionsPanelProps) { const { t } = useTranslations(); const fmt = useFormatters(); @@ -94,6 +105,7 @@ export function PredictionsPanel({ body={t("cycle.predictions.stillLearningBody")} /> + ) : ( <> @@ -119,6 +131,7 @@ export function PredictionsPanel({ value={formatDate(prediction.predictedOvulation, fmt)} /> ) : null} + )} diff --git a/src/components/cycle/types.ts b/src/components/cycle/types.ts index bc3b57ee3..e8502342d 100644 --- a/src/components/cycle/types.ts +++ b/src/components/cycle/types.ts @@ -114,10 +114,28 @@ export interface CalendarResponse { * phase band — render the calm "learning your cycle" state over the grid. */ stillLearning: boolean; + /** + * #1350 — the next cycles with widening bands, resolved server-side (the + * calendar feed serves the same list). Render as given; never extrapolate. + */ + projection: ProjectedCycle[]; + projectionState: ProjectionState; days: CalendarDay[]; meta: { generatedAt: string }; } +/** Mirrors `ProjectionState` in `src/lib/cycle/projection.ts`. */ +export type ProjectionState = "OK" | "LEARNING" | "LATE" | "OFF"; + +/** Mirrors `ProjectedCycle` in `src/lib/cycle/projection.ts`. */ +export interface ProjectedCycle { + k: number; + start: string; + periodLength: number; + bandStart: string; + bandEnd: string; +} + export interface MenstrualCycleDTO { id: string; startDate: string; diff --git a/src/components/cycle/upcoming-cycles.tsx b/src/components/cycle/upcoming-cycles.tsx new file mode 100644 index 000000000..1ab5e89ca --- /dev/null +++ b/src/components/cycle/upcoming-cycles.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; + +import { FLOW_HUE } from "./phase-tokens"; +import type { ProjectedCycle, ProjectionState } from "./types"; + +/** + * #1350 — the periods after the next one, each as the window its start may + * fall in. The list and its bands are the server's (`/api/cycle/calendar` + * `projection`, the same list the calendar feed serves); this only renders + * them. The band widens from one row to the next, and the server stops the + * list where a date would be a guess. + * + * The next period itself is the row above this one in the predictions card, + * so cycle 1 is skipped here. A late period is stated, in the muted tone: a + * late period is a fact about the calendar, not a warning. + */ +export function UpcomingCycles({ + projection, + state, +}: { + projection: readonly ProjectedCycle[]; + state: ProjectionState | null; +}) { + const { t } = useTranslations(); + const fmt = useFormatters(); + + if (state === "LATE") { + return ( +

+ {t("cycle.projection.state.LATE")} +

+ ); + } + + const later = projection.filter((c) => c.k >= 2); + if (later.length === 0) return null; + + return ( +
+

+ {t("cycle.projection.title")} +

+
    + {later.map((c) => ( +
  • +
  • + ))} +
+
+ ); +} diff --git a/src/components/cycle/use-cycle-feed.ts b/src/components/cycle/use-cycle-feed.ts new file mode 100644 index 000000000..58ab6ed64 --- /dev/null +++ b/src/components/cycle/use-cycle-feed.ts @@ -0,0 +1,110 @@ +"use client"; + +/** + * #1350 — the calendar subscription's read and write hooks. + * + * Every value the card shows is the server's: the status read carries the + * options, the fetch statistics as a calendar-app family, and the projection + * state that explains a shorter feed. The URL exists in the client only in + * the one mint or rotate response that returns it, held in the card's state + * and gone when the card unmounts. + */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { apiDelete, apiGet, apiPatch, apiPost } from "@/lib/api/api-fetch"; +import { queryKeys } from "@/lib/query-keys"; + +import type { ProjectionState } from "./types"; + +export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE"; +export type FeedFetcher = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER"; + +export interface CycleFeedSummary { + tokenPrefix: string; + createdAt: string; + lastFetchedAt: string | null; + fetchCount: number; + lastFetcher: FeedFetcher | null; + horizonCycles: number; + historyCycles: number; + includeFertile: boolean; + titleMode: FeedTitleMode; +} + +export interface CycleFeedStatus { + feed: CycleFeedSummary | null; + projectionState: ProjectionState | null; + fertileAvailable: boolean; +} + +export interface CycleFeedMinted { + url: string; + webcalUrl: string; + feed: CycleFeedSummary; +} + +export type CycleFeedOptionsPatch = Partial< + Pick< + CycleFeedSummary, + "horizonCycles" | "historyCycles" | "includeFertile" | "titleMode" + > +>; + +const PATH = "/api/cycle/feed-subscription"; + +export function useCycleFeedStatus(enabled = true) { + return useQuery({ + queryKey: queryKeys.cycleFeedSubscription(), + queryFn: () => apiGet(PATH), + staleTime: 60_000, + enabled, + }); +} + +function useInvalidateStatus() { + const qc = useQueryClient(); + return () => + qc.invalidateQueries({ queryKey: queryKeys.cycleFeedSubscription() }); +} + +/** + * Turn the subscription on, or rotate its link. `proof` is the recent-proof + * flow: both answer 401 `auth.reproof.required` without a fresh proof, and + * `proof.run` opens the re-proof dialog and retries. + */ +export function useMintCycleFeed(proof: { + run: (action: () => Promise) => Promise; +}) { + const invalidate = useInvalidateStatus(); + return useMutation({ + mutationKey: queryKeys.cycleFeedSubscription(), + mutationFn: (kind: "create" | "rotate") => + kind === "create" + ? proof.run(() => + apiPost("/api/cycle/feed-subscription"), + ) + : proof.run(() => + apiPost("/api/cycle/feed-subscription/rotate"), + ), + onSettled: () => invalidate(), + }); +} + +export function useUpdateCycleFeed() { + const invalidate = useInvalidateStatus(); + return useMutation({ + mutationKey: queryKeys.cycleFeedSubscription(), + mutationFn: (patch: CycleFeedOptionsPatch) => + apiPatch(PATH, patch), + onSettled: () => invalidate(), + }); +} + +export function useRevokeCycleFeed() { + const invalidate = useInvalidateStatus(); + return useMutation({ + mutationKey: queryKeys.cycleFeedSubscription(), + mutationFn: () => apiDelete<{ revoked: true }>(PATH), + onSettled: () => invalidate(), + }); +} diff --git a/src/lib/__tests__/glitchtip-path-secret-redaction.test.ts b/src/lib/__tests__/glitchtip-path-secret-redaction.test.ts index a2a1f295a..54d2b47d3 100644 --- a/src/lib/__tests__/glitchtip-path-secret-redaction.test.ts +++ b/src/lib/__tests__/glitchtip-path-secret-redaction.test.ts @@ -101,6 +101,16 @@ describe("GlitchTip forwarder — path-segment secret redaction", () => { expect(input.url).not.toContain(shareToken); }); + it("does not forward a cycle calendar-feed token verbatim", async () => { + // Built at run time so no token-shaped literal sits in the source. + const feedToken = `hlf_${"3c".repeat(24)}`; + const input = await forwardedPayload( + `http://localhost/api/cycle/feed/${feedToken}.ics`, + ); + expect(input.url).not.toContain(feedToken); + expect(input.url).toContain("/api/cycle/feed/"); + }); + it("still strips the query string", async () => { const input = await forwardedPayload( "http://localhost/api/auth/oidc/callback?code=abc123&state=xyz789", diff --git a/src/lib/__tests__/secret-shape.test.ts b/src/lib/__tests__/secret-shape.test.ts index b3c32b5fb..85df61353 100644 --- a/src/lib/__tests__/secret-shape.test.ts +++ b/src/lib/__tests__/secret-shape.test.ts @@ -14,6 +14,10 @@ describe("looksSecretShaped", () => { ["an MCP authorization code", "hlac_eyJ0eXAi"], ["an MCP refresh token", "hlrt_eyJ0eXAi"], ["an MCP client id", "hlc_eyJ0eXAi"], + [ + "a cycle calendar-feed token", + `url /api/cycle/feed/hlf_${"9e".repeat(24)}.ics`, + ], ["an OpenAI key", "echoed sk-1234567890"], ["an Anthropic key", "sk-ant-api03-xyz12345"], ])("matches %s", (_label, text) => { diff --git a/src/lib/cycle/__tests__/ics.test.ts b/src/lib/cycle/__tests__/ics.test.ts new file mode 100644 index 000000000..e024efed4 --- /dev/null +++ b/src/lib/cycle/__tests__/ics.test.ts @@ -0,0 +1,154 @@ +/** + * #1350 — the RFC 5545 writer behind the calendar feed. + */ +import { describe, expect, it } from "vitest"; + +import { + escapeText, + eventUid, + foldLine, + icsContentHash, + icsStamp, + renderIcs, + type IcsCalendar, +} from "../ics"; + +const CAL: IcsCalendar = { + name: "HealthLog", + events: [ + { + uid: "abc@healthlog.invalid", + start: "2026-11-10", + endExclusive: "2026-11-17", + summary: "●", + description: "±2 days", + }, + ], +}; + +const STAMP = new Date("2026-11-06T09:30:00.000Z"); + +describe("renderIcs", () => { + it("matches the golden body", () => { + expect(renderIcs(CAL, STAMP)).toBe( + [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//HealthLog//Calendar feed//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "X-WR-CALNAME:HealthLog", + "REFRESH-INTERVAL;VALUE=DURATION:PT6H", + "X-PUBLISHED-TTL:PT6H", + "BEGIN:VEVENT", + "UID:abc@healthlog.invalid", + "DTSTAMP:20261106T093000Z", + "LAST-MODIFIED:20261106T093000Z", + "DTSTART;VALUE=DATE:20261110", + "DTEND;VALUE=DATE:20261117", + "SUMMARY:●", + "DESCRIPTION:±2 days", + "TRANSP:TRANSPARENT", + "CLASS:PRIVATE", + "END:VEVENT", + "END:VCALENDAR", + "", + ].join("\r\n"), + ); + }); + + it("ends every line with CRLF and carries no bare LF", () => { + const body = renderIcs(CAL, STAMP); + expect(body.endsWith("\r\n")).toBe(true); + expect(body.replace(/\r\n/g, "")).not.toMatch(/[\r\n]/); + }); + + it("writes all-day events with an exclusive DTEND and no time zone", () => { + const body = renderIcs(CAL, STAMP); + expect(body).toContain("DTSTART;VALUE=DATE:20261110"); + expect(body).toContain("DTEND;VALUE=DATE:20261117"); + expect(body).not.toContain("VTIMEZONE"); + expect(body).not.toContain("TZID"); + }); + + it("refuses a malformed day rather than writing a broken calendar", () => { + expect(() => + renderIcs( + { name: "x", events: [{ ...CAL.events[0], start: "2026-11-1" }] }, + STAMP, + ), + ).toThrow(); + }); +}); + +describe("escapeText", () => { + it("escapes backslash, semicolon, comma and newline", () => { + expect(escapeText("a\\b;c,d\ne")).toBe("a\\\\b\\;c\\,d\\ne"); + expect(escapeText("x\r\ny")).toBe("x\\ny"); + }); +}); + +describe("foldLine", () => { + const octets = (s: string) => Buffer.byteLength(s, "utf8"); + + it("leaves a short line alone", () => { + expect(foldLine("SUMMARY:short")).toBe("SUMMARY:short"); + }); + + it("folds at 75 octets with a leading space on each continuation", () => { + const folded = foldLine(`DESCRIPTION:${"a".repeat(200)}`); + const lines = folded.split("\r\n"); + expect(lines.length).toBeGreaterThan(2); + for (const line of lines) expect(octets(line)).toBeLessThanOrEqual(75); + for (const line of lines.slice(1)) expect(line.startsWith(" ")).toBe(true); + expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe( + `DESCRIPTION:${"a".repeat(200)}`, + ); + }); + + it("never splits a multibyte character", () => { + // Cyrillic and Hangul are two and three octets each; a byte-counted cut + // would land inside one of them sooner or later. + const value = `SUMMARY:${"Месячные 생리 ".repeat(12)}`; + const lines = foldLine(value).split("\r\n"); + for (const line of lines) { + expect(octets(line)).toBeLessThanOrEqual(75); + // A cut through a sequence would leave a replacement character after a + // round trip through bytes. + expect(Buffer.from(line, "utf8").toString("utf8")).toBe(line); + expect(line).not.toContain("�"); + } + expect(lines.map((l, i) => (i === 0 ? l : l.slice(1))).join("")).toBe( + value, + ); + }); +}); + +describe("eventUid", () => { + it("is stable for a feed and a slot, and unrelated across feeds", () => { + const a = eventUid("feed-1", "pred:1"); + expect(eventUid("feed-1", "pred:1")).toBe(a); + expect(eventUid("feed-1", "pred:2")).not.toBe(a); + expect(eventUid("feed-2", "pred:1")).not.toBe(a); + expect(a).toMatch(/^[a-z2-7]{32}@healthlog\.invalid$/); + }); + + it("carries neither the feed id nor the slot", () => { + const uid = eventUid("cmfeedid123", "obs:cmcycleid456"); + expect(uid).not.toContain("cmfeedid123"); + expect(uid).not.toContain("cmcycleid456"); + }); +}); + +describe("icsContentHash", () => { + it("does not depend on the stamp, only on the content", () => { + expect(icsContentHash(CAL)).toBe(icsContentHash({ ...CAL })); + expect(icsContentHash(CAL)).not.toBe( + icsContentHash({ ...CAL, events: [] }), + ); + }); + + it("formats a stamp as basic UTC", () => { + expect(icsStamp(STAMP)).toBe("20261106T093000Z"); + }); +}); diff --git a/src/lib/cycle/feed/__tests__/build.test.ts b/src/lib/cycle/feed/__tests__/build.test.ts new file mode 100644 index 000000000..44c05a8bf --- /dev/null +++ b/src/lib/cycle/feed/__tests__/build.test.ts @@ -0,0 +1,223 @@ +/** + * #1350 — building a feed's body from the record's rows: the record's own + * day, zone and language; a hash that moves only with the content; and the + * goal and cold-start gates on the fertile window. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + cycleProfile: { findUnique: vi.fn() }, + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + }, +})); + +const localeSpy = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/i18n/server-locale", () => ({ + resolveServerLocale: localeSpy, +})); + +import { prisma } from "@/lib/db"; +import { addDays } from "@/lib/cycle/day-math"; +import { renderIcs } from "@/lib/cycle/ics"; + +import { buildCycleFeed, feedLocale } from "../build"; +import { DISCREET_SUMMARY } from "../events"; +import { FEED, SUBJECT, profileRow, regularCycles } from "./fixtures"; + +const db = prisma as unknown as { + cycleProfile: { findUnique: ReturnType }; + menstrualCycle: { findMany: ReturnType }; + cycleDayLog: { findMany: ReturnType }; + measurement: { findMany: ReturnType }; +}; + +// The last start lands on 2026-10-20, so the next period is due mid-November. +const FIRST = "2025-11-18"; +const NOW = new Date("2026-11-06T12:00:00.000Z"); + +beforeEach(() => { + vi.clearAllMocks(); + db.cycleProfile.findUnique.mockResolvedValue(profileRow()); + db.menstrualCycle.findMany.mockResolvedValue(regularCycles(FIRST)); + db.cycleDayLog.findMany.mockResolvedValue([]); + db.measurement.findMany.mockResolvedValue([]); +}); + +describe("buildCycleFeed", () => { + it("serves the projected periods with discreet titles by default", async () => { + const built = await buildCycleFeed(FEED, SUBJECT, NOW); + expect(built.projectionState).toBe("OK"); + expect(built.calendar.name).toBe("HealthLog"); + expect(built.calendar.events.length).toBe(3); + for (const e of built.calendar.events) { + expect(e.summary).toBe(DISCREET_SUMMARY); + } + }); + + it("hashes the same inputs on the same day to the same value", async () => { + const a = await buildCycleFeed(FEED, SUBJECT, NOW); + const b = await buildCycleFeed( + FEED, + SUBJECT, + new Date("2026-11-06T18:00:00.000Z"), + ); + expect(b.contentHash).toBe(a.contentHash); + // The events keep their UIDs across rebuilds. + expect(b.calendar.events.map((e) => e.uid)).toEqual( + a.calendar.events.map((e) => e.uid), + ); + }); + + it("moves the hash when a cycle is deleted", async () => { + const before = await buildCycleFeed(FEED, SUBJECT, NOW); + db.menstrualCycle.findMany.mockResolvedValue( + regularCycles(FIRST).slice(0, -1), + ); + const after = await buildCycleFeed(FEED, SUBJECT, NOW); + expect(after.contentHash).not.toBe(before.contentHash); + }); + + it("moves the hash when a day roll takes the forecast past its band", async () => { + const before = await buildCycleFeed(FEED, SUBJECT, NOW); + const late = await buildCycleFeed( + FEED, + SUBJECT, + new Date("2026-12-15T12:00:00.000Z"), + ); + expect(late.projectionState).toBe("LATE"); + expect(late.calendar.events).toEqual([]); + expect(late.contentHash).not.toBe(before.contentHash); + }); + + it("reads today in the record's zone, not in UTC", async () => { + // Find the next period's band end, then ask at 23:30 UTC on that day: in + // Berlin it is already the following day, which is past the band. + const onTime = await buildCycleFeed(FEED, SUBJECT, NOW); + const first = onTime.calendar.events[0]; + // DTEND is the band end plus the period length; recover the band end. + const bandEnd = addDays(first.endExclusive, -5); + const lateEvening = new Date(`${bandEnd}T23:30:00.000Z`); + + const utc = await buildCycleFeed( + FEED, + { ...SUBJECT, timezone: "UTC" }, + lateEvening, + ); + const berlin = await buildCycleFeed(FEED, SUBJECT, lateEvening); + expect(utc.projectionState).toBe("OK"); + expect(berlin.projectionState).toBe("LATE"); + }); + + it("names nothing when the prediction is switched off, and history still works", async () => { + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("GENERAL_HEALTH", { predictionEnabled: false }), + ); + const off = await buildCycleFeed( + { ...FEED, historyCycles: 3 }, + SUBJECT, + NOW, + ); + expect(off.projectionState).toBe("OFF"); + expect(off.calendar.events).toHaveLength(3); + const starts = off.calendar.events.map((e) => e.start); + expect(starts.at(-1)).toBe("2026-10-20"); + }); + + it("treats raw-chart mode as no forecast", async () => { + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("GENERAL_HEALTH", { rawChartMode: true }), + ); + const built = await buildCycleFeed(FEED, SUBJECT, NOW); + expect(built.projectionState).toBe("OFF"); + expect(built.calendar.events).toEqual([]); + }); + + describe("the fertile window", () => { + it("is added for TRYING_TO_CONCEIVE when asked for", async () => { + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("TRYING_TO_CONCEIVE"), + ); + const built = await buildCycleFeed( + { ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" }, + SUBJECT, + NOW, + ); + expect( + built.calendar.events.filter( + (e) => e.summary === "Fertile window (estimate)", + ), + ).toHaveLength(1); + }); + + it("is never added for AVOID_PREGNANCY, whatever the option says", async () => { + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("AVOID_PREGNANCY"), + ); + const built = await buildCycleFeed( + { ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" }, + SUBJECT, + NOW, + ); + expect(built.calendar.events.map((e) => e.summary)).not.toContain( + "Fertile window (estimate)", + ); + expect(built.calendar.events).toHaveLength(3); + }); + + it("is withheld while the engine is still learning", async () => { + db.cycleProfile.findUnique.mockResolvedValue( + profileRow("TRYING_TO_CONCEIVE"), + ); + db.menstrualCycle.findMany.mockResolvedValue( + regularCycles("2026-09-22", 3), + ); + const built = await buildCycleFeed( + { ...FEED, includeFertile: true, titleMode: "DESCRIPTIVE" }, + SUBJECT, + NOW, + ); + expect(built.projectionState).toBe("LEARNING"); + expect(built.calendar.events.map((e) => e.summary)).toEqual([ + "Period (estimate)", + ]); + }); + }); + + it("uses the record's stored language for descriptive titles", async () => { + const built = await buildCycleFeed( + { ...FEED, titleMode: "DESCRIPTIVE" }, + { ...SUBJECT, locale: "de" }, + NOW, + ); + expect(built.calendar.name).toBe("Zyklus (HealthLog)"); + expect(built.calendar.events[0].summary).toBe("Periode (Schätzung)"); + }); + + it("falls back to the instance default for a null locale and never asks the request", async () => { + expect(feedLocale(null)).toBe("en"); + expect(feedLocale("xx")).toBe("en"); + const built = await buildCycleFeed( + { ...FEED, titleMode: "DESCRIPTIVE" }, + { ...SUBJECT, locale: null }, + NOW, + ); + expect(built.calendar.events[0].summary).toBe("Period (estimate)"); + // A provider's Accept-Language must never reach the choice. + expect(localeSpy).not.toHaveBeenCalled(); + }); + + it("renders the descriptive description with the likely start and the band", async () => { + const built = await buildCycleFeed( + { ...FEED, titleMode: "DESCRIPTIVE" }, + SUBJECT, + NOW, + ); + const body = renderIcs(built.calendar, NOW); + expect(body).toMatch( + /DESCRIPTION:Most likely start [^\\\r]+\\, ±\d+ days?\r\n/, + ); + }); +}); diff --git a/src/lib/cycle/feed/__tests__/fixtures.ts b/src/lib/cycle/feed/__tests__/fixtures.ts new file mode 100644 index 000000000..ad65c01e5 --- /dev/null +++ b/src/lib/cycle/feed/__tests__/fixtures.ts @@ -0,0 +1,57 @@ +/** + * Shared rows for the calendar-feed tests: a regular record with a year of + * 28-day cycles, mocked through the same Prisma calls the loader makes. + */ +import { addDays } from "@/lib/cycle/day-math"; + +export function regularCycles(first: string, count = 13, length = 28) { + const out = []; + for (let i = 0; i < count; i++) { + const startDate = addDays(first, i * length); + out.push({ + id: `cycle-${i}`, + userId: "subject", + startDate, + endDate: i < count - 1 ? addDays(startDate, length) : null, + periodEndDate: i % 2 === 0 ? addDays(startDate, 4) : null, + ovulationDate: null, + ovulationConfirmed: false, + deletedAt: null, + }); + } + return out; +} + +export function profileRow(goal = "GENERAL_HEALTH", overrides = {}) { + return { + id: "profile", + userId: "subject", + goal, + cycleTrackingEnabled: true, + typicalCycleLength: null, + typicalPeriodLength: null, + lutealPhaseLength: null, + secondarySymptom: "MUCUS", + predictionEnabled: true, + rawChartMode: false, + discreetNotifications: false, + sensitiveCategoryEncryption: true, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +export const FEED = { + id: "feed-1", + horizonCycles: 3, + historyCycles: 0, + includeFertile: false, + titleMode: "DISCREET" as const, +}; + +export const SUBJECT = { + id: "subject", + timezone: "Europe/Berlin", + locale: "en", +}; diff --git a/src/lib/cycle/feed/__tests__/serve.test.ts b/src/lib/cycle/feed/__tests__/serve.test.ts new file mode 100644 index 000000000..ae6da70c7 --- /dev/null +++ b/src/lib/cycle/feed/__tests__/serve.test.ts @@ -0,0 +1,290 @@ +/** + * #1350 — the anonymous feed fetch: the limit before any lookup, one 404 for + * every miss, every gate re-read per fetch, and the ETag / 304 path. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const db = vi.hoisted(() => ({ + cycleCalendarFeed: { findFirst: vi.fn(), update: vi.fn() }, + user: { findFirst: vi.fn() }, +})); +vi.mock("@/lib/db", () => ({ prisma: db })); + +const limits = vi.hoisted(() => ({ + surface: vi.fn(), + token: vi.fn(), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: limits.surface, + checkRateLimit: limits.token, + rateLimitHeaders: () => ({ "Retry-After": "60" }), +})); + +const gates = vi.hoisted(() => ({ + api: vi.fn(), + cycle: vi.fn(), + grant: vi.fn(), +})); +vi.mock("@/lib/app-settings", () => ({ isApiGloballyEnabled: gates.api })); +vi.mock("@/lib/cycle/gate", () => ({ isCycleAvailableForUser: gates.cycle })); +vi.mock("@/lib/sharing/grant-access", async (orig) => ({ + ...(await orig()), + findActiveGrant: gates.grant, +})); + +const built = vi.hoisted(() => ({ build: vi.fn() })); +vi.mock("../build", () => ({ buildCycleFeed: built.build })); + +import { hashToken } from "@/lib/auth/hmac"; +import { settleBackgroundTasks } from "@/lib/logging/background-tasks"; + +import { serveCycleFeed } from "../serve"; + +const TOKEN = `hlf_${"ab".repeat(24)}`; +const ALLOWED = { allowed: true, limit: 1, remaining: 1, resetAt: new Date() }; + +const FEED_ROW = { + id: "feed-1", + userId: "subject", + createdById: "subject", + bodyHash: "old-hash", + contentChangedAt: new Date("2026-11-01T00:00:00Z"), + horizonCycles: 3, + historyCycles: 0, + includeFertile: false, + titleMode: "DISCREET", +}; + +function request(path = `${TOKEN}.ics`, headers: Record = {}) { + return { + req: new NextRequest(`http://localhost/api/cycle/feed/${path}`, { + headers, + }), + segment: path, + }; +} + +async function fetchFeed(path?: string, headers?: Record) { + const { req, segment } = request(path, headers); + const res = await serveCycleFeed(req, segment); + await settleBackgroundTasks(); + return res; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(40)); + limits.surface.mockResolvedValue({ ...ALLOWED, ip: "203.0.113.7" }); + limits.token.mockResolvedValue(ALLOWED); + db.cycleCalendarFeed.findFirst.mockResolvedValue(FEED_ROW); + db.cycleCalendarFeed.update.mockResolvedValue({}); + db.user.findFirst.mockResolvedValue({ + id: "subject", + timezone: "Europe/Berlin", + locale: "en", + }); + gates.api.mockResolvedValue(true); + gates.cycle.mockResolvedValue(true); + gates.grant.mockResolvedValue(null); + built.build.mockResolvedValue({ + calendar: { + name: "HealthLog", + events: [ + { + uid: "u@healthlog.invalid", + start: "2026-11-15", + endExclusive: "2026-11-22", + summary: "●", + }, + ], + }, + contentHash: "new-hash", + projectionState: "OK", + }); +}); + +async function expectBlunt404(res: Response) { + expect(res.status).toBe(404); + const body = await res.json(); + expect(body).toMatchObject({ error: "Not found" }); + expect(body.meta?.errorCode).toBe("cycle.feed.notFound"); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); +} + +describe("serveCycleFeed", () => { + it("serves the calendar with its validator and privacy headers", async () => { + const res = await fetchFeed(); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe( + "text/calendar; charset=utf-8", + ); + expect(res.headers.get("cache-control")).toBe( + "private, no-cache, max-age=0", + ); + expect(res.headers.get("etag")).toBe('"new-hash"'); + expect(res.headers.get("referrer-policy")).toBe("no-referrer"); + expect(await res.text()).toContain("BEGIN:VCALENDAR"); + }); + + it("looks the token up by its HMAC, live rows only", async () => { + await fetchFeed(); + expect(db.cycleCalendarFeed.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { tokenHash: hashToken(TOKEN), revokedAt: null }, + }), + ); + }); + + it("takes the anonymous limit before any lookup", async () => { + limits.surface.mockResolvedValue({ ...ALLOWED, allowed: false, ip: null }); + const res = await fetchFeed(); + expect(res.status).toBe(429); + expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled(); + expect(limits.surface).toHaveBeenCalledWith( + expect.anything(), + "cycle-feed", + 120, + 3_600_000, + ); + }); + + it("throttles one link through its own bucket", async () => { + limits.token.mockResolvedValue({ ...ALLOWED, allowed: false }); + const res = await fetchFeed(); + expect(res.status).toBe(429); + expect(limits.token).toHaveBeenCalledWith( + `cycle-feed:${hashToken(TOKEN)}`, + 30, + 3_600_000, + ); + expect(built.build).not.toHaveBeenCalled(); + }); + + it("answers a malformed token without a lookup", async () => { + await expectBlunt404(await fetchFeed("hlf_short.ics")); + await expectBlunt404(await fetchFeed(`hlk_${"ab".repeat(24)}`)); + expect(db.cycleCalendarFeed.findFirst).not.toHaveBeenCalled(); + }); + + it.each([ + [ + "unknown or revoked", + () => db.cycleCalendarFeed.findFirst.mockResolvedValue(null), + ], + [ + "the record pending deletion or gone", + () => db.user.findFirst.mockResolvedValue(null), + ], + [ + "the operator's external-access switch off", + () => gates.api.mockResolvedValue(false), + ], + ["cycle tracking off", () => gates.cycle.mockResolvedValue(false)], + ])("answers the same 404 for %s", async (_label, arrange) => { + arrange(); + await expectBlunt404(await fetchFeed()); + expect(built.build).not.toHaveBeenCalled(); + }); + + describe("a feed a delegate minted", () => { + beforeEach(() => { + db.cycleCalendarFeed.findFirst.mockResolvedValue({ + ...FEED_ROW, + createdById: "manager", + }); + }); + + const grant = (overrides: Record = {}) => ({ + id: "g", + access: "MANAGE", + acceptedAt: new Date("2026-01-01"), + revokedAt: null, + expiresAt: null, + scopeJson: null, + ...overrides, + }); + + it("is served while the manager's MANAGE grant over cycle is live", async () => { + gates.grant.mockResolvedValue(grant()); + expect((await fetchFeed()).status).toBe(200); + expect(gates.grant).toHaveBeenCalledWith({ + grantorId: "subject", + granteeId: "manager", + }); + }); + + it.each([ + ["no live grant", null], + ["a WRITE grant", grant({ access: "WRITE" })], + ["a READ grant", grant({ access: "READ" })], + [ + "a grant scoped away from cycle", + grant({ scopeJson: ["measurements"] }), + ], + ])("is a blunt 404 with %s", async (_label, row) => { + gates.grant.mockResolvedValue(row); + await expectBlunt404(await fetchFeed()); + }); + + it("is a blunt 404 once the manager's account is gone", async () => { + gates.grant.mockResolvedValue(grant()); + db.user.findFirst.mockImplementation( + async (args: { where: { id: string } }) => + args.where.id === "manager" + ? null + : { id: "subject", timezone: null, locale: null }, + ); + await expectBlunt404(await fetchFeed()); + }); + }); + + it("moves the stamp and the hash only when the content moved", async () => { + await fetchFeed(); + expect(db.cycleCalendarFeed.update).toHaveBeenCalledWith({ + where: { id: "feed-1" }, + data: expect.objectContaining({ + bodyHash: "new-hash", + contentChangedAt: expect.any(Date), + fetchCount: { increment: 1 }, + }), + }); + + vi.clearAllMocks(); + db.cycleCalendarFeed.findFirst.mockResolvedValue({ + ...FEED_ROW, + bodyHash: "new-hash", + }); + db.cycleCalendarFeed.update.mockResolvedValue({}); + const res = await fetchFeed(); + const data = db.cycleCalendarFeed.update.mock.calls[0][0].data; + expect(data).not.toHaveProperty("bodyHash"); + expect(data).not.toHaveProperty("contentChangedAt"); + expect(await res.text()).toContain("DTSTAMP:20261101T000000Z"); + }); + + it("answers 304 to a matching If-None-Match and still counts the fetch", async () => { + db.cycleCalendarFeed.findFirst.mockResolvedValue({ + ...FEED_ROW, + bodyHash: "new-hash", + }); + const res = await fetchFeed(undefined, { "if-none-match": '"new-hash"' }); + expect(res.status).toBe(304); + expect(res.headers.get("etag")).toBe('"new-hash"'); + expect(await res.text()).toBe(""); + expect(db.cycleCalendarFeed.update).toHaveBeenCalled(); + }); + + it("keeps the User-Agent short", async () => { + await fetchFeed(undefined, { + "user-agent": `Google-Calendar-Importer ${"x".repeat(200)}`, + }); + const data = db.cycleCalendarFeed.update.mock.calls[0][0].data; + expect(data.lastFetcherUa.length).toBe(80); + }); + + it("does not fail the fetch when the statistics write fails", async () => { + db.cycleCalendarFeed.update.mockRejectedValue(new Error("db down")); + expect((await fetchFeed()).status).toBe(200); + }); +}); diff --git a/src/lib/cycle/feed/build.ts b/src/lib/cycle/feed/build.ts new file mode 100644 index 000000000..3505a65fb --- /dev/null +++ b/src/lib/cycle/feed/build.ts @@ -0,0 +1,161 @@ +/** + * Build one calendar feed's body from the record's rows, live (#1350). + * + * Nothing is read from the persisted `CyclePrediction`: it can be a day + * stale, and the feed is computed on every fetch precisely so HealthLog stays + * the only source of truth. The day-log window is the calendar's default + * view, so the feed agrees with what the calendar page shows. + * + * `today`, the time zone and the language are the RECORD's stored settings, + * never the request's. A calendar provider fetches from a data centre, in a + * zone and with an Accept-Language that have nothing to do with the person. + */ +import { prisma } from "@/lib/db"; +import { + defaultCalendarDayLogStart, + loadCycleEngineInputs, + loadFlowDays, +} from "@/lib/cycle/load-inputs"; +import { + gatedPrediction, + toCycleInputs, + toProfileInput, +} from "@/lib/cycle/engine-adapter"; +import { goalAllowsFertileWindow, toCyclePredictionDTO } from "@/lib/cycle/dto"; +import { projectCycles, type ProjectionState } from "@/lib/cycle/projection"; +import type { CycleProfileInput } from "@/lib/cycle/types"; +import { defaultLocale, isLocale, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { moodDateKey } from "@/lib/mood/date-key"; +import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; + +import { icsContentHash, type IcsCalendar } from "../ics"; +import { buildFeedCalendar, type FeedTitleMode } from "./events"; +import { historyPeriods, historyWindowStart } from "./history"; + +/** The feed options the body depends on. */ +export interface FeedOptions { + id: string; + horizonCycles: number; + historyCycles: number; + includeFertile: boolean; + titleMode: FeedTitleMode; +} + +/** The record the feed serves, as far as building it needs. */ +export interface FeedSubject { + id: string; + timezone: string | null; + locale: string | null; +} + +export interface BuiltFeed { + calendar: IcsCalendar; + /** SHA-256 of the body without its stamps. Doubles as the ETag. */ + contentHash: string; + projectionState: ProjectionState; +} + +/** + * The language the feed's descriptive titles use: the record's stored one, or + * the instance default. Deliberately not `resolveServerLocale`, which would + * fall back to the request's cookies and Accept-Language — the provider's, + * not the person's. + */ +export function feedLocale(stored: string | null): Locale { + return isLocale(stored) ? stored : defaultLocale; +} + +/** The engine's defaults, for a record whose profile row was never created. */ +const DEFAULT_PROFILE: CycleProfileInput = { + goal: "GENERAL_HEALTH", + typicalCycleLength: null, + typicalPeriodLength: null, + lutealPhaseLength: null, + predictionEnabled: true, + rawChartMode: false, +}; + +export async function buildCycleFeed( + feed: FeedOptions, + subject: FeedSubject, + now: Date = new Date(), +): Promise { + const tz = subject.timezone ?? DEFAULT_TIMEZONE; + const today = moodDateKey(now, tz); + + const [profileRow, inputs] = await Promise.all([ + prisma.cycleProfile.findUnique({ where: { userId: subject.id } }), + loadCycleEngineInputs({ + userId: subject.id, + today, + tz, + dayLogsFrom: defaultCalendarDayLogStart(today), + }), + ]); + const profile = profileRow ? toProfileInput(profileRow) : DEFAULT_PROFILE; + + const prediction = gatedPrediction(profile, inputs, today); + const projection = projectCycles({ + prediction, + cycles: toCycleInputs(inputs.cycles), + dayLogs: inputs.dayLogs, + goal: profile.goal, + today, + horizon: feed.horizonCycles, + }); + + // The fertile window: the next cycle's, for a conception goal that asked + // for it, and read off the gated DTO rather than the raw result so the + // cold-start suppression applies here exactly as it does on every other + // surface. Never for AVOID_PREGNANCY: a calendar event cannot carry the + // "not a contraceptive method" caveat, and a fertile window without it is + // the one thing this feature must not hand out. + let fertile: { start: string; end: string } | null = null; + if ( + feed.includeFertile && + profile.goal === "TRYING_TO_CONCEIVE" && + prediction !== null && + projection.cycles.length > 0 + ) { + const dto = toCyclePredictionDTO( + prediction, + goalAllowsFertileWindow(profile.goal), + "", + ); + if (dto.fertileWindowStart && dto.fertileWindowEnd) { + fertile = { start: dto.fertileWindowStart, end: dto.fertileWindowEnd }; + } + } + + let history: ReturnType = []; + const historyFrom = historyWindowStart( + inputs.cycles, + today, + feed.historyCycles, + ); + if (historyFrom !== null) { + const flowDays = await loadFlowDays(subject.id, historyFrom); + history = historyPeriods( + inputs.cycles, + flowDays, + today, + feed.historyCycles, + ); + } + + const calendar = buildFeedCalendar({ + feedId: feed.id, + titleMode: feed.titleMode, + projected: projection.cycles, + history, + fertile, + translator: getServerTranslator(feedLocale(subject.locale)), + }); + + return { + calendar, + contentHash: icsContentHash(calendar), + projectionState: projection.state, + }; +} diff --git a/src/lib/cycle/feed/events.ts b/src/lib/cycle/feed/events.ts new file mode 100644 index 000000000..44f399a09 --- /dev/null +++ b/src/lib/cycle/feed/events.ts @@ -0,0 +1,129 @@ +/** + * The calendar feed's events, from the projection, the record's history and + * (for a conception goal that asked for it) the next fertile window (#1350). + * + * Pure. What an event may carry is decided here and nowhere else, and it is + * dates and a title: never a symptom, a note, a flow level, a temperature, a + * test result, the intent fields or anything else the day log holds. The + * content-leak guard (`cycle-feed-content-guard.test.ts`) seeds every one of + * those and asserts the rendered body contains none of them. + * + * DISCREET (the default) titles every event with a dot and names the calendar + * "HealthLog", so a glance at a shared screen or a lock-screen notification + * says nothing. DESCRIPTIVE names the period, in the record's own language. + */ +import { addDays, dayDiff } from "@/lib/cycle/day-math"; +import type { ProjectedCycle } from "@/lib/cycle/projection"; +import { pluralKey } from "@/lib/i18n/plural"; +import { resolveIntlLocale } from "@/lib/format-locale"; +import type { ServerTranslator } from "@/lib/i18n/server-translator"; + +import { eventUid, type IcsCalendar, type IcsEvent } from "../ics"; + +export type FeedTitleMode = "DISCREET" | "DESCRIPTIVE"; + +/** The discreet title: one dot, no word. */ +export const DISCREET_SUMMARY = "●"; +/** The discreet calendar name. */ +export const DISCREET_CALENDAR_NAME = "HealthLog"; + +/** One logged period, as far as the feed draws it. */ +export interface HistoryPeriod { + cycleId: string; + /** First bleeding day. */ + start: string; + /** Last bleeding day, inclusive. */ + end: string; +} + +export interface FeedEventInput { + feedId: string; + titleMode: FeedTitleMode; + projected: readonly ProjectedCycle[]; + history: readonly HistoryPeriod[]; + /** The next fertile window, already gated; null when not shown. */ + fertile: { start: string; end: string } | null; + translator: ServerTranslator; +} + +/** "12 Nov" in the record's language, for the descriptive description. */ +function shortDate(day: string, translator: ServerTranslator): string { + return new Intl.DateTimeFormat(resolveIntlLocale(translator.locale), { + day: "numeric", + month: "short", + timeZone: "UTC", + }).format(new Date(`${day}T12:00:00Z`)); +} + +/** "±3 days", pluralised in the record's language. */ +function bandText(days: number, translator: ServerTranslator): string { + return translator.t( + pluralKey("cycle.feed.ics.band", days, translator.locale), + { count: days }, + ); +} + +function forecastEvent(p: ProjectedCycle, input: FeedEventInput): IcsEvent { + const { translator } = input; + const band = dayDiff(p.bandEnd, p.start); + const descriptive = input.titleMode === "DESCRIPTIVE"; + return { + uid: eventUid(input.feedId, `pred:${p.k}`), + // The whole window the period may fall in: earliest likely start through + // the last bleeding day of the latest likely start. DTEND is exclusive. + start: p.bandStart, + endExclusive: addDays(p.bandEnd, p.periodLength), + summary: descriptive + ? translator.t("cycle.feed.ics.periodEstimate") + : DISCREET_SUMMARY, + description: descriptive + ? translator.t("cycle.feed.ics.likelyStart", { + date: shortDate(p.start, translator), + band: bandText(band, translator), + }) + : bandText(band, translator), + }; +} + +function historyEvent(h: HistoryPeriod, input: FeedEventInput): IcsEvent { + return { + uid: eventUid(input.feedId, `obs:${h.cycleId}`), + start: h.start, + endExclusive: addDays(h.end, 1), + summary: + input.titleMode === "DESCRIPTIVE" + ? input.translator.t("cycle.feed.ics.periodLogged") + : DISCREET_SUMMARY, + }; +} + +function fertileEvent( + window: { start: string; end: string }, + input: FeedEventInput, +): IcsEvent { + return { + uid: eventUid(input.feedId, "fertile:1"), + start: window.start, + endExclusive: addDays(window.end, 1), + summary: + input.titleMode === "DESCRIPTIVE" + ? input.translator.t("cycle.feed.ics.fertileEstimate") + : DISCREET_SUMMARY, + }; +} + +/** The whole calendar the feed serves, events in date order. */ +export function buildFeedCalendar(input: FeedEventInput): IcsCalendar { + const events: IcsEvent[] = [ + ...input.history.map((h) => historyEvent(h, input)), + ...input.projected.map((p) => forecastEvent(p, input)), + ...(input.fertile ? [fertileEvent(input.fertile, input)] : []), + ].sort((a, b) => dayDiff(a.start, b.start) || a.uid.localeCompare(b.uid)); + return { + name: + input.titleMode === "DESCRIPTIVE" + ? input.translator.t("cycle.feed.ics.calendarName") + : DISCREET_CALENDAR_NAME, + events, + }; +} diff --git a/src/lib/cycle/feed/fetcher.ts b/src/lib/cycle/feed/fetcher.ts new file mode 100644 index 000000000..a032e6a99 --- /dev/null +++ b/src/lib/cycle/feed/fetcher.ts @@ -0,0 +1,23 @@ +/** + * Which calendar app last fetched a feed, from its User-Agent (#1350). + * + * Only a family is ever published: the settings card says "by Google + * Calendar", and the wide event counts conditional fetches per family. The + * stored User-Agent itself stays on the row, truncated, and never leaves the + * server. + */ +export type FetcherFamily = "GOOGLE" | "APPLE" | "MICROSOFT" | "OTHER"; + +const FAMILIES: ReadonlyArray<[FetcherFamily, RegExp]> = [ + ["GOOGLE", /google/i], + // iOS and macOS fetch through `dataaccessd` / `CalendarAgent`; older macOS + // still says `iCal`. + ["APPLE", /dataaccessd|calendaragent|ical\/|\bios\/|macos\/|mac os x/i], + ["MICROSOFT", /microsoft|outlook|exchange/i], +]; + +export function fetcherFamily(ua: string | null): FetcherFamily | null { + if (!ua) return null; + for (const [family, re] of FAMILIES) if (re.test(ua)) return family; + return "OTHER"; +} diff --git a/src/lib/cycle/feed/history.ts b/src/lib/cycle/feed/history.ts new file mode 100644 index 000000000..740f2d251 --- /dev/null +++ b/src/lib/cycle/feed/history.ts @@ -0,0 +1,73 @@ +/** + * The logged periods the calendar feed may include as history (#1350). + * + * Pure. A period runs from the cycle's start to its `periodEndDate` when one + * is recorded, otherwise across its observed bleeding days (the engine's own + * §2 run rule), otherwise it is the start day alone. It never runs past today + * or into the next cycle. + */ +import { addDays, dayDiff } from "@/lib/cycle/day-math"; +import { observedPeriodLength } from "@/lib/cycle/prediction"; +import type { DayLogInput, FlowLevel } from "@/lib/cycle/types"; + +import type { HistoryPeriod } from "./events"; + +export interface HistoryCycleRow { + id: string; + startDate: string; + periodEndDate: string | null; +} + +/** The earliest start of the last `count` cycles at or before today. */ +export function historyWindowStart( + cycles: readonly HistoryCycleRow[], + today: string, + count: number, +): string | null { + return recent(cycles, today, count)[0]?.startDate ?? null; +} + +function recent( + cycles: readonly HistoryCycleRow[], + today: string, + count: number, +): HistoryCycleRow[] { + if (count <= 0) return []; + return [...cycles] + .filter((c) => dayDiff(today, c.startDate) >= 0) + .sort((a, b) => dayDiff(a.startDate, b.startDate)) + .slice(-count); +} + +export function historyPeriods( + cycles: readonly HistoryCycleRow[], + flowDays: ReadonlyArray<{ date: string; flow: FlowLevel | null }>, + today: string, + count: number, +): HistoryPeriod[] { + const chosen = recent(cycles, today, count); + const sortedStarts = [...cycles] + .map((c) => c.startDate) + .sort((a, b) => dayDiff(a, b)); + const logs: DayLogInput[] = flowDays.map((d) => ({ + date: d.date, + flow: d.flow, + basalBodyTempC: null, + ovulationTest: null, + cervicalMucus: null, + })); + + return chosen.map((c) => { + let end = c.startDate; + if (c.periodEndDate && dayDiff(c.periodEndDate, c.startDate) >= 0) { + end = c.periodEndDate; + } else { + const observed = observedPeriodLength(c.startDate, logs); + if (observed > 1) end = addDays(c.startDate, observed - 1); + } + const nextStart = sortedStarts.find((s) => dayDiff(s, c.startDate) > 0); + if (nextStart && dayDiff(end, nextStart) >= 0) end = addDays(nextStart, -1); + if (dayDiff(end, today) > 0) end = today; + return { cycleId: c.id, start: c.startDate, end }; + }); +} diff --git a/src/lib/cycle/feed/manage.ts b/src/lib/cycle/feed/manage.ts new file mode 100644 index 000000000..76aafab67 --- /dev/null +++ b/src/lib/cycle/feed/manage.ts @@ -0,0 +1,47 @@ +/** + * Pieces the calendar-feed management routes share (#1350). + * + * Management is cookie-only. Minting hands out a standing URL credential, so + * it needs the person at the keyboard (a fresh proof on the session) and is + * not reachable from a Bearer token at all; the native app shows "set up on + * the web" instead. The routes resolve through `requireRecordAuth("manage", + * "cycle")`, so the owner and a MANAGE delegate over the cycle section reach + * them and a READ or WRITE delegate does not. + */ +import { apiError } from "@/lib/api-response"; + +import { feedPath } from "./token"; + +/** Mint and rotate, per acting account. */ +export const FEED_MINT_LIMIT_PER_HOUR = 10; +export const FEED_MINT_WINDOW_MS = 60 * 60 * 1000; + +/** The refusal a Bearer caller gets: management is a web surface. */ +export function cookieOnlyRefusal(): Response { + return apiError("Calendar subscriptions are managed on the web", 403, { + errorCode: "cycle.feed.cookie_only", + }); +} + +export function noLiveFeed(): Response { + return apiError("No calendar subscription is on", 404, { + errorCode: "cycle.feed.none", + }); +} + +export function feedExists(): Response { + return apiError( + "A calendar subscription is already on. Rotate it for a new link.", + 409, + { errorCode: "cycle.feed.exists" }, + ); +} + +/** The two forms of the link, built once, at mint. */ +export function feedLinks( + origin: string, + raw: string, +): { url: string; webcalUrl: string } { + const url = `${origin}${feedPath(raw)}`; + return { url, webcalUrl: url.replace(/^https?:/, "webcal:") }; +} diff --git a/src/lib/cycle/feed/serve.ts b/src/lib/cycle/feed/serve.ts new file mode 100644 index 000000000..3e9bed107 --- /dev/null +++ b/src/lib/cycle/feed/serve.ts @@ -0,0 +1,261 @@ +/** + * `GET /api/cycle/feed/` — the anonymous calendar-feed fetch + * (#1350). + * + * The token in the path is the whole credential. A calendar provider polls it + * from its own servers with no cookie and no header we could ask for, which is + * why it is a URL credential at all. Everything it may do is decided here, on + * every fetch, in this order: + * + * 1. The anonymous auth-surface limit, before anything is looked up, so a + * prober walking token space pays for every guess (the webhook rule: + * limit before verification). + * 2. The token's shape. Anything that is not `hlf_` plus 48 hex is not + * looked up at all. + * 3. The HMAC lookup of a live (unrevoked) feed. + * 4. The token's own bucket, so one leaked link cannot grind the engine. + * 5. The gates, all read live and none of them through a session: the + * record and the carrier are not pending deletion, the operator's + * external-access switch is on, cycle tracking is available for the + * record (user toggle AND operator module switch), and a feed minted by + * a delegate still has a live MANAGE grant that covers the cycle section. + * + * Every miss is the same 404 with the same bytes: unknown, malformed, + * revoked, switched off, deleted, or a grant that has lapsed. A prober learns + * nothing from the difference because there is none. + */ +import { NextRequest } from "next/server"; + +import { apiError } from "@/lib/api-response"; +import { hashToken } from "@/lib/auth/hmac"; +import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { activeAccount } from "@/lib/account-deletion/active-account"; +import { isCycleAvailableForUser } from "@/lib/cycle/gate"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { runDetached } from "@/lib/logging/detached"; +import { trackBackgroundTask } from "@/lib/logging/background-tasks"; +import { + checkAuthSurfaceRateLimit, + checkRateLimit, + rateLimitHeaders, +} from "@/lib/rate-limit"; +import { + findActiveGrant, + grantAllows, + grantCoversDomain, +} from "@/lib/sharing/grant-access"; + +import { renderIcs } from "../ics"; +import { buildCycleFeed, type FeedSubject } from "./build"; +import { fetcherFamily } from "./fetcher"; +import { parseFeedTokenSegment } from "./token"; + +/** + * Per source address, across every feed. Generous because the big calendar + * providers poll from a pool of addresses that churns; the per-token bucket + * below is the one that bounds a single link. + */ +const SURFACE_LIMIT_PER_HOUR = 120; +/** Per link. A provider polling every few minutes still fits. */ +const TOKEN_LIMIT_PER_HOUR = 30; +const HOUR_MS = 60 * 60 * 1000; + +/** The longest User-Agent kept for the status read. */ +const UA_MAX_LENGTH = 80; + +/** Headers every answer carries, the 404 included. */ +function baseHeaders(): Record { + return { + "X-Robots-Tag": "noindex, nofollow", + "Referrer-Policy": "no-referrer", + }; +} + +/** One blunt 404 for every miss class. */ +function notFound(reason: string): Response { + annotate({ meta: { feed_miss: reason } }); + const response = apiError("Not found", 404, { + errorCode: "cycle.feed.notFound", + }); + for (const [k, v] of Object.entries(baseHeaders())) + response.headers.set(k, v); + response.headers.set("Cache-Control", "no-store"); + return response; +} + +function tooMany(snapshot: Parameters[0]): Response { + const response = apiError("Too many requests. Try again later.", 429, { + errorCode: "cycle.feed.rateLimited", + }); + for (const [k, v] of Object.entries({ + ...baseHeaders(), + ...rateLimitHeaders(snapshot), + })) { + response.headers.set(k, v); + } + return response; +} + +const FEED_SELECT = { + id: true, + userId: true, + createdById: true, + bodyHash: true, + contentChangedAt: true, + horizonCycles: true, + historyCycles: true, + includeFertile: true, + titleMode: true, +} as const; + +/** + * Whether a feed may still be served. Every condition is read on this fetch; + * nothing is cached on the row, so a revocation of anything the feed rests on + * lands on the provider's next poll. + */ +async function feedStillAllowed(feed: { + userId: string; + createdById: string; +}): Promise< + { ok: true; subject: FeedSubject } | { ok: false; reason: string } +> { + const [subject, apiOn, cycleOn] = await Promise.all([ + prisma.user.findFirst({ + where: activeAccount({ id: feed.userId }), + select: { id: true, timezone: true, locale: true }, + }), + isApiGloballyEnabled(), + isCycleAvailableForUser(feed.userId), + ]); + if (!subject) return { ok: false, reason: "subject" }; + if (!apiOn) return { ok: false, reason: "api_disabled" }; + if (!cycleOn) return { ok: false, reason: "cycle_disabled" }; + + if (feed.createdById !== feed.userId) { + const [carrier, grant] = await Promise.all([ + prisma.user.findFirst({ + where: activeAccount({ id: feed.createdById }), + select: { id: true }, + }), + findActiveGrant({ grantorId: feed.userId, granteeId: feed.createdById }), + ]); + if (!carrier) return { ok: false, reason: "carrier" }; + if (!grant || !grantAllows(grant, "manage")) { + return { ok: false, reason: "grant" }; + } + if (!grantCoversDomain(grant, "cycle")) + return { ok: false, reason: "grant" }; + } + return { ok: true, subject }; +} + +/** Does the request's `If-None-Match` name this ETag? */ +function matchesEtag(request: NextRequest, etag: string): boolean { + const header = request.headers.get("if-none-match"); + if (!header) return false; + return header + .split(",") + .map((v) => v.trim().replace(/^W\//, "")) + .some((v) => v === etag || v === "*"); +} + +export async function serveCycleFeed( + request: NextRequest, + segment: string, +): Promise { + annotate({ action: { name: "cycle.feed.fetch" } }); + + // 1. Before any lookup. + const surface = await checkAuthSurfaceRateLimit( + request, + "cycle-feed", + SURFACE_LIMIT_PER_HOUR, + HOUR_MS, + ); + if (!surface.allowed) return tooMany(surface); + + // 2. Shape. + const token = parseFeedTokenSegment(segment); + if (token === null) return notFound("malformed"); + + // 3. Lookup of a live feed. + const tokenHash = hashToken(token); + const feed = await prisma.cycleCalendarFeed.findFirst({ + where: { tokenHash, revokedAt: null }, + select: FEED_SELECT, + }); + if (!feed) return notFound("unknown"); + + // 4. The link's own bucket. + const perToken = await checkRateLimit( + `cycle-feed:${tokenHash}`, + TOKEN_LIMIT_PER_HOUR, + HOUR_MS, + ); + if (!perToken.allowed) return tooMany(perToken); + + // 5. Gates. + const allowed = await feedStillAllowed(feed); + if (!allowed.ok) return notFound(allowed.reason); + + // 6. The body, built live. + const now = new Date(); + const built = await buildCycleFeed(feed, allowed.subject, now); + const changed = built.contentHash !== feed.bodyHash; + const changedAt = changed ? now : feed.contentChangedAt; + const etag = `"${built.contentHash}"`; + const conditional = request.headers.has("if-none-match"); + const notModified = !changed && matchesEtag(request, etag); + + const ua = request.headers.get("user-agent")?.slice(0, UA_MAX_LENGTH) ?? null; + trackBackgroundTask( + runDetached(() => + prisma.cycleCalendarFeed + .update({ + where: { id: feed.id }, + data: { + lastFetchedAt: now, + fetchCount: { increment: 1 }, + // A fetch without a User-Agent keeps the last one we know. + ...(ua ? { lastFetcherUa: ua } : {}), + ...(changed + ? { bodyHash: built.contentHash, contentChangedAt: changedAt } + : {}), + }, + }) + .then(() => undefined) + .catch(() => { + // The statistics are best-effort; a failed write never fails the + // fetch, and the next fetch writes them again. + }), + ), + ); + + annotate({ + meta: { + status: notModified ? 304 : 200, + conditional, + uaFamily: fetcherFamily(ua), + projection_state: built.projectionState, + events: built.calendar.events.length, + }, + }); + + const headers: Record = { + ...baseHeaders(), + // `no-cache`, not `no-store`: a client keeps the body and its validator + // and asks again with `If-None-Match`, which is what the 304 path is for. + "Cache-Control": "private, no-cache, max-age=0", + ETag: etag, + }; + if (notModified) return new Response(null, { status: 304, headers }); + return new Response(renderIcs(built.calendar, changedAt), { + status: 200, + headers: { + ...headers, + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": 'inline; filename="healthlog.ics"', + }, + }); +} diff --git a/src/lib/cycle/feed/status.ts b/src/lib/cycle/feed/status.ts new file mode 100644 index 000000000..b53a6ef98 --- /dev/null +++ b/src/lib/cycle/feed/status.ts @@ -0,0 +1,101 @@ +/** + * What the settings card may know about a record's calendar feed (#1350). + * + * The token's prefix, the options and the fetch statistics. Never the hash + * and never the URL: the URL is returned once, by the mint, and after that it + * exists only where the person pasted it. + */ +import { prisma } from "@/lib/db"; +import type { ProjectionState } from "@/lib/cycle/projection"; + +import { buildCycleFeed, type FeedSubject } from "./build"; +import { fetcherFamily, type FetcherFamily } from "./fetcher"; +import type { FeedTitleMode } from "./events"; + +export const FEED_SUMMARY_SELECT = { + id: true, + tokenPrefix: true, + createdAt: true, + lastFetchedAt: true, + fetchCount: true, + lastFetcherUa: true, + horizonCycles: true, + historyCycles: true, + includeFertile: true, + titleMode: true, +} as const; + +export interface FeedSummaryRow { + id: string; + tokenPrefix: string; + createdAt: Date; + lastFetchedAt: Date | null; + fetchCount: number; + lastFetcherUa: string | null; + horizonCycles: number; + historyCycles: number; + includeFertile: boolean; + titleMode: FeedTitleMode; +} + +export interface CycleFeedSummary { + tokenPrefix: string; + createdAt: string; + lastFetchedAt: string | null; + fetchCount: number; + /** Which calendar app fetched last, as a family. Never the raw UA. */ + lastFetcher: FetcherFamily | null; + horizonCycles: number; + historyCycles: number; + includeFertile: boolean; + titleMode: FeedTitleMode; +} + +export interface CycleFeedStatus { + /** The live feed, or null when the subscription is off. */ + feed: CycleFeedSummary | null; + /** + * What the live feed serves right now (OK / LEARNING / LATE / OFF), so the + * card can say why it holds fewer cycles than asked. Null with no feed. + */ + projectionState: ProjectionState | null; + /** Whether the record's goal admits the fertile-window option at all. */ + fertileAvailable: boolean; +} + +export function toFeedSummary(row: FeedSummaryRow): CycleFeedSummary { + return { + tokenPrefix: row.tokenPrefix, + createdAt: row.createdAt.toISOString(), + lastFetchedAt: row.lastFetchedAt?.toISOString() ?? null, + fetchCount: row.fetchCount, + lastFetcher: fetcherFamily(row.lastFetcherUa), + horizonCycles: row.horizonCycles, + historyCycles: row.historyCycles, + includeFertile: row.includeFertile, + titleMode: row.titleMode, + }; +} + +/** The record's live feed, or null. The partial index admits at most one. */ +export function findLiveFeed(userId: string): Promise { + return prisma.cycleCalendarFeed.findFirst({ + where: { userId, revokedAt: null }, + select: FEED_SUMMARY_SELECT, + }); +} + +export async function cycleFeedStatus( + subject: FeedSubject, + goal: string, +): Promise { + const live = await findLiveFeed(subject.id); + const fertileAvailable = goal === "TRYING_TO_CONCEIVE"; + if (!live) return { feed: null, projectionState: null, fertileAvailable }; + const built = await buildCycleFeed(live, subject); + return { + feed: toFeedSummary(live), + projectionState: built.projectionState, + fertileAvailable, + }; +} diff --git a/src/lib/cycle/feed/token.ts b/src/lib/cycle/feed/token.ts new file mode 100644 index 000000000..fc1e0daf2 --- /dev/null +++ b/src/lib/cycle/feed/token.ts @@ -0,0 +1,50 @@ +/** + * The `hlf_` calendar-feed credential (#1350). + * + * `hlf_` plus 48 hex characters: 192 random bits, the share link's strength. + * Only `hashToken(raw)` is stored, under the same `API_TOKEN_HMAC_KEY` as every + * other HealthLog credential, so rotating that key ends every feed URL along + * with every token (documented in the self-hosting guide). The raw token is + * returned once at mint and cannot be recovered afterwards. + */ +import { randomBytes } from "node:crypto"; + +import { hashToken } from "@/lib/auth/hmac"; + +export const FEED_TOKEN_PREFIX = "hlf_"; + +/** The exact shape a feed token has. Anything else is not looked up. */ +const FEED_TOKEN_SHAPE = /^hlf_[0-9a-f]{48}$/; + +/** How many leading characters the settings card shows. */ +export const FEED_TOKEN_DISPLAY_PREFIX_LENGTH = 8; + +export interface MintedFeedToken { + raw: string; + hash: string; + prefix: string; +} + +export function mintFeedToken(): MintedFeedToken { + const raw = `${FEED_TOKEN_PREFIX}${randomBytes(24).toString("hex")}`; + return { + raw, + hash: hashToken(raw), + prefix: raw.slice(0, FEED_TOKEN_DISPLAY_PREFIX_LENGTH), + }; +} + +/** + * The token from a path segment, or null when it is not one. A trailing + * `.ics` is accepted and stripped, because calendar apps are happier with a + * URL that names its file type. + */ +export function parseFeedTokenSegment(segment: string): string | null { + const token = segment.endsWith(".ics") ? segment.slice(0, -4) : segment; + return FEED_TOKEN_SHAPE.test(token) ? token : null; +} + +/** The feed path for a raw token, relative to the instance origin. */ +export function feedPath(raw: string): string { + return `/api/cycle/feed/${raw}.ics`; +} diff --git a/src/lib/cycle/ics.ts b/src/lib/cycle/ics.ts new file mode 100644 index 000000000..cace2ce10 --- /dev/null +++ b/src/lib/cycle/ics.ts @@ -0,0 +1,166 @@ +/** + * A small, hand-rolled RFC 5545 writer for the cycle calendar feed (#1350). + * + * The feed needs one shape of calendar: all-day events, a published + * subscription, no time zones and no recurrence. That is a few dozen lines of + * the specification, so it is written here rather than taken from a + * dependency whose defaults (descriptions, organisers, alarms, a VTIMEZONE) + * would each be something to switch off. + * + * What the specification asks for and this does: + * * CRLF line endings (§3.1). + * * Lines folded at 75 octets, never inside a UTF-8 sequence, continued with + * a single space (§3.1). + * * TEXT escaping of backslash, semicolon, comma and newline (§3.3.11). + * * All-day events as `VALUE=DATE`, with DTEND exclusive (§3.6.1). + * + * Pure: no clock, no database. The caller hands in the stamp. + */ +import { createHash, createHmac } from "node:crypto"; + +export interface IcsEvent { + /** Globally unique and stable across rebuilds of the same feed. */ + uid: string; + /** First day, `YYYY-MM-DD`. */ + start: string; + /** The day AFTER the last day, `YYYY-MM-DD` (DTEND is exclusive). */ + endExclusive: string; + summary: string; + description?: string; +} + +export interface IcsCalendar { + /** `X-WR-CALNAME`, the name a calendar app shows for the subscription. */ + name: string; + events: readonly IcsEvent[]; +} + +const CRLF = "\r\n"; +const MAX_OCTETS = 75; + +/** The stamp a body is hashed with, so the hash never depends on the clock. */ +const HASH_STAMP = "19700101T000000Z"; + +/** §3.3.11 — escape a TEXT value. */ +export function escapeText(value: string): string { + return value + .replace(/\\/g, "\\\\") + .replace(/;/g, "\\;") + .replace(/,/g, "\\,") + .replace(/\r\n|\r|\n/g, "\\n"); +} + +/** + * §3.1 — fold a content line at 75 octets. Continuation lines begin with one + * space, which counts toward their own 75. Splits only between code points, + * so a multibyte character is never cut in half. + */ +export function foldLine(line: string): string { + const out: string[] = []; + let current = ""; + let octets = 0; + for (const ch of line) { + const size = Buffer.byteLength(ch, "utf8"); + if (octets + size > MAX_OCTETS) { + out.push(current); + current = " "; + octets = 1; + } + current += ch; + octets += size; + } + out.push(current); + return out.join(CRLF); +} + +/** `YYYY-MM-DD` → `YYYYMMDD`. */ +function icsDate(day: string): string { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) { + throw new Error(`Not a calendar day: ${day}`); + } + return day.replace(/-/g, ""); +} + +/** A UTC instant as `YYYYMMDDTHHMMSSZ`. */ +export function icsStamp(at: Date): string { + return at + .toISOString() + .replace(/\.\d{3}Z$/, "Z") + .replace(/[-:]/g, ""); +} + +const BASE32 = "abcdefghijklmnopqrstuvwxyz234567"; + +/** RFC 4648 base32, lower case, unpadded. */ +function base32(bytes: Uint8Array): string { + let out = ""; + let bits = 0; + let value = 0; + for (const byte of bytes) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5) { + out += BASE32[(value >>> (bits - 5)) & 31]; + bits -= 5; + } + } + if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]; + return out; +} + +/** + * A stable event UID: the feed's id keys an HMAC over the slot (`obs:` or `pred:`). Stable across rebuilds of one feed, so a calendar app + * updates an event in place; unrelated across feeds, so a rotated link starts + * a fresh set; and it carries neither a user id nor a host. + */ +export function eventUid(feedId: string, slot: string): string { + const mac = createHmac("sha256", feedId).update(slot).digest(); + return `${base32(mac.subarray(0, 20))}@healthlog.invalid`; +} + +function render(calendar: IcsCalendar, stamp: string): string { + const lines: string[] = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//HealthLog//Calendar feed//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + `X-WR-CALNAME:${escapeText(calendar.name)}`, + "REFRESH-INTERVAL;VALUE=DURATION:PT6H", + "X-PUBLISHED-TTL:PT6H", + ]; + for (const event of calendar.events) { + lines.push( + "BEGIN:VEVENT", + `UID:${event.uid}`, + `DTSTAMP:${stamp}`, + `LAST-MODIFIED:${stamp}`, + `DTSTART;VALUE=DATE:${icsDate(event.start)}`, + `DTEND;VALUE=DATE:${icsDate(event.endExclusive)}`, + `SUMMARY:${escapeText(event.summary)}`, + ); + if (event.description) { + lines.push(`DESCRIPTION:${escapeText(event.description)}`); + } + lines.push("TRANSP:TRANSPARENT", "CLASS:PRIVATE", "END:VEVENT"); + } + lines.push("END:VCALENDAR"); + return lines.map(foldLine).join(CRLF) + CRLF; +} + +/** + * The SHA-256 of the calendar as it would render with a fixed stamp. Equal + * inputs give an equal hash whatever the clock says, which is what lets the + * feed move its stamps only when the content moved. + */ +export function icsContentHash(calendar: IcsCalendar): string { + return createHash("sha256") + .update(render(calendar, HASH_STAMP)) + .digest("hex"); +} + +/** The calendar, every event stamped with `changedAt`. */ +export function renderIcs(calendar: IcsCalendar, changedAt: Date): string { + return render(calendar, icsStamp(changedAt)); +} diff --git a/src/lib/http/origin.ts b/src/lib/http/origin.ts new file mode 100644 index 000000000..d25acecc0 --- /dev/null +++ b/src/lib/http/origin.ts @@ -0,0 +1,22 @@ +/** + * The absolute origin a link the app hands out is built from. + * + * Prefers the forwarded host and protocol a reverse proxy sets (the documented + * self-hosting path fronts the app with Caddy, Traefik or nginx), falling back + * to the request URL's own origin. It trusts `X-Forwarded-Host` exactly as far + * as the share links always have: the value only ever reaches the caller who + * sent it, in the response to their own authenticated request. + * + * Shared by the clinician share links and the cycle calendar feed, so the two + * links a person copies out of the app always name the same host. + */ +export function resolveOrigin(request: Request): string { + const proto = + request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() || + new URL(request.url).protocol.replace(":", ""); + const host = + request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() || + request.headers.get("host") || + new URL(request.url).host; + return `${proto}://${host}`; +} diff --git a/src/lib/http/path-credential-headers.ts b/src/lib/http/path-credential-headers.ts new file mode 100644 index 000000000..8966f364f --- /dev/null +++ b/src/lib/http/path-credential-headers.ts @@ -0,0 +1,40 @@ +/** + * Edge headers for the paths whose credential is the URL itself. + * + * The public clinician share view at `/c/` is a scoped + * health record authenticated by an unguessable bearer token in the path. + * Defend it at the edge regardless of what the RSC emits: + * - `Cache-Control: no-store` so no shared proxy / CDN ever retains a + * scoped record (the page is `force-dynamic`, but that governs Next's + * cache, not a downstream intermediary). + * - `X-Robots-Tag: noindex, nofollow` as the header peer of the page's + * `robots` meta — a crawler that never parses the document still obeys + * the header, and the token must never reach a search index. + * - `Referrer-Policy: no-referrer` so the token-bearing URL is not leaked + * in the `Referer` of any outbound navigation from the page. + * + * The invite landing `/invite/` (iOS #16) carries an equally sensitive + * secret in the path, so it earns the same edge defence even though it is a + * pure server redirect: no shared proxy / CDN may cache it and no crawler may + * index the token-bearing URL. + * + * The cycle calendar feed (`/api/cycle/feed/`) carries its + * credential in the path too. It is kept out of indexes and referrers like the + * share view, but its caching is the route's own: `private, no-cache` plus an + * ETag, so a provider can ask again with `If-None-Match` instead of + * downloading the body on every poll. `no-store` here would throw the + * validator away. + * + * Runs after the proxy's general header block, so these values win. + */ +export function setPathCredentialHeaders( + pathname: string, + headers: Headers, +): void { + const feed = pathname.startsWith("/api/cycle/feed/"); + const page = pathname.startsWith("/c/") || pathname.startsWith("/invite/"); + if (!feed && !page) return; + if (page) headers.set("Cache-Control", "no-store, no-cache, must-revalidate"); + headers.set("X-Robots-Tag", "noindex, nofollow"); + headers.set("Referrer-Policy", "no-referrer"); +} diff --git a/src/lib/logging/__tests__/redact.test.ts b/src/lib/logging/__tests__/redact.test.ts index d4f1fc11a..ef21189bc 100644 --- a/src/lib/logging/__tests__/redact.test.ts +++ b/src/lib/logging/__tests__/redact.test.ts @@ -127,6 +127,7 @@ describe("redactSecrets", () => { it.each([ ["an invite token", `hlv_${"0a".repeat(32)}`], ["a managed-profile handover token", `hlp_${"0a".repeat(32)}`], + ["a cycle calendar-feed token", `hlf_${"0a".repeat(24)}`], ["a session secret", `hls_${"ab".repeat(32)}`], ["an elevation", `hle_${"cd".repeat(32)}`], ["a native sign-in handoff code", `hlh_${"Ab9_-".repeat(9)}Ab`], @@ -332,6 +333,36 @@ describe("notification quick-answer token in the path", () => { }); }); +describe("cycle calendar-feed token in the path (#1350)", () => { + const token = `hlf_${"5b".repeat(24)}`; + + it("scrubs the feed token from the path, with or without .ics", () => { + expect(redactSecrets(`/api/cycle/feed/${token}`)).toBe( + "/api/cycle/feed/[REDACTED]", + ); + expect(redactSecrets(`/api/cycle/feed/${token}.ics`)).toBe( + "/api/cycle/feed/[REDACTED]", + ); + expect( + redactSecrets(`GET https://health.example/api/cycle/feed/${token}.ics`), + ).toBe("GET https://health.example/api/cycle/feed/[REDACTED]"); + expect(PATH_SECRET_PATHS.map((p) => p.prefix)).toContain( + "/api/cycle/feed/", + ); + }); + + it("leaves the management namespace readable", () => { + // A different segment, so the feed prefix (ending in `/`) never reaches + // it: an operator reading the logs still sees which management verb ran. + expect(redactSecrets("/api/cycle/feed-subscription")).toBe( + "/api/cycle/feed-subscription", + ); + expect(redactSecrets("/api/cycle/feed-subscription/rotate")).toBe( + "/api/cycle/feed-subscription/rotate", + ); + }); +}); + /** * Prisma prints a failed call, arguments and all, into its error message and * stack. Those went to the logs and to the operator's GlitchTip with a diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index 4dcef81d2..9502be731 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -72,6 +72,12 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ // single-use action token is the request's only credential and rides the // trailing path segment of `/api/notifications/action/`. { prefix: "/api/notifications/action/" }, + // #1350 — the cycle calendar feed. A calendar provider polls + // `/api/cycle/feed/.ics` with no session; the path segment is + // the whole credential. The management routes live under + // `/api/cycle/feed-subscription`, a different segment, so this prefix + // (which ends in `/`) never matches them. + { prefix: "/api/cycle/feed/" }, ]; function redactPathSegments(input: string): string { @@ -140,12 +146,13 @@ export function redactSecrets(input: string): string { // HealthLog hex-bodied credentials: `hlk_` access token, `hlr_` refresh // token, `hls_` session cookie secret (v1.30.32) and clinician share // token, `hle_` step-up elevation (v1.30.34), `hlv_` registration invite, - // `hlp_` managed-profile handover link (v1.42). + // `hlp_` managed-profile handover link (v1.42), `hlf_` cycle + // calendar-feed link (#1350). // Same boundary-aware shape as the `sk-` rule. The // idempotency replay-cache already rejects bodies containing // these prefixes (CLAUDE.md headless-client-API patterns); this // is the matching egress guard for log/error surfaces. - .replace(/(^|[^A-Za-z0-9])hl[kpresv]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") + .replace(/(^|[^A-Za-z0-9])hl[kpresvf]_[A-Fa-f0-9]{32,}/g, "$1[REDACTED]") // v1.22.0 — the MCP OAuth bridge artifacts: the authorization code // (`hlac_`), refresh token (`hlrt_`), and DCR client id (`hlc_`). All // are signed, base64url-bodied, and credential-bearing, so they are diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 4867385e8..548057cf0 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -144,6 +144,13 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "cycle.day-log.conflict", "cycle.day-log.invalid", "cycle.disabled", + "cycle.feed.api_disabled", + "cycle.feed.cookie_only", + "cycle.feed.exists", + "cycle.feed.fertile_goal", + "cycle.feed.none", + "cycle.feed.notFound", + "cycle.feed.rateLimited", "cycle.period.invalid", "cycle.symptom.custom.invalid", "cycle.symptom.custom.limit", diff --git a/src/lib/openapi/routes/cycle-feed.ts b/src/lib/openapi/routes/cycle-feed.ts new file mode 100644 index 000000000..851ddd022 --- /dev/null +++ b/src/lib/openapi/routes/cycle-feed.ts @@ -0,0 +1,284 @@ +/** + * OpenAPI route table — the subscribable cycle forecast (#1350): the + * anonymous `.ics` feed, its cookie-only management routes, and the + * projection the calendar read publishes beside its forecast. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. The request + * schema comes from `src/lib/validations/cycle-feed.ts`, so the wire contract + * stays single-source. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; + +import { + cycleFeedOptionsPatchSchema, + cycleFeedTitleModeEnum, +} from "@/lib/validations/cycle-feed"; +import { isoDateTime } from "@/lib/validations/datetime"; + +import { + dataEnvelope, + errorEnvelope, + malformedJsonResponse, + recordRefusal, + recordWriteRateLimitResponse, + stdResponses, +} from "./shared"; + +const dayKey = () => z.string().meta({ format: "date", example: "2026-11-12" }); + +/** One projected period, as `/api/cycle/calendar` publishes it. */ +export const projectedCycleDto = z + .object({ + k: z + .number() + .int() + .describe( + "1 = the next period (the engine's own forecast), 2 = the one after it, …", + ), + start: dayKey().describe("The most likely first day."), + periodLength: z.number().int().describe("Predicted bleeding days."), + bandStart: dayKey().describe("The earliest likely first day."), + bandEnd: dayKey().describe("The latest likely first day."), + }) + .meta({ + id: "ProjectedCycleDTO", + description: + "A forecast period start with its band. The band widens from one cycle to the next and the list stops itself where it would pass ±10 days or half a cycle. Render it as given; never extrapolate further cycles on a client.", + }); + +export const projectionStateEnum = z + .enum(["OK", "LEARNING", "LATE", "OFF"]) + .meta({ + id: "CycleProjectionState", + description: + "Why `projection` holds what it holds. `OK`: the projected cycles. `LEARNING`: fewer than three observed cycles, so the next period only (or nothing with no logged cycle). `LATE`: the next period's band has passed with no new period logged; no forecast until one is. `OFF`: prediction is off or raw-chart mode is on.", + }); + +const fetcherFamilyEnum = z.enum(["GOOGLE", "APPLE", "MICROSOFT", "OTHER"]); + +const feedSummary = z + .object({ + tokenPrefix: z + .string() + .describe( + "The first 8 characters of the link's token, to tell links apart.", + ), + createdAt: isoDateTime(), + lastFetchedAt: isoDateTime().nullable(), + fetchCount: z.number().int(), + lastFetcher: fetcherFamilyEnum + .nullable() + .describe( + "Which calendar app fetched last, as a family; never the raw User-Agent.", + ), + horizonCycles: z.number().int().min(1).max(6), + historyCycles: z.number().int().min(0).max(12), + includeFertile: z.boolean(), + titleMode: cycleFeedTitleModeEnum, + }) + .meta({ + id: "CycleFeedSummary", + description: + "The live calendar subscription, without its secret: never the token hash and never the URL.", + }); + +const feedStatus = z.object({ + feed: feedSummary.nullable(), + projectionState: projectionStateEnum.nullable(), + fertileAvailable: z + .boolean() + .describe( + "Whether the record's goal admits the fertile-window option (TRYING_TO_CONCEIVE only).", + ), +}); + +const feedMinted = z.object({ + url: z + .string() + .describe( + "The feed URL. Returned exactly once; it cannot be read back later.", + ), + webcalUrl: z + .string() + .describe( + "The same URL with the `webcal:` scheme, which opens a calendar app's subscribe dialog.", + ), + feed: feedSummary, +}); + +const feedOptionsRequest = cycleFeedOptionsPatchSchema.meta({ + id: "CycleFeedOptionsPatch", + description: + "The writable options. At least one field. `includeFertile: true` is refused (422 `cycle.feed.fertile_goal`) unless the goal is TRYING_TO_CONCEIVE.", +}); + +const MANAGEMENT_REFUSALS = recordRefusal( + "Calendar subscriptions are managed over a browser session only: any Bearer token gets `cycle.feed.cookie_only`. Cycle tracking switched off for the record answers `cycle.disabled`; the operator's external-access switch answers `cycle.feed.api_disabled` on turning it on and on rotating. Reached by the record's owner and by a MANAGE delegate whose grant covers the cycle section, never by a READ or WRITE delegate.", +); + +const REPROOF_401 = { + "401": { + description: + "Not signed in, or the session has no fresh proof: turning the subscription on or rotating it needs a sign-in or `POST /api/auth/reproof` within the last five minutes, answered otherwise with 401 `auth.reproof.required` and `meta.methods`.", + content: { "application/json": { schema: errorEnvelope } }, + }, +}; + +const mintResponses = { + "201": { + description: "The new link, returned exactly once.", + content: { + "application/json": { + schema: dataEnvelope(feedMinted, "CycleFeedMinted"), + }, + }, + }, + ...MANAGEMENT_REFUSALS, + ...stdResponses, + ...REPROOF_401, +}; + +export const cycleFeedPaths: NonNullable = { + "/api/cycle/feed/{token}": { + get: { + tags: ["Cycle"], + summary: "The subscribable cycle forecast as iCalendar (public, #1350)", + description: + "Anonymous: a calendar provider polls this URL, so the raw `hlf_` token in the path (optionally followed by `.ics`) is the ONLY credential. Rate-limited per source address before any lookup and per token after it. Every gate is re-read on each fetch: the record and the minting account are not pending deletion, the operator's external-access switch is on, cycle tracking is available, and a feed minted by a delegate still has a live MANAGE grant over the cycle section. Every miss (malformed, unknown, revoked, switched off, deleted, lapsed grant) is the same 404. The body is RFC 5545: all-day events, dates only, discreet titles by default, the next few periods with widening bands (self-capped), optionally past periods and, for TRYING_TO_CONCEIVE, the next fertile window. `ETag` is the content hash; a matching `If-None-Match` answers 304.", + security: [], + requestParams: { + path: z.object({ + token: z + .string() + .describe("Raw `hlf_` feed token, optionally with `.ics`."), + }), + }, + responses: { + "200": { + description: + "The calendar. `Cache-Control: private, no-cache, max-age=0`, `ETag`.", + content: { + "text/calendar": { schema: z.string() }, + }, + }, + "304": { + description: "The body is unchanged since the `If-None-Match` ETag.", + }, + "404": { + description: + "Flat 404 for every miss class (errorCode `cycle.feed.notFound`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: "The source-address or per-token bucket is exhausted.", + content: { "application/json": { schema: errorEnvelope } }, + headers: stdResponses["429"].headers, + }, + }, + }, + }, + "/api/cycle/feed-subscription": { + get: { + tags: ["Cycle"], + summary: "Calendar subscription status (#1350)", + description: + "The record's live calendar subscription without its secret (prefix, options, fetch statistics) and what the feed serves right now. Never the hash, never the URL.", + responses: { + "200": { + description: + "The status; `feed` is null while the subscription is off.", + content: { + "application/json": { + schema: dataEnvelope(feedStatus, "CycleFeedStatus"), + }, + }, + }, + ...MANAGEMENT_REFUSALS, + ...stdResponses, + }, + }, + post: { + tags: ["Cycle"], + summary: "Turn the calendar subscription on (#1350)", + description: + "Mints an `hlf_` token (192-bit), stores only its HMAC, and returns the URL EXACTLY ONCE. One live subscription per record: a second answers 409 `cycle.feed.exists` (rotate instead). Defaults: three cycles ahead, no history, no fertile window, discreet titles. Rate-limited per acting account (`cycle-feed:mint:`, 10/h, shared with rotate).", + responses: { + ...mintResponses, + "409": { + description: "A subscription is already on (`cycle.feed.exists`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + patch: { + tags: ["Cycle"], + summary: "Change the calendar subscription's options (#1350)", + description: + "Field-by-field update of the options. The URL does not change; the feed's next fetch serves the new content.", + requestBody: { + required: true, + content: { "application/json": { schema: feedOptionsRequest } }, + }, + responses: { + "200": { + description: "The status after the change.", + content: { + "application/json": { + schema: dataEnvelope(feedStatus, "CycleFeedStatusAfterPatch"), + }, + }, + }, + "404": { + description: "No subscription is on (`cycle.feed.none`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...MANAGEMENT_REFUSALS, + ...malformedJsonResponse, + ...stdResponses, + ...recordWriteRateLimitResponse, + }, + }, + delete: { + tags: ["Cycle"], + summary: "Turn the calendar subscription off (#1350)", + description: + "Revokes the link; calendars holding it stop updating on their next poll. Copies a calendar provider already holds are beyond the app's reach.", + responses: { + "200": { + description: "Revoked.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ revoked: z.literal(true) }), + "CycleFeedRevoked", + ), + }, + }, + }, + "404": { + description: "No subscription is on (`cycle.feed.none`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...MANAGEMENT_REFUSALS, + ...stdResponses, + ...recordWriteRateLimitResponse, + }, + }, + }, + "/api/cycle/feed-subscription/rotate": { + post: { + tags: ["Cycle"], + summary: "Replace the calendar subscription's link (#1350)", + description: + "One transaction: the live link is revoked and a new one minted with the same options. Returns the new URL EXACTLY ONCE. Calendars using the old link stop updating.", + responses: { + ...mintResponses, + "404": { + description: "No subscription is on (`cycle.feed.none`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index b16c95ff9..f07ff9811 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -41,6 +41,7 @@ import { listEnvelope, } from "./shared"; import { isoDateTime } from "@/lib/validations/datetime"; +import { projectedCycleDto, projectionStateEnum } from "./cycle-feed"; // ── Cycle tracking (v1.15.0) ───────────────────────────────────────── // The `/api/cycle` capture / calendar / history / settings surface + @@ -316,6 +317,12 @@ const cycleCalendarResponse = z.object({ // population prior) — the client shows a calm "learning your cycle" state. // Additive + back-compatible. stillLearning: z.boolean(), + // #1350 — the next cycles with widening bands, from the same engine run and + // the same projection the calendar feed serves. Additive. Fertile fields are + // never here; they stay in `prediction.*` behind the goal and cold-start + // gates. + projection: z.array(projectedCycleDto), + projectionState: projectionStateEnum, days: z.array(cycleCalendarDayDto), meta: z.object({ generatedAt: isoDateTime() }), }); diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index cea0de0a0..6ab9ca36f 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -53,6 +53,7 @@ import { consentPaths } from "./consent"; import { customMetricPaths } from "./custom-metrics"; import { dailyPaths } from "./daily"; import { cyclePaths } from "./cycle"; +import { cycleFeedPaths } from "./cycle-feed"; import { dashboardWidgetPaths } from "./dashboard"; import { dayPaths } from "./day"; import { searchPaths } from "./search"; @@ -100,6 +101,7 @@ import { coachPrefsSchema, withScopeRefusal } from "./shared"; const registeredPaths: NonNullable = { ...cyclePaths, + ...cycleFeedPaths, ...metaPaths, ...healthRecordPaths, ...exportPaths, diff --git a/src/lib/pwa/__tests__/sw-cache-policy.test.ts b/src/lib/pwa/__tests__/sw-cache-policy.test.ts index bbd49082e..f263fd7e8 100644 --- a/src/lib/pwa/__tests__/sw-cache-policy.test.ts +++ b/src/lib/pwa/__tests__/sw-cache-policy.test.ts @@ -57,6 +57,15 @@ describe("isAllowlistedApiRead — offline read boundary", () => { expect(isAllowlistedApiRead("/api/withings/callback")).toBe(false); }); + it("never caches the cycle calendar feed or its management routes (#1350)", () => { + // The feed URL is a credential and its body is a record's cycle dates; + // neither belongs in a browser's durable storage. + const feed = `/api/cycle/feed/hlf_${"1f".repeat(24)}.ics`; + expect(isAllowlistedApiRead(feed)).toBe(false); + expect(isAllowlistedApiRead("/api/cycle/feed-subscription")).toBe(false); + expect(SW_SOURCE).not.toContain("/api/cycle"); + }); + it("does not match a prefix that is only a partial path segment", () => { // `/api/measurements-export` must NOT match `/api/measurements`. expect(isAllowlistedApiRead("/api/measurements-export")).toBe(false); diff --git a/src/lib/query-keys/cycle.ts b/src/lib/query-keys/cycle.ts index 09e394aa7..589bcef9a 100644 --- a/src/lib/query-keys/cycle.ts +++ b/src/lib/query-keys/cycle.ts @@ -34,4 +34,8 @@ export const cycleKeys = { /** The caller's own custom symptoms (decrypted labels) the log-day sheet * merges into the seeded chip grid. */ cycleCustomSymptoms: () => ["cycle", "custom-symptoms"] as const, + /** #1350 — the calendar subscription's status. Under the `["cycle"]` prefix + * on purpose: a logged period changes what the feed serves (a late period + * becomes a forecast again), so every cycle write refreshes the card too. */ + cycleFeedSubscription: () => ["cycle", "feed-subscription"] as const, }; diff --git a/src/lib/record-activity/activity-verb.ts b/src/lib/record-activity/activity-verb.ts index 801cafb19..1c31d2363 100644 --- a/src/lib/record-activity/activity-verb.ts +++ b/src/lib/record-activity/activity-verb.ts @@ -65,6 +65,8 @@ export function recordActivityVerbLine( if (refused !== null) return refused; const medication = medicationManageVerb(t, action, name); if (medication !== null) return medication; + const cycle = cycleManageVerb(t, action, name); + if (cycle !== null) return cycle; switch (action) { case "measurement.create": return t("recordSharing.activityVerb.measurementCreate", { name }); @@ -270,19 +272,6 @@ export function recordActivityVerbLine( case "mental-health.create": return t("recordSharing.activityVerb.mentalHealthCreate", { name }); - case "cycle.cycle.delete": - return t("recordSharing.activityVerb.cycleDelete", { name }); - case "cycle.day-log.upsert": - return t("recordSharing.activityVerb.cycleDayLogUpsert", { name }); - case "cycle.day-log.update": - return t("recordSharing.activityVerb.cycleDayLogUpdate", { name }); - case "cycle.day-log.delete": - return t("recordSharing.activityVerb.cycleDayLogDelete", { name }); - case "cycle.period.boundary": - return t("recordSharing.activityVerb.cyclePeriodBoundary", { name }); - case "cycle.symptom.custom.create": - return t("recordSharing.activityVerb.cycleSymptomCreate", { name }); - case "nutrient.water.write": return t("recordSharing.activityVerb.nutrientWaterWrite", { name }); @@ -496,3 +485,39 @@ function medicationManageVerb( return null; } } + +/** + * The cycle section's delegated acts: its log, its period boundaries, its + * symptom vocabulary and its calendar subscription (#1350). Null for anything + * else, so the caller falls through to the rest of the list. + */ +function cycleManageVerb( + t: Translator, + action: string, + name: string, +): string | null { + switch (action) { + case "cycle.cycle.delete": + return t("recordSharing.activityVerb.cycleDelete", { name }); + case "cycle.day-log.upsert": + return t("recordSharing.activityVerb.cycleDayLogUpsert", { name }); + case "cycle.day-log.update": + return t("recordSharing.activityVerb.cycleDayLogUpdate", { name }); + case "cycle.day-log.delete": + return t("recordSharing.activityVerb.cycleDayLogDelete", { name }); + case "cycle.period.boundary": + return t("recordSharing.activityVerb.cyclePeriodBoundary", { name }); + case "cycle.symptom.custom.create": + return t("recordSharing.activityVerb.cycleSymptomCreate", { name }); + case "cycle.feed.create": + return t("recordSharing.activityVerb.cycleFeedCreate", { name }); + case "cycle.feed.update": + return t("recordSharing.activityVerb.cycleFeedUpdate", { name }); + case "cycle.feed.rotate": + return t("recordSharing.activityVerb.cycleFeedRotate", { name }); + case "cycle.feed.revoke": + return t("recordSharing.activityVerb.cycleFeedRevoke", { name }); + default: + return null; + } +} diff --git a/src/lib/secret-shape.ts b/src/lib/secret-shape.ts index 845d1aa94..a925ac961 100644 --- a/src/lib/secret-shape.ts +++ b/src/lib/secret-shape.ts @@ -5,7 +5,8 @@ * * `hlk_` access tokens, `hlr_` refresh tokens, `hls_` clinician share-link * tokens and session secrets, `hlv_` registration invite tokens, `hlp_` - * managed-profile handover tokens (v1.42), `hle_` + * managed-profile handover tokens (v1.42), `hlf_` cycle calendar-feed + * tokens (#1350), `hle_` * elevation tokens, `hlh_` native sign-in handoff codes, and the MCP OAuth * artifacts `hlac_` (authorization code), `hlrt_` (refresh token) and `hlc_` * (client id); @@ -18,7 +19,7 @@ * that knows the secrets in play must also refuse text containing them. */ const SECRET_SHAPED = - /(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; + /(?:\b(?:hlk_|hlr_|hls_|hlv_|hlp_|hlf_|hle_|hlh_|hlac_|hlrt_|hlc_)[A-Za-z0-9_-]+|\bsk-(?:ant-)?[A-Za-z0-9_-]{8,})/; export function looksSecretShaped(text: string): boolean { return SECRET_SHAPED.test(text); diff --git a/src/lib/validations/cycle-feed.ts b/src/lib/validations/cycle-feed.ts new file mode 100644 index 000000000..646faf3ea --- /dev/null +++ b/src/lib/validations/cycle-feed.ts @@ -0,0 +1,42 @@ +/** + * The cycle calendar feed's request contract (#1350). + * + * Only the options are writable. The token, the record and the carrier are + * never body fields: the record comes from the resolved session, the carrier + * is the actor, and the token is minted by the server. + */ +import { z } from "zod/v4"; + +/** The feed's title modes. Mirrors `CycleCalendarFeedTitleMode`. */ +export const cycleFeedTitleModeEnum = z.enum(["DISCREET", "DESCRIPTIVE"]); + +/** Forecast cycles ahead. The feed caps itself further by band width. */ +export const FEED_HORIZON_MIN = 1; +export const FEED_HORIZON_MAX = 6; +/** Observed cycles included as history. */ +export const FEED_HISTORY_MIN = 0; +export const FEED_HISTORY_MAX = 12; + +export const cycleFeedOptionsPatchSchema = z + .object({ + horizonCycles: z + .number() + .int() + .min(FEED_HORIZON_MIN) + .max(FEED_HORIZON_MAX) + .optional(), + historyCycles: z + .number() + .int() + .min(FEED_HISTORY_MIN) + .max(FEED_HISTORY_MAX) + .optional(), + includeFertile: z.boolean().optional(), + titleMode: cycleFeedTitleModeEnum.optional(), + }) + .strict() + .refine((v) => Object.keys(v).length > 0, { + message: "At least one option is required", + }); + +export type CycleFeedOptionsPatch = z.infer; diff --git a/src/proxy.ts b/src/proxy.ts index abd1abed5..616602fa0 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -7,6 +7,7 @@ import { RETIRED_ROUTE_STATUS, } from "@/lib/http/retired-routes"; import { setBaselineSecurityHeaders } from "@/lib/http/proxy-bypass-routes"; +import { setPathCredentialHeaders } from "@/lib/http/path-credential-headers"; import { DEMO_REFUSAL_BODY } from "@/lib/demo-refusal"; import { resolveRequestId } from "@/lib/http/request-id"; import { redactSecrets } from "@/lib/logging/redact"; @@ -49,6 +50,8 @@ export const PUBLIC_PATHS = [ // token in the path is the whole credential, verified by the route after // its rate limit. Trailing slash: admits only `/action/`. "/api/notifications/action/", + // #1350 — the calendar feed; the `hlf_` path token is the credential. + "/api/cycle/feed/", "/api/monitoring/", "/api/withings/webhook", // v1.11.0 — WHOOP webhook (`recovery.updated` / `sleep.updated` / @@ -691,30 +694,8 @@ export function proxy(request: NextRequest) { "camera=(), microphone=(), geolocation=()", ); - // v1.11.0 — the public clinician share view at `/c/` - // is a scoped health record authenticated by an unguessable bearer token - // in the path. Defend it at the edge regardless of what the RSC emits: - // - `Cache-Control: no-store` so no shared proxy / CDN ever retains a - // scoped record (the page is `force-dynamic`, but that governs Next's - // cache, not a downstream intermediary). - // - `X-Robots-Tag: noindex, nofollow` as the header peer of the page's - // `robots` meta — a crawler that never parses the document still obeys - // the header, and the token must never reach a search index. - // - `Referrer-Policy: no-referrer` so the token-bearing URL is not - // leaked in the `Referer` of any outbound navigation from the page. - // - // v1.17.0 — `/invite/` (iOS #16) carries an equally sensitive - // secret in the path, so it earns the same edge defence even though it is - // a pure server redirect: no shared proxy / CDN may cache it and no - // crawler may index the token-bearing URL. - if (pathname.startsWith("/c/") || pathname.startsWith("/invite/")) { - response.headers.set( - "Cache-Control", - "no-store, no-cache, must-revalidate", - ); - response.headers.set("X-Robots-Tag", "noindex, nofollow"); - response.headers.set("Referrer-Policy", "no-referrer"); - } + // Paths whose credential is the URL itself (share view, invite, feed). + setPathCredentialHeaders(pathname, response.headers); // COOP isolates this BrowsingContextGroup from cross-origin popups, // closing the Spectre-class side-channel surface a stray // `window.opener` reference would otherwise carry. CORP narrows the diff --git a/tests/fixtures/v137/sharing-matrix.ts b/tests/fixtures/v137/sharing-matrix.ts index 00df119aa..1ca69ae54 100644 --- a/tests/fixtures/v137/sharing-matrix.ts +++ b/tests/fixtures/v137/sharing-matrix.ts @@ -754,6 +754,38 @@ export const ADMITTED_MUTATING_HANDLERS = [ "manage", "create", ), + handler( + "app/api/cycle/feed-subscription/route.ts", + "/api/cycle/feed-subscription", + "POST", + "cycle", + "manage", + "create", + ), + handler( + "app/api/cycle/feed-subscription/route.ts", + "/api/cycle/feed-subscription", + "PATCH", + "cycle", + "manage", + "update", + ), + handler( + "app/api/cycle/feed-subscription/route.ts", + "/api/cycle/feed-subscription", + "DELETE", + "cycle", + "manage", + "delete", + ), + handler( + "app/api/cycle/feed-subscription/rotate/route.ts", + "/api/cycle/feed-subscription/rotate", + "POST", + "cycle", + "manage", + "create", + ), handler( "app/api/medications/categories/route.ts", "/api/medications/categories", diff --git a/tests/integration/cycle-calendar-feed.test.ts b/tests/integration/cycle-calendar-feed.test.ts new file mode 100644 index 000000000..1f990eb71 --- /dev/null +++ b/tests/integration/cycle-calendar-feed.test.ts @@ -0,0 +1,515 @@ +/** + * #1350 — the subscribable cycle forecast, against real Postgres. + * + * The management routes and the anonymous feed driven through their shipped + * exports: the URL is returned once and never again, every miss class is the + * same 404, a Bearer token and a stale session cannot mint, a READ delegate + * cannot, the partial index holds one live feed per record, and the lifecycle + * hooks (handover claim, carrier deletion, credential remediation) end the + * link. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +const db = () => getPrismaClient(); +let counter = 0; + +async function makeUser(label: string, extra: Record = {}) { + const suffix = `${label}-${counter++}`; + return db().user.create({ + data: { + username: `cf-${suffix}`, + email: `cf-${suffix}@example.test`, + displayName: `CF ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + gender: "FEMALE", + ...extra, + }, + }); +} + +async function signIn(userId: string, createdAt?: Date) { + cookieJar.clear(); + headerJar.clear(); + const session = await db().session.create({ + data: { + userId, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + ...(createdAt ? { createdAt } : {}), + }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Thirteen regular cycles, the last one starting three weeks ago. */ +async function seedCycles(userId: string) { + const today = new Date(); + const lastStart = new Date(today.getTime() - 21 * 86_400_000); + for (let i = 12; i >= 0; i--) { + const start = new Date(lastStart.getTime() - i * 28 * 86_400_000); + const key = start.toISOString().slice(0, 10); + await db().menstrualCycle.create({ + data: { + userId, + startDate: key, + periodEndDate: new Date(start.getTime() + 4 * 86_400_000) + .toISOString() + .slice(0, 10), + }, + }); + } +} + +type Handler = ( + request: NextRequest, + ctx: { params: Promise> }, +) => Promise; + +function req(method: string, path: string, body?: unknown, headers = {}) { + return new NextRequest(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json", ...headers }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} + +async function manage( + method: "GET" | "POST" | "PATCH" | "DELETE", + body?: unknown, + headers = {}, +) { + const route = await import("@/app/api/cycle/feed-subscription/route"); + const handler = route[method] as unknown as Handler; + return handler(req(method, "/api/cycle/feed-subscription", body, headers), { + params: Promise.resolve({}), + }); +} + +async function rotate() { + const { POST } = + await import("@/app/api/cycle/feed-subscription/rotate/route"); + return (POST as unknown as Handler)( + req("POST", "/api/cycle/feed-subscription/rotate"), + { params: Promise.resolve({}) }, + ); +} + +async function fetchFeed( + segment: string, + headers: Record = {}, +) { + const saved = new Map(cookieJar); + cookieJar.clear(); + headerJar.clear(); + const { GET } = await import("@/app/api/cycle/feed/[token]/route"); + const res = await (GET as unknown as Handler)( + new NextRequest(`http://localhost/api/cycle/feed/${segment}`, { headers }), + { params: Promise.resolve({ token: segment }) }, + ); + for (const [k, v] of saved) cookieJar.set(k, v); + return res; +} + +async function mint(userId: string) { + await signIn(userId); + const res = await manage("POST"); + expect(res.status).toBe(201); + const { data } = await res.json(); + return data as { + url: string; + webcalUrl: string; + feed: { tokenPrefix: string }; + }; +} + +function segmentOf(url: string): string { + return url.split("/api/cycle/feed/")[1]; +} + +async function expect404(res: Response) { + expect(res.status).toBe(404); + expect((await res.json()).meta?.errorCode).toBe("cycle.feed.notFound"); +} + +beforeEach(async () => { + await truncateAllTables(db()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("turning the subscription on", () => { + it("returns the URL once; the status never carries the hash or the URL", async () => { + const owner = await makeUser("owner"); + await seedCycles(owner.id); + const minted = await mint(owner.id); + + expect(minted.url).toMatch( + /^http:\/\/localhost\/api\/cycle\/feed\/hlf_[0-9a-f]{48}\.ics$/, + ); + expect(minted.webcalUrl).toBe(minted.url.replace(/^http:/, "webcal:")); + + const row = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId: owner.id }, + }); + const raw = segmentOf(minted.url).replace(/\.ics$/, ""); + expect(row.tokenHash).not.toContain(raw); + expect(row.tokenPrefix).toBe(raw.slice(0, 8)); + expect(row.createdById).toBe(owner.id); + + const status = await (await manage("GET")).text(); + expect(status).not.toContain(raw); + expect(status).not.toContain(row.tokenHash); + expect(status).not.toContain("/api/cycle/feed/"); + expect(JSON.parse(status).data.feed.tokenPrefix).toBe(row.tokenPrefix); + + const audit = await db().auditLog.findFirst({ + where: { action: "cycle.feed.create", userId: owner.id }, + }); + expect(audit).not.toBeNull(); + }); + + it("refuses a second live feed; rotate leaves exactly one", async () => { + const owner = await makeUser("owner"); + const first = await mint(owner.id); + expect((await manage("POST")).status).toBe(409); + + const res = await rotate(); + expect(res.status).toBe(201); + const second = (await res.json()).data as { url: string }; + expect(second.url).not.toBe(first.url); + + const live = await db().cycleCalendarFeed.findMany({ + where: { userId: owner.id, revokedAt: null }, + }); + expect(live).toHaveLength(1); + await expect404(await fetchFeed(segmentOf(first.url))); + expect((await fetchFeed(segmentOf(second.url))).status).toBe(200); + expect( + await db().auditLog.count({ where: { action: "cycle.feed.rotate" } }), + ).toBe(1); + }); + + it("the partial unique index rejects a second live row written directly", async () => { + const owner = await makeUser("owner"); + const base = { + userId: owner.id, + createdById: owner.id, + tokenPrefix: "hlf_0000", + }; + await db().cycleCalendarFeed.create({ data: { ...base, tokenHash: "a" } }); + await expect( + db().cycleCalendarFeed.create({ data: { ...base, tokenHash: "b" } }), + ).rejects.toThrow(); + // A revoked row does not count. + await db().cycleCalendarFeed.updateMany({ + where: { userId: owner.id }, + data: { revokedAt: new Date() }, + }); + await db().cycleCalendarFeed.create({ data: { ...base, tokenHash: "b" } }); + }); + + it("the table refuses a horizon outside 1..6", async () => { + const owner = await makeUser("owner"); + await expect( + db().cycleCalendarFeed.create({ + data: { + userId: owner.id, + createdById: owner.id, + tokenHash: "x", + tokenPrefix: "hlf_0000", + horizonCycles: 12, + }, + }), + ).rejects.toThrow(); + }); + + it("refuses a Bearer token, even a cookie-equivalent one", async () => { + const owner = await makeUser("owner"); + const { issueApiToken } = await import("@/lib/auth/issue-token"); + const issued = await issueApiToken({ userId: owner.id, name: "app" }); + cookieJar.clear(); + headerJar.clear(); + headerJar.set("authorization", `Bearer ${issued.token}`); + const res = await manage("POST", undefined, { + authorization: `Bearer ${issued.token}`, + }); + expect(res.status).toBe(403); + expect((await res.json()).meta?.errorCode).toBe("cycle.feed.cookie_only"); + expect(await db().cycleCalendarFeed.count()).toBe(0); + }); + + it("refuses a session without a fresh proof", async () => { + const owner = await makeUser("owner"); + await signIn(owner.id, new Date(Date.now() - 60 * 60 * 1000)); + const res = await manage("POST"); + expect(res.status).toBe(401); + expect((await res.json()).meta?.errorCode).toBe("auth.reproof.required"); + expect(await db().cycleCalendarFeed.count()).toBe(0); + }); + + it("refuses a READ delegate", async () => { + const owner = await makeUser("owner"); + const reader = await makeUser("reader"); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: reader.id, + access: "READ", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: reader.id }); + const session = await signIn(reader.id); + await switchSessionTo(session.id, owner.id); + + expect((await manage("POST")).status).toBe(403); + expect((await manage("GET")).status).toBe(403); + expect(await db().cycleCalendarFeed.count()).toBe(0); + }); + + it("refuses the fertile window outside TRYING_TO_CONCEIVE", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const res = await manage("PATCH", { includeFertile: true }); + expect(res.status).toBe(422); + expect((await res.json()).meta?.errorCode).toBe("cycle.feed.fertile_goal"); + }); + + it("never stores an idempotent replay of the mint", async () => { + const owner = await makeUser("owner"); + await signIn(owner.id); + const key = "feed-mint-replay-key-1"; + const first = await manage("POST", undefined, { "idempotency-key": key }); + expect(first.status).toBe(201); + const second = await manage("POST", undefined, { "idempotency-key": key }); + // Not a replay of the URL: the second request ran and met the live feed. + expect(second.headers.get("x-idempotent-replay")).not.toBe("true"); + expect(second.status).toBe(409); + const cached = await db().idempotencyKey.findMany({ + where: { userId: owner.id }, + }); + for (const row of cached) { + expect(row.responseBody == null || row.responseBody.length === 0).toBe( + true, + ); + } + }); +}); + +describe("fetching the feed", () => { + it("serves iCalendar with an ETag and answers 304 to it", async () => { + const owner = await makeUser("owner"); + await seedCycles(owner.id); + const minted = await mint(owner.id); + + const res = await fetchFeed(segmentOf(minted.url), { + "user-agent": "Google-Calendar-Importer", + }); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe( + "text/calendar; charset=utf-8", + ); + const body = await res.text(); + expect(body.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true); + expect(body).toContain("SUMMARY:●"); + const etag = res.headers.get("etag")!; + expect(etag).toMatch(/^"[0-9a-f]{64}"$/); + + // Let the fire-and-forget statistics land before the conditional fetch. + const { settleBackgroundTasks } = + await import("@/lib/logging/background-tasks"); + await settleBackgroundTasks(); + const again = await fetchFeed(segmentOf(minted.url), { + "if-none-match": etag, + }); + expect(again.status).toBe(304); + await settleBackgroundTasks(); + + const row = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId: owner.id }, + }); + expect(row.fetchCount).toBe(2); + expect(row.bodyHash).toBe(etag.slice(1, -1)); + + await signIn(owner.id); + const status = (await (await manage("GET")).json()).data; + expect(status.feed.lastFetcher).toBe("GOOGLE"); + expect(status.feed.fetchCount).toBe(2); + }); + + it("answers the same 404 for every miss class", async () => { + const owner = await makeUser("owner"); + await seedCycles(owner.id); + const minted = await mint(owner.id); + const live = segmentOf(minted.url); + + await expect404(await fetchFeed(`hlf_${"0".repeat(48)}.ics`)); // unknown + await expect404(await fetchFeed("hlf_short")); // malformed + + // The operator's external-access switch. + await db().appSettings.upsert({ + where: { id: "singleton" }, + create: { id: "singleton", apiGlobal: false }, + update: { apiGlobal: false }, + }); + await expect404(await fetchFeed(live)); + await db().appSettings.update({ + where: { id: "singleton" }, + data: { apiGlobal: true }, + }); + + // The operator's module switch. + await db().appSettings.update({ + where: { id: "singleton" }, + data: { moduleAvailabilityJson: { cycle: false } }, + }); + await expect404(await fetchFeed(live)); + await db().appSettings.update({ + where: { id: "singleton" }, + data: { moduleAvailabilityJson: {} }, + }); + + // The person's own toggle. + await db().cycleProfile.update({ + where: { userId: owner.id }, + data: { cycleTrackingEnabled: false }, + }); + await expect404(await fetchFeed(live)); + await db().cycleProfile.update({ + where: { userId: owner.id }, + data: { cycleTrackingEnabled: true }, + }); + + // Back to serving, so the misses above are the gates and not the fixture. + expect((await fetchFeed(live)).status).toBe(200); + + // An account in its deletion window. + await db().user.update({ + where: { id: owner.id }, + data: { deletionRequestedAt: new Date() }, + }); + await expect404(await fetchFeed(live)); + await db().user.update({ + where: { id: owner.id }, + data: { deletionRequestedAt: null }, + }); + + // Revoked. + await signIn(owner.id); + expect((await manage("DELETE")).status).toBe(200); + await expect404(await fetchFeed(live)); + }); + + it("throttles a well-formed unknown token through the anonymous limit", async () => { + let throttled = false; + for (let i = 0; i < 130 && !throttled; i++) { + const res = await fetchFeed(`hlf_${"1".repeat(48)}.ics`); + if (res.status === 429) throttled = true; + else expect(res.status).toBe(404); + } + expect(throttled).toBe(true); + }); + + it("dies with the carrier's MANAGE grant, and a handover claim revokes it", async () => { + const profile = await makeUser("profile", { managedProfileAt: new Date() }); + const guardian = await makeUser("guardian"); + await seedCycles(profile.id); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: profile.id, + granteeId: guardian.id, + access: "MANAGE", + scope: null, + }); + const grant = await acceptGrant({ + grantId: invited.id, + granteeId: guardian.id, + }); + + const session = await signIn(guardian.id); + await switchSessionTo(session.id, profile.id); + const res = await manage("POST"); + expect(res.status).toBe(201); + const { url } = (await res.json()).data as { url: string }; + const row = await db().cycleCalendarFeed.findFirstOrThrow({ + where: { userId: profile.id }, + }); + expect(row.createdById).toBe(guardian.id); + expect((await fetchFeed(segmentOf(url))).status).toBe(200); + + // Grant revoked: the next poll is a 404, with no write anywhere. + await db().accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + await expect404(await fetchFeed(segmentOf(url))); + await db().accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: null, revokedBy: null }, + }); + expect((await fetchFeed(segmentOf(url))).status).toBe(200); + + // The handover claim ends the carrier's feed outright. + const { clearManagedProfileMarker } = + await import("@/lib/managed-profiles/lifecycle"); + await clearManagedProfileMarker({ profileId: profile.id }); + const after = await db().cycleCalendarFeed.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(after.revokedAt).not.toBeNull(); + expect( + await db().auditLog.count({ + where: { action: "cycle.feed.revoke.handover", userId: profile.id }, + }), + ).toBe(1); + }); + + it("is removed when the carrier's account is deleted", async () => { + const owner = await makeUser("owner"); + const carrier = await makeUser("carrier"); + await db().cycleCalendarFeed.create({ + data: { + userId: owner.id, + createdById: carrier.id, + tokenHash: "cascade", + tokenPrefix: "hlf_0000", + }, + }); + await db().user.delete({ where: { id: carrier.id } }); + expect(await db().cycleCalendarFeed.count()).toBe(0); + }); + + it("is revoked by a credential remediation on either account", async () => { + const owner = await makeUser("owner"); + await mint(owner.id); + const { destroyAllSessions } = await import("@/lib/auth/session"); + await destroyAllSessions(owner.id); + expect( + await db().cycleCalendarFeed.count({ + where: { userId: owner.id, revokedAt: null }, + }), + ).toBe(0); + }); +}); diff --git a/tests/integration/sharing-manage-handler-matrix.test.ts b/tests/integration/sharing-manage-handler-matrix.test.ts index 12f5c2159..01c3a88f9 100644 --- a/tests/integration/sharing-manage-handler-matrix.test.ts +++ b/tests/integration/sharing-manage-handler-matrix.test.ts @@ -280,7 +280,7 @@ const ROUTE_IMPORTS = import.meta.glob>( describe("the complete MANAGE handler matrix", () => { it("starts with a non-empty, exact handler inventory", () => { - expect(ADMITTED_MUTATING_HANDLERS.length).toBe(93); + expect(ADMITTED_MUTATING_HANDLERS.length).toBe(97); }); }); @@ -2678,6 +2678,75 @@ proveManageRoute("POST /api/cycle/symptoms/custom", { })) === 1, }); +/** A live calendar feed on the record, minted as the owner would (#1350). */ +async function makeCycleFeed(ownerId: string, prefix = "hlf_matr") { + await enableCycle(ownerId); + return getPrismaClient().cycleCalendarFeed.create({ + data: { + userId: ownerId, + createdById: ownerId, + tokenHash: `matrix-${ownerId}-${prefix}`, + tokenPrefix: prefix, + }, + }); +} + +async function liveCycleFeeds(ownerId: string) { + return getPrismaClient().cycleCalendarFeed.findMany({ + where: { userId: ownerId, revokedAt: null }, + }); +} + +proveManageRoute("POST /api/cycle/feed-subscription", { + route: "/api/cycle/feed-subscription", + prepare: async (ownerId) => ({ ownerId: await enableCycle(ownerId) }), + ok: 201, + auditAction: "cycle.feed.create", + applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 1, +}); + +proveManageRoute("PATCH /api/cycle/feed-subscription", { + route: "/api/cycle/feed-subscription", + action: "PATCH", + prepare: async (ownerId) => { + await makeCycleFeed(ownerId); + return { ownerId }; + }, + body: () => ({ horizonCycles: 6, titleMode: "DESCRIPTIVE" }), + ok: 200, + auditAction: "cycle.feed.update", + applied: async ({ ownerId }) => { + const [feed] = await liveCycleFeeds(ownerId); + return feed?.horizonCycles === 6 && feed.titleMode === "DESCRIPTIVE"; + }, +}); + +proveManageRoute("DELETE /api/cycle/feed-subscription", { + route: "/api/cycle/feed-subscription", + action: "DELETE", + prepare: async (ownerId) => { + await makeCycleFeed(ownerId); + return { ownerId }; + }, + ok: 200, + auditAction: "cycle.feed.revoke", + applied: async ({ ownerId }) => (await liveCycleFeeds(ownerId)).length === 0, +}); + +proveManageRoute("POST /api/cycle/feed-subscription/rotate", { + route: "/api/cycle/feed-subscription/rotate", + prepare: async (ownerId) => ({ + ownerId, + previous: (await makeCycleFeed(ownerId)).id, + }), + ok: 201, + auditAction: "cycle.feed.rotate", + applied: async ({ ownerId, previous }) => { + const live = await liveCycleFeeds(ownerId); + return live.length === 1 && live[0].id !== previous; + }, +}); + proveManageRoute("POST /api/medications/categories", { route: "/api/medications/categories", prepare: async (ownerId) => ({ ownerId }), @@ -3020,7 +3089,7 @@ describe("the conditions the admissions were granted on", () => { describe("the complete MANAGE handler matrix", () => { it("registers one strict actor-and-effect driver for every admission", () => { const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(STRICT_DRIVER_KEYS.size).toBe(93); + expect(STRICT_DRIVER_KEYS.size).toBe(97); expect([...STRICT_DRIVER_KEYS].sort()).toEqual(expected); }); }); @@ -3032,6 +3101,6 @@ describe("the complete MANAGE handler matrix", () => { afterAll(() => { if (EFFECT_CASES_STARTED.size !== STRICT_DRIVER_KEYS.size) return; const expected = ADMITTED_MUTATING_HANDLERS.map(matrixKey).sort(); - expect(REAL_EFFECT_KEYS.size).toBe(93); + expect(REAL_EFFECT_KEYS.size).toBe(97); expect([...REAL_EFFECT_KEYS].sort()).toEqual(expected); });