From d9a31524a290c4bcbc13f093d4c6143fd8f4467e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 6 Nov 2026 17:53:45 +0100 Subject: [PATCH] feat(cycle): serve the cycle forecast as a subscribable calendar GET /api/cycle/feed/[.ics] answers any calendar app with the record's forecast as RFC 5545: all-day events for the next few periods over their widening windows, optionally past periods, and for a conception goal that asks for it the next fertile window. Dates and a title only; discreet titles and the calendar name "HealthLog" by default. The body is built live on every fetch in the record's own zone and language, never the request's. ETag is the content hash and a matching If-None-Match answers 304; DTSTAMP moves only when the content does. The feed is anonymous, so every gate is read per fetch: the auth-surface limit before any lookup, the token's shape, the HMAC lookup of a live feed, a per-token bucket, then the record and carrier not pending deletion, the operator's external-access switch, cycle tracking for the record, and for a feed a delegate minted, a live MANAGE grant covering cycle. Every miss is the same 404. /api/cycle/feed-subscription (status, turn on, options, turn off) and its rotate are cookie-only through requireRecordAuth("manage", "cycle"), with a fresh proof on turning on and rotating and a per-actor mint bucket. The URL is returned once; the status carries the prefix, the options, the fetch statistics as a calendar-app family and the projection state. The hlf_ family is registered with the path and token redactors, the secret-shape refusal, the proxy's public paths and edge headers, the URL-credential, sharing, recent-proof, step-up and module-gate guards, the error-code catalogue, the activity feed and the destructive-control registry. The cycle settings tab gains the subscription card and the predictions panel lists the later cycles. --- docs/api/openapi.yaml | 1556 +++++++++++------ messages/de.json | 71 +- messages/en.json | 71 +- messages/es.json | 71 +- messages/fr.json | 71 +- messages/it.json | 71 +- messages/ko.json | 71 +- messages/nl.json | 71 +- messages/pl.json | 71 +- messages/pt-BR.json | 71 +- messages/ru.json | 71 +- .../baselines/source-file-lines.json | 4 +- .../baselines/source-function-lines.json | 9 +- .../baselines/version-stamp-comments.json | 2 +- .../cycle-feed-content-guard.test.ts | 185 ++ .../destructive-control-guard.test.ts | 8 + src/__tests__/i18n-english-leak-guard.test.ts | 4 + ...openapi-security-declaration-guard.test.ts | 2 + src/__tests__/proxy-cycle-feed-public.test.ts | 78 + .../recent-proof-surface-guard.test.ts | 30 + src/__tests__/session-surface-guard.test.ts | 9 + src/__tests__/sharing-surface-guard.test.ts | 26 +- .../step-up-web-caller-guard.test.ts | 2 + src/__tests__/v137-wave-zero-fixtures.test.ts | 2 +- .../module-route-gate-inventory.test.ts | 13 + .../cycle/feed-subscription/rotate/route.ts | 115 ++ src/app/api/cycle/feed-subscription/route.ts | 239 +++ src/app/api/cycle/feed/[token]/route.ts | 25 + src/app/api/share-links/route.ts | 18 +- .../cycle-calendar-feed-card.test.tsx | 71 + .../__tests__/predictions-panel.test.tsx | 56 + .../cycle/cycle-calendar-feed-card.tsx | 395 +++++ src/components/cycle/cycle-settings-tab.tsx | 19 + src/components/cycle/cycle-view.tsx | 9 +- src/components/cycle/predictions-panel.tsx | 15 +- src/components/cycle/types.ts | 18 + src/components/cycle/upcoming-cycles.tsx | 72 + src/components/cycle/use-cycle-feed.ts | 110 ++ .../glitchtip-path-secret-redaction.test.ts | 10 + src/lib/__tests__/secret-shape.test.ts | 4 + src/lib/cycle/__tests__/ics.test.ts | 154 ++ src/lib/cycle/feed/__tests__/build.test.ts | 223 +++ src/lib/cycle/feed/__tests__/fixtures.ts | 57 + src/lib/cycle/feed/__tests__/serve.test.ts | 290 +++ src/lib/cycle/feed/build.ts | 161 ++ src/lib/cycle/feed/events.ts | 129 ++ src/lib/cycle/feed/fetcher.ts | 23 + src/lib/cycle/feed/history.ts | 73 + src/lib/cycle/feed/manage.ts | 47 + src/lib/cycle/feed/serve.ts | 261 +++ src/lib/cycle/feed/status.ts | 101 ++ src/lib/cycle/feed/token.ts | 50 + src/lib/cycle/ics.ts | 166 ++ src/lib/http/origin.ts | 22 + src/lib/http/path-credential-headers.ts | 40 + src/lib/logging/__tests__/redact.test.ts | 31 + src/lib/logging/redact.ts | 11 +- src/lib/openapi/error-codes.ts | 7 + src/lib/openapi/routes/cycle-feed.ts | 284 +++ src/lib/openapi/routes/cycle.ts | 7 + src/lib/openapi/routes/index.ts | 2 + src/lib/pwa/__tests__/sw-cache-policy.test.ts | 9 + src/lib/query-keys/cycle.ts | 4 + src/lib/record-activity/activity-verb.ts | 51 +- src/lib/secret-shape.ts | 5 +- src/lib/validations/cycle-feed.ts | 42 + src/proxy.ts | 29 +- tests/fixtures/v137/sharing-matrix.ts | 32 + tests/integration/cycle-calendar-feed.test.ts | 515 ++++++ .../sharing-manage-handler-matrix.test.ts | 75 +- 70 files changed, 6063 insertions(+), 654 deletions(-) create mode 100644 src/__tests__/cycle-feed-content-guard.test.ts create mode 100644 src/__tests__/proxy-cycle-feed-public.test.ts create mode 100644 src/app/api/cycle/feed-subscription/rotate/route.ts create mode 100644 src/app/api/cycle/feed-subscription/route.ts create mode 100644 src/app/api/cycle/feed/[token]/route.ts create mode 100644 src/components/cycle/__tests__/cycle-calendar-feed-card.test.tsx create mode 100644 src/components/cycle/cycle-calendar-feed-card.tsx create mode 100644 src/components/cycle/cycle-settings-tab.tsx create mode 100644 src/components/cycle/upcoming-cycles.tsx create mode 100644 src/components/cycle/use-cycle-feed.ts create mode 100644 src/lib/cycle/__tests__/ics.test.ts create mode 100644 src/lib/cycle/feed/__tests__/build.test.ts create mode 100644 src/lib/cycle/feed/__tests__/fixtures.ts create mode 100644 src/lib/cycle/feed/__tests__/serve.test.ts create mode 100644 src/lib/cycle/feed/build.ts create mode 100644 src/lib/cycle/feed/events.ts create mode 100644 src/lib/cycle/feed/fetcher.ts create mode 100644 src/lib/cycle/feed/history.ts create mode 100644 src/lib/cycle/feed/manage.ts create mode 100644 src/lib/cycle/feed/serve.ts create mode 100644 src/lib/cycle/feed/status.ts create mode 100644 src/lib/cycle/feed/token.ts create mode 100644 src/lib/cycle/ics.ts create mode 100644 src/lib/http/origin.ts create mode 100644 src/lib/http/path-credential-headers.ts create mode 100644 src/lib/openapi/routes/cycle-feed.ts create mode 100644 src/lib/validations/cycle-feed.ts create mode 100644 tests/integration/cycle-calendar-feed.test.ts 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); });