diff --git a/.env.example b/.env.example index 20f090f7b..e1bc76e38 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,20 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY +# ----------------------------------------------------------------------------- +# Audit-trail retention (optional) +# ----------------------------------------------------------------------------- +# How long rows in `audit_logs` are kept. Default 365 days; the floor is 7 and +# anything below it is ignored, so a window set in seconds by mistake cannot +# empty a fresh table. A daily pg-boss job does the pruning. +# +# The number is not internal. Settings -> Shared access states it under the +# activity list, and the dialog that ends somebody's access says how long +# "who entered what" stays answerable. Both read the value the server +# resolved, so an instance running 90 says 90 everywhere. +# AUDIT_LOG_RETENTION_DAYS="365" + + # ----------------------------------------------------------------------------- # APNs — Apple Push Notification service (v1.4.23+, optional) # ----------------------------------------------------------------------------- diff --git a/.env.production.example b/.env.production.example index 76c50df27..93aa99e1e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -184,6 +184,17 @@ API_TOKEN_HMAC_KEY="" # SMTP_SECURE="false" +# ----------------------------------------------------------------------------- +# Session cookie Secure flag -- the most common self-host snag +# ----------------------------------------------------------------------------- +# Unset, the flag tracks NODE_ENV, so a production image sets `Secure` and a +# browser on plain HTTP silently drops the session cookie: login appears to +# succeed and every following request is anonymous. Set it to false ONLY on a +# LAN, Tailscale or VPN-only host that deliberately serves HTTP. Behind a TLS +# reverse proxy, leave it alone. +# SESSION_COOKIE_SECURE="false" + + # ----------------------------------------------------------------------------- # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback # ----------------------------------------------------------------------------- @@ -230,6 +241,21 @@ API_TOKEN_HMAC_KEY="" # BACKUP_ENCRYPTION_KEY="" +# ----------------------------------------------------------------------------- +# Audit-trail retention -- optional +# ----------------------------------------------------------------------------- +# How long rows in `audit_logs` survive, in days. Default 365. The floor is 7: +# a smaller value is ignored rather than obeyed, so a window entered in seconds +# by mistake cannot empty a fresh table. A daily job does the pruning. +# +# This number is shown to users, not just enforced. Settings -> Shared access +# prints the window under the activity list, and the dialog that ends somebody +# else's access states how long "who entered what" stays answerable. Both take +# the value the server resolved from this variable, so an instance running 90 +# says 90 on both surfaces. +# AUDIT_LOG_RETENTION_DAYS="365" + + # ----------------------------------------------------------------------------- # FHIR health-record export -- optional # ----------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index e5c925bfc..e121cf65a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,7 @@ /test-results /playwright-report /playwright/.cache -/e2e/setup/storageState.json -/e2e/setup/storageStateOwner.json -/e2e/setup/storageStateDelegate.json +/e2e/setup/storageState*.json # next.js /.next/ @@ -95,4 +93,4 @@ next-env.d.ts # times over. The same two names are excluded from eslint and vitest, for the # same reason in a different tool. /.wt-*/ -/.worktrees/ \ No newline at end of file +/.worktrees/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d63810152..69dfb6b15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,79 @@ ## [Unreleased] +## [1.37.0] — 2026-08-07 + ### Added +- Sharing a record no longer has to mean sharing the whole of it. An invitation + can name the parts it opens: readings, medications, lab results, health + background, illness, mood and mind, cycle, documents. Everything you do not + pick stays closed, and a section you kept back is refused the same way a + record nobody shared with you is, so what you held back is not visible from + the outside. + + Two things the invitation screen says out loud rather than leaving you to + work them out. A section can mention the rest of your health in its notes and + its names, so sharing a section shares whatever was written in it. And the + dashboard overview, the health score and the daily digest appear only when + the entire record is shared, because a figure computed from part of a record + would read as a figure about the person. + + Every grant written before this release opens the entire record and is + untouched. Nobody has to agree to anything again. + +- A third level of access. Read access looks at the record. Write access adds + to it. Manage access can also change and remove what is already there, + including entries you made yourself, and can read the insights generated from + the record. The line it stops at is the account around the record: your + login, your second factor, your connected services, your API tokens, where + your notifications go, which modules and thresholds are on, and who else has + access. + + Offering manage access asks for your second factor, so it can only be done in + a browser. Accepting it can be done anywhere. It always covers the entire + record. There is no way to hand somebody everything about part of you, because + a note in one section can be about any other. + + Everything a manager does is recorded under their own name, with a verb that + says what it was rather than "made a change". + +- A health record for somebody who does not sign in. @balajiv113 asked in #360 + for a way to look after a family member, and a grant between two accounts only + ever covered half of that. The other half is the person who has no account to + begin with: a child, or somebody you care for. + + A managed profile has no login and no e-mail address. You give it a name, + optionally a real date of birth, and the language and timezone its own days + and reminders are measured in. Nothing is invented from a year you did not + give. Creating one asks for your second factor, and you become its first + guardian in the same transaction, so there is no moment where the record + exists and nobody is looking after it. + + You can invite a second guardian, who accepts the invitation the way any + other is accepted. The record can never be left with nobody: the last + guardian cannot hand it back and cannot be removed, and the screen says so + where the refusal happens. The way out is to add somebody else, or to delete + the profile. + +- The reminders a managed profile produces reach the people looking after it. A + record with no login has no phone and no chat of its own, so medication + reminders, measurement reminders, safety-floor alerts and low-stock alerts go + to its guardians, each in their own language and over the channels they had + already chosen. The message names whose record it is about. It carries no + buttons: opening the app takes you through the ordinary switch into that + record rather than acting on it from a lock screen. + + One guardian turning off their own reminders on their own phone does not + silence the others. + +- A guardian can keep a managed profile's settings. Its modules, its units, its + language and timezone, its thresholds and its notification preferences, read + and written as that record's settings rather than as yours. Your own screens + stay in your own language while you do it. Connected services show a status + and nothing else for now: connecting, syncing and disconnecting a wearable on + somebody else's behalf is not part of this release. + - The document vault opens inside a shared record. `/documents` was offered in the navigation of somebody else's record and then refused every read behind it, so the page rendered an error card and the illness view rendered another @@ -33,6 +104,31 @@ ### Fixed +- Switching records with more than one tab open could leave every tab on a + spinner for half a minute. While a switch is in flight the other tabs hold, + and they are released by the answer the server gives afterwards. A tab that + reloaded during the switch was mistaken for somebody else starting a second + one, which quietly detached the hold from the switch it belonged to, so no + answer could release it and the tabs waited out the timeout instead. The + release now stays attached to the switch that opened it. + +- Closing a document could leave it named in the address bar. Opening a + document from a card adds a step to the browser's history so that Back closes + it again, and closing it asks the browser to consume that step. The browser + can decline, and when it did the sheet closed while the address bar went on + naming the document, so a reload or a shared link re-opened the one you had + just closed. The close now checks that the step was consumed and tidies the + address bar itself when it was not. + +- Two tabs of one browser could disagree about which record they were in. + Switching records moves a session rather than a tab, so a tab left open on + the record you just left went on reading and writing there until somebody + reloaded it. Every request now carries the record the tab believes it is in, + and one naming the record it has left is refused with an answer the tab + recovers from by re-reading who it is. A session that has never opened + somebody else's record is unaffected, and so is the phone app, which carries + the record on the request itself and never held a selector to go stale. + - Somebody with write access could still overturn a dose you had already recorded. The rule that keeps that with the owner was on one request and the medication card reaches the same entry through another, where an explicit diff --git a/docker-compose.yml b/docker-compose.yml index 503e87ed8..345c19d88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,16 @@ x-healthlog-env: &healthlog_env BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" + # How long the audit trail is kept, in days. Default 365, floor 7 (a value + # below the floor is ignored rather than obeyed, so a window set in seconds + # by mistake cannot empty a fresh table). It is not only a retention knob: + # the shared-access panel PUBLISHES this number — "who entered what stays + # answerable for N days" is on the dialog that ends somebody's access, and + # the activity view states the same window. An instance running 90 says 90, + # so the var has to actually reach the container. Listed here for the reason + # the block exists: `environment:` is a whitelist, and a value in .env that + # is not named here never arrives. + AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}" # Per-export ceiling on MedicationAdministration rows in the FHIR # health-record Bundle. Bounded 1..50000, default 5000. Listed here so # an operator override in .env actually reaches the container (the diff --git a/docs/README.md b/docs/README.md index 084dd655c..5384a5694 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,10 +28,11 @@ notes, and machine-readable specs that ship alongside the source: own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending login IPs to a third-party lookup. And [`self-hosting/account-sharing.md`](./self-hosting/account-sharing.md) — - letting one account on the instance open another's health record: what - the person given access can and cannot reach, what revocation does and - does not undo, and which trust properties of the instance are unchanged - by it. + letting one account on the instance open another's health record: the + three access levels, the eight sections a grant can be narrowed to, + managed profiles for somebody with no login and the guardians who look + after them, what revocation does and does not undo, and which trust + properties of the instance are unchanged by any of it. - [`migration/`](./migration/) — release-by-release migration notes. Read the entry for the version you're upgrading from. - [`audit/`](./audit/) — per-release audit summaries archived for diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 01b8633c3..eddfd2b8c 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.36.1 + version: 1.37.0 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -75,7 +75,20 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": &a2 - description: Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). + description: >- + Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD + being read, not the caller: a delegate inside somebody else's record sees the owner's setting. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -565,7 +578,13 @@ paths: schema: $ref: "#/components/schemas/CycleBulkEnvelope" "401": *a1 - "403": *a2 + "403": &a5 + description: "Cycle tracking is not enabled for this account (errorCode `cycle.disabled`). Resolved against the RECORD + being read, not the caller: a delegate inside somebody else's record sees the owner's setting." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/cycle/period: @@ -727,7 +746,7 @@ paths: schema: $ref: "#/components/schemas/CyclePurgeEnvelope" "401": *a1 - "403": *a2 + "403": *a5 "422": *a3 "429": *a4 /api/cycle/profile: @@ -961,6 +980,20 @@ paths: type: string format: binary "401": *a1 + "403": &a6 + description: "Refused: this request named a record the caller may not act on (`meta.errorCode` = + `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted + nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the + response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record + owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers + one further case: a session that has entered a shared record and sends a request without its record-context + assertion, which is a client older than the fence — leave the record and reload. A request that names no + record but the caller's own never reaches this response, so a client that never switches and never sends the + per-request account selector will not see it." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/share-links: @@ -2466,6 +2499,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurements/series: @@ -2517,6 +2551,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementsSeriesResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurements: @@ -2713,6 +2748,7 @@ paths: schema: $ref: "#/components/schemas/ListMeasurementsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -2970,6 +3006,7 @@ paths: schema: $ref: "#/components/schemas/CreateMeasurementResponse" "401": *a1 + "403": *a6 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. @@ -2999,6 +3036,7 @@ paths: schema: $ref: "#/components/schemas/GetMeasurementResponse" "401": *a1 + "403": *a6 "404": description: Measurement not found (or owned by another user). content: @@ -3046,6 +3084,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 + "403": *a6 "404": description: Measurement not found (or owned by another user). content: @@ -3081,6 +3120,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMeasurementResponse" "401": *a1 + "403": *a6 "404": description: Measurement not found (or owned by another user). content: @@ -3162,6 +3202,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsBulkDeleteResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurements/restore: @@ -3187,6 +3228,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementsRestoreResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/sleep/night: @@ -3216,10 +3258,22 @@ paths: schema: $ref: "#/components/schemas/SleepNightResponse" "401": *a1 - "403": &a5 - description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = + "403": &a7 + description: >- + Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a - valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + valid Bearer token. Clients hide the whole module surface end-to-end rather than retry. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -3245,7 +3299,7 @@ paths: schema: $ref: "#/components/schemas/SleepRhythmResponse" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/workouts: @@ -3294,7 +3348,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/workouts/{id}: @@ -3330,7 +3384,14 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": *a5 + "403": &a10 + description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = + `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a + valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": description: Workout not found (or owned by another user). content: @@ -3624,6 +3685,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -3652,6 +3714,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/medications/layout: @@ -3670,6 +3733,7 @@ paths: schema: $ref: "#/components/schemas/MedicationListLayoutResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 put: @@ -3703,7 +3767,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a6 + "422": &a8 description: Request validation failed. When the body carried a `baseUpdatedAt` that could not be parsed as an ISO-8601 timestamp — including an explicit `null` — `meta.errorCode` = `invalid_base_updated_at` and nothing was written. Omit the key entirely for the unconditional write; do not send `null` for it. @@ -3748,6 +3812,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3784,6 +3849,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3855,6 +3921,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationIntakeResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3887,6 +3954,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationInventoryResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3921,6 +3989,7 @@ paths: schema: $ref: "#/components/schemas/CreateMedicationInventoryItemResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -3963,6 +4032,7 @@ paths: schema: $ref: "#/components/schemas/UpdateMedicationInventoryItemResponse" "401": *a1 + "403": *a6 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -3997,6 +4067,7 @@ paths: schema: $ref: "#/components/schemas/DeleteMedicationInventoryItemResponse" "401": *a1 + "403": *a6 "404": description: Inventory item not found (or owned by another user / medication). content: @@ -4074,6 +4145,7 @@ paths: schema: $ref: "#/components/schemas/GetMedicationCadenceResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -4101,6 +4173,7 @@ paths: schema: $ref: "#/components/schemas/ListMedicationComplianceResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/medications/{id}/compliance: @@ -4364,6 +4437,7 @@ paths: schema: $ref: "#/components/schemas/ListScheduleRevisionsResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -4401,6 +4475,7 @@ paths: schema: $ref: "#/components/schemas/CreateScheduleRevisionResponse" "401": *a1 + "403": *a6 "404": description: Medication not found (or owned by another user). content: @@ -4444,6 +4519,7 @@ paths: schema: $ref: "#/components/schemas/UpdateScheduleRevisionResponse" "401": *a1 + "403": *a6 "404": description: Medication or revision not found (or owned by another user). content: @@ -4484,6 +4560,7 @@ paths: schema: $ref: "#/components/schemas/DeleteScheduleRevisionResponse" "401": *a1 + "403": *a6 "404": description: Medication or revision not found (or owned by another user). content: @@ -4546,6 +4623,7 @@ paths: schema: $ref: "#/components/schemas/MedicationDoseHistoryImportQueuedEnvelope" "401": *a1 + "403": *a6 "409": description: A dose-history import is already in progress. content: @@ -4607,6 +4685,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 + "403": *a6 "404": description: No such job for this medication (or owned by another user). content: @@ -4640,6 +4719,7 @@ paths: schema: $ref: "#/components/schemas/MedicationIntakeImportJobStatusEnvelope" "401": *a1 + "403": *a6 "404": description: No such job for this account (or owned by another user). content: @@ -4713,7 +4793,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/auth/me/disable-coach: get: @@ -4911,7 +4991,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/auth/me/source-priority: get: @@ -5937,7 +6017,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/auth/me/report-selection: get: @@ -6381,7 +6461,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 delete: tags: @@ -6470,6 +6550,7 @@ paths: schema: $ref: "#/components/schemas/CoachNudgeStatus" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/coach/seen: @@ -6584,7 +6665,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/anamnesis/facts: get: @@ -6602,6 +6683,7 @@ paths: schema: $ref: "#/components/schemas/GetAnamnesisFactsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -6709,7 +6791,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a7 + "404": &a9 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -6745,7 +6827,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a7 + "404": *a9 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -7027,7 +7109,19 @@ paths: $ref: "#/components/schemas/CoachRemindersList" "401": *a1 "403": - description: Coach surface disabled. + description: >- + Coach surface disabled. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -7337,6 +7431,7 @@ paths: schema: $ref: "#/components/schemas/DashboardSnapshotResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/comprehensive: @@ -7356,8 +7451,20 @@ paths: $ref: "#/components/schemas/InsightsComprehensiveResponseEnvelope" "401": *a1 "403": - description: "AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = - `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying." + description: >- + AI consent required: no active ConsentReceipt for the server-managed provider. `meta.errorCode` = + `consent.ai.required`. Mint a receipt via POST /api/consent/ai before retrying. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. content: application/json: schema: @@ -7382,7 +7489,7 @@ paths: schema: $ref: "#/components/schemas/InsightsCardsEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/insights/pregenerate: @@ -7557,6 +7664,7 @@ paths: schema: $ref: "#/components/schemas/BloodPressureStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/pulse-status: @@ -7585,6 +7693,7 @@ paths: schema: $ref: "#/components/schemas/PulseStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/weight-status: @@ -7613,6 +7722,7 @@ paths: schema: $ref: "#/components/schemas/WeightStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/bmi-status: @@ -7641,6 +7751,7 @@ paths: schema: $ref: "#/components/schemas/BmiStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/mood-status: @@ -7669,6 +7780,7 @@ paths: schema: $ref: "#/components/schemas/MoodStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/medication-compliance-status: @@ -7697,6 +7809,7 @@ paths: schema: $ref: "#/components/schemas/MedicationComplianceStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/metric-status: @@ -7788,6 +7901,7 @@ paths: schema: $ref: "#/components/schemas/MetricStatusResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/biomarker-assessment: @@ -7829,6 +7943,7 @@ paths: schema: $ref: "#/components/schemas/BiomarkerAssessmentResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/derived: @@ -7911,6 +8026,7 @@ paths: schema: $ref: "#/components/schemas/DerivedMetricResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/derived/batch: @@ -7949,6 +8065,7 @@ paths: schema: $ref: "#/components/schemas/DerivedBatchResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/glp1-plateau: @@ -7968,6 +8085,7 @@ paths: schema: $ref: "#/components/schemas/InsightsGlp1PlateauResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/ecg: @@ -7990,7 +8108,7 @@ paths: schema: $ref: "#/components/schemas/EcgListResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 post: @@ -8028,7 +8146,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a5 + "403": *a10 "422": *a3 "429": *a4 /api/insights/ecg/{id}: @@ -8068,7 +8186,7 @@ paths: schema: $ref: "#/components/schemas/EcgDetailResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "404": description: No ECG recording with that id for the authenticated user (existence sealed — a foreign id is indistinguishable from a missing one). @@ -8100,7 +8218,7 @@ paths: schema: $ref: "#/components/schemas/RhythmEventsResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/insights/correlations: @@ -8121,6 +8239,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationDiscoveryResponseEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/patterns: @@ -8138,7 +8257,7 @@ paths: schema: $ref: "#/components/schemas/CorrelationPatternListResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/insights/patterns/{id}: @@ -8168,7 +8287,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCorrelationPatternResponseEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "404": description: No current pattern with that id for this account. content: @@ -8193,6 +8312,7 @@ paths: schema: $ref: "#/components/schemas/InsightsHealthStatusEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/breathing-screening: @@ -8211,6 +8331,7 @@ paths: schema: $ref: "#/components/schemas/InsightsBreathingScreeningEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/insights/labs-changes: @@ -8229,6 +8350,7 @@ paths: schema: $ref: "#/components/schemas/InsightsLabsChangesEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/mood-entries/bulk: @@ -8365,7 +8487,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a8 + "404": &a11 description: Not found / not owned by the caller. content: application/json: @@ -8400,7 +8522,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 /api/mood/tags/{key}/hidden: @@ -8441,7 +8563,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 /api/mood/tags/groups: @@ -8516,7 +8638,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 delete: @@ -8547,7 +8669,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a8 + "404": *a11 "422": *a3 "429": *a4 /api/mood/tags/layout: @@ -8595,7 +8717,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 /api/settings/reminder-thresholds: get: @@ -8613,6 +8735,7 @@ paths: schema: $ref: "#/components/schemas/GetReminderThresholdsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/consent/ai: @@ -8747,6 +8870,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderListEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -8769,6 +8893,7 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCreatedEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/measurement-reminders/{id}: @@ -8791,7 +8916,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 - "404": &a9 + "403": *a6 + "404": &a12 description: Measurement reminder not found / not owned. content: application/json: @@ -8825,7 +8951,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 delete: @@ -8847,7 +8974,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/satisfy: @@ -8871,7 +8999,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 /api/measurement-reminders/{id}/complete: @@ -8898,7 +9027,8 @@ paths: schema: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 - "404": *a9 + "403": *a6 + "404": *a12 "422": *a3 "429": *a4 /api/labs: @@ -8970,6 +9100,7 @@ paths: schema: $ref: "#/components/schemas/ListLabResultsEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -9041,6 +9172,7 @@ paths: schema: $ref: "#/components/schemas/CreateLabResultResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/labs/{id}: @@ -9063,7 +9195,8 @@ paths: schema: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 - "404": &a10 + "403": *a6 + "404": &a13 description: Lab result not found (or owned by another user). content: application/json: @@ -9141,7 +9274,8 @@ paths: schema: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 - "404": *a10 + "403": *a6 + "404": *a13 "422": *a3 "429": *a4 delete: @@ -9163,7 +9297,8 @@ paths: schema: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 - "404": *a10 + "403": *a6 + "404": *a13 "422": *a3 "429": *a4 /api/labs/restore: @@ -9187,6 +9322,7 @@ paths: schema: $ref: "#/components/schemas/RestoreLabResultsResponse" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/labs/ocr/capability: @@ -9332,6 +9468,7 @@ paths: schema: $ref: "#/components/schemas/ListBiomarkersEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -9380,7 +9517,8 @@ paths: schema: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 - "409": &a12 + "403": *a6 + "409": &a15 description: A biomarker with this name already exists for the caller. content: application/json: @@ -9408,7 +9546,8 @@ paths: schema: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 - "404": &a11 + "403": *a6 + "404": &a14 description: Biomarker not found (or owned by another user). content: application/json: @@ -9475,8 +9614,9 @@ paths: schema: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 - "404": *a11 - "409": *a12 + "403": *a6 + "404": *a14 + "409": *a15 "422": *a3 "429": *a4 delete: @@ -9499,7 +9639,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a11 + "404": *a14 "422": *a3 "429": *a4 /api/illness/episodes: @@ -9532,6 +9672,7 @@ paths: schema: $ref: "#/components/schemas/ListIllnessEpisodesEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -9602,6 +9743,7 @@ paths: schema: $ref: "#/components/schemas/CreateIllnessEpisodeEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/illness/episodes/{id}: @@ -9624,7 +9766,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 - "404": &a13 + "403": *a6 + "404": &a16 description: Illness episode not found (or owned by another user). content: application/json: @@ -9702,7 +9845,8 @@ paths: schema: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 delete: @@ -9725,7 +9869,8 @@ paths: schema: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/restore: @@ -9751,7 +9896,8 @@ paths: schema: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/resolve: @@ -9787,7 +9933,8 @@ paths: schema: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/day-logs: @@ -9837,7 +9984,8 @@ paths: schema: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 post: @@ -9916,7 +10064,8 @@ paths: schema: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 - "404": *a13 + "403": *a6 + "404": *a16 "422": *a3 "429": *a4 /api/illness/episodes/{id}/correlation: @@ -9943,7 +10092,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a13 + "404": *a16 "422": *a3 "429": *a4 /api/illness/insights: @@ -10016,6 +10165,7 @@ paths: schema: $ref: "#/components/schemas/ListMentalHealthAssessmentsEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10082,6 +10232,7 @@ paths: schema: $ref: "#/components/schemas/CreateMentalHealthAssessmentEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/allergies: @@ -10114,6 +10265,7 @@ paths: schema: $ref: "#/components/schemas/ListAllergiesEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10189,6 +10341,7 @@ paths: schema: $ref: "#/components/schemas/CreateAllergyEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/allergies/{id}: @@ -10211,7 +10364,8 @@ paths: schema: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 - "404": &a14 + "403": *a6 + "404": &a17 description: Allergy not found (or owned by another user). content: application/json: @@ -10293,7 +10447,8 @@ paths: schema: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 - "404": *a14 + "403": *a6 + "404": *a17 "422": *a3 "429": *a4 delete: @@ -10316,7 +10471,8 @@ paths: schema: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 - "404": *a14 + "403": *a6 + "404": *a17 "422": *a3 "429": *a4 /api/family-history: @@ -10340,6 +10496,7 @@ paths: schema: $ref: "#/components/schemas/ListFamilyHistoryEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10399,6 +10556,7 @@ paths: schema: $ref: "#/components/schemas/CreateFamilyHistoryEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/family-history/{id}: @@ -10421,7 +10579,8 @@ paths: schema: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 - "404": &a15 + "403": *a6 + "404": &a18 description: Family history entry not found (or owned by another user). content: application/json: @@ -10489,7 +10648,8 @@ paths: schema: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 - "404": *a15 + "403": *a6 + "404": *a18 "422": *a3 "429": *a4 delete: @@ -10512,7 +10672,8 @@ paths: schema: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 - "404": *a15 + "403": *a6 + "404": *a18 "422": *a3 "429": *a4 /api/documents/inbound: @@ -10619,6 +10780,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentListEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -10744,6 +10906,7 @@ paths: schema: $ref: "#/components/schemas/DocumentUsageEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/documents/inbound/bulk: @@ -10826,6 +10989,7 @@ paths: schema: $ref: "#/components/schemas/InboundDocumentDetailEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 patch: @@ -10974,6 +11138,7 @@ paths: type: string format: binary "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/documents/inbound/{id}/thumbnail: @@ -11002,6 +11167,7 @@ paths: type: string format: binary "401": *a1 + "403": *a6 "422": *a3 "429": *a4 /api/documents/inbound/{id}/extract: @@ -11573,6 +11739,7 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricsEnvelope" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 post: @@ -11621,7 +11788,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a17 + "409": &a20 description: A custom metric with this name already exists for the caller. content: application/json: @@ -11649,7 +11816,8 @@ paths: schema: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 - "404": &a16 + "403": *a6 + "404": &a19 description: Custom metric not found (or owned by another user). content: application/json: @@ -11713,8 +11881,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a16 - "409": *a17 + "404": *a19 + "409": *a20 "422": *a3 "429": *a4 delete: @@ -11737,7 +11905,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a16 + "404": *a19 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries: @@ -11780,7 +11948,8 @@ paths: schema: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 - "404": *a16 + "403": *a6 + "404": *a19 "422": *a3 "429": *a4 post: @@ -11824,7 +11993,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a16 + "404": *a19 "422": *a3 "429": *a4 /api/custom-metrics/{id}/entries/{entryId}: @@ -11874,7 +12043,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a18 + "404": &a21 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -11906,7 +12075,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a18 + "404": *a21 "422": *a3 "429": *a4 /api/onboarding/disclaimer: @@ -12042,7 +12211,7 @@ paths: schema: $ref: "#/components/schemas/NutrientBatchEnvelope" "401": *a1 - "403": &a19 + "403": description: The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is on. @@ -12076,7 +12245,26 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": *a19 + "403": &a22 + description: >- + The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not + retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is + on. + + + Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). + Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections + do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it + is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's + audit trail instead. On the cookie transport the same code answers one further case: a session that has + entered a shared record and sends a request without its record-context assertion, which is a client older + than the fence — leave the record and reload. A request that names no record but the caller's own never + reaches this response, so a client that never switches and never sends the per-request account selector will + not see it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/nutrients/daily: @@ -12112,7 +12300,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a19 + "403": *a22 "422": *a3 "429": *a4 /api/nutrients/water: @@ -12139,7 +12327,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a19 + "403": *a22 "422": *a3 "429": *a4 /api/integrations/healthkit: @@ -12327,7 +12515,7 @@ paths: schema: $ref: "#/components/schemas/DailyDigestEnvelope" "401": *a1 - "403": *a5 + "403": *a7 "422": *a3 "429": *a4 /api/daily/digest/dismiss: @@ -12363,7 +12551,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a5 + "403": *a10 "422": *a3 "429": *a4 /api/dashboard/widgets: @@ -12381,6 +12569,7 @@ paths: schema: $ref: "#/components/schemas/DashboardLayout" "401": *a1 + "403": *a6 "422": *a3 "429": *a4 put: @@ -12414,7 +12603,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a6 + "422": *a8 "429": *a4 delete: tags: @@ -12476,7 +12665,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a20 + "403": &a23 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -12501,48 +12690,28 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "422": *a3 "429": *a4 post: tags: - Account sharing summary: Invite an account to this record - description: Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing - until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an - authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a - silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can - neither invite nor re-delegate. + description: "Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole + record or named sections; the grant confers nothing until they accept it. An identifier that names no account + answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 + invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while + acting on another account, so a delegate can neither invite nor re-delegate. Offering MANAGE is step-up gated + (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 + `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else + happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not + authenticated' to a caller it had just authenticated." requestBody: required: true content: application/json: schema: - type: object - properties: - identifier: - type: string - minLength: 1 - maxLength: 255 - expiresAt: - anyOf: - - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - - type: "null" - access: - default: READ - description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, - observations, a medication, a marked dose) and can still edit or delete nothing, including its own. - Omitted means READ, so a client that predates the field keeps working. The level is fixed when the - invitation is written: no endpoint raises a live grant, because that would widen what the delegate - accepted without asking them again. The way up is a new invitation the delegate accepts." - type: string - enum: - - READ - - WRITE - required: - - identifier + $ref: "#/components/schemas/AccountGrantInvite" responses: "201": description: The pending grant. @@ -12550,8 +12719,22 @@ paths: application/json: schema: $ref: "#/components/schemas/AccountGrantEnvelope" - "401": *a1 - "403": *a20 + "401": + description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode: + auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another + account (grant management is never delegable), or `sharing.invite.manage_browser_only` when a Bearer caller + tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client + cannot mint it and should route the person to a browser rather than surface an error. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "404": description: No account on this instance carries that identifier. content: @@ -12566,8 +12749,10 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "422": - description: "Validation failed, or the invitation named the caller's own account (`meta.errorCode: - sharing.invite.self`)." + description: "Validation failed, the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`), + or the scope cannot mean anything (`meta.errorCode: sharing.invite.invalid_scope` from the domain module; + the request-shape refusal for an empty array, an unknown section or a scope beside MANAGE arrives as the + ordinary multi-issue envelope naming the `scope` field)." content: application/json: schema: @@ -12600,7 +12785,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such invitation for this account. content: @@ -12644,7 +12829,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such grant on this account's record. content: @@ -12652,7 +12837,11 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: That access had already ended. + description: "Two refusals share this status. `meta.errorCode: sharing.revoke.already_ended` — that access had already + ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the grant is the last + active Guardian grant on a managed profile, and ending it would leave a record with nobody able to open it. + The floor is real and this is one of the four routes that enforce it; the way past it is to invite another + Guardian and wait for them to accept, or to delete the profile." content: application/json: schema: @@ -12681,7 +12870,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a20 + "403": *a23 "404": description: No such grant held by this account. content: @@ -12689,7 +12878,257 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "409": - description: That access had already ended. + description: "Two refusals share this status. `meta.errorCode: sharing.renounce.already_ended` — that access had already + ended, and nothing changed. `meta.errorCode: managed_profile.guardian.required` — the caller is the last + active Guardian of a managed profile, so handing this access back would strand the record. Invite somebody + and wait for them to accept, or delete the profile; a Guardian may not simply walk away from a record that + has no self." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/managed-profiles: + post: + tags: + - Account sharing + summary: Create a record somebody looks after + description: "The one way a managed profile comes into existence. It has no password, no passkey and no second factor of + its own, and it never gains one: an account with no self is reachable only through its Guardians, which is what + makes the step-up below a boundary rather than a formality. The creator's own Guardian grant is minted in the + same transaction, so the record is switchable immediately and the caller is never left holding a profile nobody + administers. Cookie transport only — `requireFreshMfa` resolves through the session cookie, so a Bearer caller + is refused as unauthenticated; a native client sends the person to a browser rather than surfacing that as an + error." + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateManagedProfileRequest" + responses: + "201": + description: The new record. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileEnvelope" + "401": + description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all + (`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a + second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives + here too, including for a Bearer caller, whose transport this gate does not read." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": + description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none + anywhere in this API; branch on the status. Nothing was created. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/managed-profiles/{id}: + delete: + tags: + - Account sharing + summary: Delete a managed record + description: "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is + why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after + it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second + factor." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The record is gone. + content: + application/json: + schema: + $ref: "#/components/schemas/DeletedManagedProfileEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and + an ordinary record whose owner granted somebody MANAGE is not a managed profile, so it answers here too." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/managed-profiles/{id}/guardians: + get: + tags: + - Account sharing + summary: Who looks after a managed profile + description: "The live Guardians of one managed profile — every unexpired, unrevoked MANAGE grant on it, PENDING and + ACTIVE alike. Cookie transport only, by the same structural argument as the rest of this family: it resolves + through the session and never falls through to the Bearer branch. A fresh second factor is NOT required, unlike + every act on the same profile: this is a read, and it discloses nothing about a party the caller cannot already + read on their own sharing panel. An ACTOR surface — the profile is named in the path and the caller acts as + themselves, so it answers from the Guardian's own account rather than only while switched into the record. + Anyone who is not an active Guardian of THIS profile gets the same 404 as an unknown id, including a READ, WRITE + or MANAGE delegate of an ordinary record: the refusal is not an enumeration oracle. Count the ACTIVE rows to + know whether removing a Guardian is possible — a PENDING invitation does not satisfy the last-Guardian floor." + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: The profile's live Guardians, oldest invitation first. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileGuardianListEnvelope" + "401": *a1 + "404": + description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode: + managed_profile.not_found`). The two are byte-identical." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + post: + tags: + - Account sharing + summary: Invite another Guardian + description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only + and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on + `meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route + answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account + carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A + client branching on the status first would tell somebody to check a spelling when the real answer is that the + record is no longer theirs to administer." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/InviteManagedProfileGuardianRequest" + responses: + "201": + description: The pending Guardian, in the shape the roster publishes. + content: + application/json: + schema: + $ref: "#/components/schemas/ManagedProfileGuardianEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this + instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST + /api/account/grants` makes, and codeless there too, so this family does not answer one way here and another + way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is + not one of its Guardians." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "That account already holds a live grant on this record (`meta.errorCode: + managed_profile.guardian.duplicate`). Inviting YOURSELF lands here rather than on a refusal of its own: a + Guardian grant's grantor is the profile, never the caller, so naming yourself names an account that already + has access — which is both true and the useful sentence." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "Validation failed (the multi-issue envelope; a bare `YYYY-MM-DD` expiry arrives here), or the invitee is + itself a managed profile (`meta.errorCode: managed_profile.guardian.managed_invitee`) — a record with no + self cannot look after another one." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + /api/managed-profiles/{id}/guardians/{grantId}: + delete: + tags: + - Account sharing + summary: End another Guardian's access + description: "Ends one Guardian grant on this profile — a pending invitation or an accepted one. Another person's, never + the caller's own: the service refuses a target grant whose grantee is the caller, and that refusal is the same + 404 an unknown profile gets, so a self-removal control would be a button that always fails with a sentence + describing something else. A Guardian steps away through `POST /api/account/grants/{id}/renounce` instead, which + carries the last-Guardian refusal properly. Count the ACTIVE rows on the roster BEFORE offering this: the floor + is stated ahead of the act rather than discovered as a refusal, and on this route it cannot arrive as one — see + the 409." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: path + name: grantId + schema: + type: string + required: true + responses: + "200": + description: What ended, and when. + content: + application/json: + schema: + $ref: "#/components/schemas/EndedManagedProfileGuardianEnvelope" + "401": + description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such + profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's + OWN. The last is the one to design around — a client must not offer this control on the caller's own row, + because the refusal it earns describes a missing profile rather than what actually happened." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "The record would be left with nobody looking after it (`meta.errorCode: + managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather + than silently: the caller must already be one active Guardian and the target is never the caller, so an + active target is always a second one — and two is never the one the floor refuses. The routes that DO emit + this code are `DELETE /api/account/grants/{id}` (the owner withdrawing the last Guardian grant), `POST + /api/account/grants/{id}/renounce` (a Guardian handing their own access back), Guardian account deletion and + a data wipe — a client resolves the code the same way wherever it arrives, which is why this arm stays. It + is documented here so that a change making it reachable through this route is a change somebody notices." content: application/json: schema: @@ -12705,22 +13144,17 @@ paths: should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from - inside a switched session: this endpoint is the way out." + inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a + session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write + conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409 + `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and + retry once against the reconciled epoch." requestBody: required: true content: application/json: schema: - type: object - properties: - accountId: - anyOf: - - type: string - minLength: 1 - maxLength: 64 - - type: "null" - required: - - accountId + $ref: "#/components/schemas/AccountSwitchRequest" responses: "200": description: The account this session is now acting on, or null. @@ -12735,7 +13169,18 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a20 + "403": *a23 + "409": + description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is + `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — + so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only + request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a + client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a + third source of truth." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": description: Validation failed. content: @@ -15616,6 +16061,162 @@ components: - version description: PUT body for the dashboard layout — the layout fields plus the optional optimistic-concurrency base token (`baseUpdatedAt`). Omit the token for the legacy unconditional write. + AccountGrantInvite: + type: object + properties: + identifier: + type: string + minLength: 1 + maxLength: 255 + expiresAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + access: + default: READ + description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, + observations, a medication, a marked dose) and can still edit or delete nothing, including its own. MANAGE + can additionally change and remove entries, including ones the owner wrote, read the generated insights, and + record the record's own health background (allergies, family history). The boundary is record content versus + account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens, + notification routing, module and threshold configuration, or who else has access. Omitted means READ, so a + client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint + raises a live grant, because that would widen what the delegate accepted without asking them again. The way + up is a new invitation the delegate accepts." + type: string + enum: + - READ + - WRITE + - MANAGE + scope: + description: "The sections of the record this invitation opens. Omitted or null means the entire record, which is what + every grant written before v1.37.0 means and what an owner who does not narrow still gets. A non-null value + is a non-empty array of section keys from the closed vocabulary; an empty array, an unknown key and the + whole-record sentinel `record` are each refused. A stored scope never grows: a section added by a later + release is not in a set an owner ticked before it existed." + anyOf: + - minItems: 1 + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + - type: "null" + required: + - identifier + description: "Offer access to another account on this instance. Every sentence here describes an INVITED grant on this + endpoint, and nothing else: a Guardian of a managed profile also holds MANAGE over a whole record, but that + grant is minted by `POST /api/managed-profiles/{id}/guardians` under its own gate and answers with its own + shape, so none of the rules below — the step-up, the transport, the refusals — may be carried across to it. + `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in + with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ + when omitted, so a client that does not know about the field keeps working: READ can read the record and change + nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and + can still edit or delete nothing, including its own, and MANAGE can additionally change and remove entries, + including ones the owner wrote, read the generated insights, and record the record's own health background + (allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches + login, the second factor, provider connections, API tokens, notification routing, module and threshold + configuration, or who else has access. `scope` narrows a READ or WRITE grant to named sections; omitted or null + is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, + and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. + Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it + cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person + to a browser. The level and the scope are fixed when the invitation is written — no endpoint raises a live + grant, because that would widen what the delegate accepted without asking them again. The way up is a new + invitation the delegate accepts." + CreateManagedProfileRequest: + type: object + properties: + displayName: + type: string + minLength: 1 + maxLength: 80 + dateOfBirth: + anyOf: + - type: string + format: date + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))$ + - type: "null" + locale: + type: string + enum: + - de + - en + - es + - fr + - it + - pl + timezone: + type: string + minLength: 1 + maxLength: 64 + required: + - displayName + - locale + - timezone + additionalProperties: false + description: "Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. + The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new + record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is + switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an + optional `YYYY-MM-DD` and is never synthesised from a year. `locale` and `timezone` belong to the RECORD and not + to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. + Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated + (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent + management relationship, and an account with no second factor enrolled is refused rather than waved through. + Rate-limited to ten an hour per caller." + InviteManagedProfileGuardianRequest: + type: object + properties: + identifier: + type: string + minLength: 1 + maxLength: 255 + expiresAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + required: + - identifier + additionalProperties: false + description: Offer somebody else a share in looking after this profile. `identifier` is their username or e-mail, + matched case-insensitively. `expiresAt` is an ISO instant WITH an offset — a bare `YYYY-MM-DD` is a 422 — or + null for an invitation that runs until somebody ends it. The grant is created PENDING at MANAGE over the whole + record; the invitee accepts it on their own invitations card exactly as they would any other, and until they do + they do not satisfy the last-Guardian floor. Strict, cookie-only, step-up gated and rate-limited to ten an hour, + like every act in this family. + AccountSwitchRequest: + type: object + properties: + accountId: + anyOf: + - type: string + minLength: 1 + maxLength: 64 + - type: "null" + expectedEpoch: + description: "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a + previous switch. Present makes the write conditional: if another tab moved the selector first the switch is + refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me. Omitted keeps the + unconditional write, which is what a client predating the record-session fence sends. Cookie transport + only." + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - accountId + description: "Point this browser session at an account, or (with `accountId: null`) back at its own. Cookie transport only." MedicationTreatmentClass: type: string enum: @@ -36642,10 +37243,15 @@ components: type: string accountAccess: $ref: "#/components/schemas/AccountAccess" + recordSession: + anyOf: + - $ref: "#/components/schemas/RecordSessionState" + - type: "null" required: - id - username - accountAccess + - recordSession additionalProperties: {} description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do. Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this @@ -36657,22 +37263,131 @@ components: accounts: type: array items: - $ref: "#/components/schemas/AccountAccessEntry" - description: Every record this caller may open, newest grant first. + type: object + properties: + accountId: + type: string + minLength: 1 + username: + type: string + minLength: 1 + displayName: + anyOf: + - type: string + - type: "null" + access: + type: string + enum: + - read + - write + level: + type: string + enum: + - read + - write + - manage + recordKind: + type: string + enum: + - shared + - managed + sections: + anyOf: + - type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + - type: "null" + canWrite: + type: boolean + required: + - accountId + - username + - displayName + - access + - level + - recordKind + - sections + - canWrite + additionalProperties: false active: anyOf: - - $ref: "#/components/schemas/AccountAccessEntry" + - type: object + properties: + accountId: + type: string + minLength: 1 + username: + type: string + minLength: 1 + displayName: + anyOf: + - type: string + - type: "null" + access: + type: string + enum: + - read + - write + level: + type: string + enum: + - read + - write + - manage + recordKind: + type: string + enum: + - shared + - managed + sections: + anyOf: + - type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + - type: "null" + canWrite: + type: boolean + required: + - accountId + - username + - displayName + - access + - level + - recordKind + - sections + - canWrite + additionalProperties: false - type: "null" - description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a - banner can name the person without joining two fields. Null when the caller is in their own record, and - ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses - one." + recordKind: + type: string + enum: + - self + - shared + - managed canSwitch: type: boolean - description: Whether there is anywhere to switch to. Bind it directly. required: - accounts - active + - recordKind - canSwitch additionalProperties: false description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server @@ -36680,38 +37395,30 @@ components: be a second program deciding one person's access to a health record, and the two answer differently the first time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list, not a missing field." - AccountAccessEntry: + RecordSessionState: type: object properties: - accountId: - type: string - description: The account whose record it is — the value POST /api/account/switch takes, and the value the - X-HealthLog-Account header carries. - username: - type: string - displayName: + epoch: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to + this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request, + and as `expectedEpoch` when switching. + scope: anyOf: - type: string - type: "null" - access: - type: string - enum: - - read - - write - canWrite: - type: boolean - description: "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It - never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never - derive it." + description: The account this session is pointed at, or null for its own record. Send it back as the + `X-HealthLog-Record-Scope` header, with `self` standing for null. required: - - accountId - - username - - displayName - - access - - canWrite + - epoch + - scope additionalProperties: false - description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL - here would resolve to a refusal. Clients paint their initials fallback." + description: Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which + carries its selector per request and accumulates no session state to be stale about. A client adopts this and + then asserts it on every same-origin request, so a request formed under a context that has since moved is + refused rather than served against the wrong record. RecordActivityEnvelope: type: object properties: @@ -36741,11 +37448,17 @@ components: minimum: -9007199254740991 maximum: 9007199254740991 description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by - default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it - beside the rows. + default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. The query is + bounded to this window, so it is the real limit of the list and not only a caption. Show it beside the rows. + truncated: + type: boolean + description: True when the read reached its row ceiling, so `entries` is the most recent activity inside the window + rather than all of it. A client must say so — a list that stops at its own cap in silence lets the oldest + row read as the beginning. False means the list is everything inside the window. required: - entries - retentionDays + - truncated additionalProperties: false description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention. RecordActivityEntry: @@ -36825,9 +37538,18 @@ components: type: array items: $ref: "#/components/schemas/AccountGrant" + retentionDays: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: How long the record's activity feed can still attribute an entry to the person who made it, in days. + Resolved from the instance's audit retention setting (365 by default, operator-configurable), NOT a + constant. It rides this payload so a client ending an access can state the window in the same breath, + without a second request whose timing would decide whether the owner was told. required: - given - received + - retentionDays additionalProperties: false description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first." @@ -36843,6 +37565,18 @@ components: enum: - READ - WRITE + - MANAGE + scope: + anyOf: + - type: array + items: + $ref: "#/components/schemas/ShareSection" + - type: "null" + description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every + grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A + MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of + a stored scope the server cannot parse — and the server will refuse every section for it, so render it as + nothing rather than as everything. Ordered by the consent screen's own reading order. state: $ref: "#/components/schemas/AccountGrantState" invitedAt: @@ -36874,6 +37608,7 @@ components: - id - account - access + - scope - state - invitedAt - acceptedAt @@ -36886,6 +37621,21 @@ components: accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back. `lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised." + ShareSection: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by + clustering the delegable read surfaces rather than from the module list — the two answer different questions and + never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this + enum: a scope that named the whole record would be a narrowing that means its own absence." AccountGrantState: type: string enum: @@ -36960,6 +37710,18 @@ components: enum: - READ - WRITE + - MANAGE + scope: + anyOf: + - type: array + items: + $ref: "#/components/schemas/ShareSection" + - type: "null" + description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every + grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A + MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of + a stored scope the server cannot parse — and the server will refuse every section for it, so render it as + nothing rather than as everything. Ordered by the consent screen's own reading order. state: $ref: "#/components/schemas/AccountGrantState" invitedAt: @@ -36997,6 +37759,7 @@ components: - id - account - access + - scope - state - invitedAt - acceptedAt @@ -37025,6 +37788,183 @@ components: - data - error additionalProperties: false + ManagedProfileEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfile" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfile: + type: object + properties: + id: + type: string + description: The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector + take, and the `{id}` in this family's paths. + displayName: + type: string + dateOfBirth: + anyOf: + - type: string + - type: "null" + locale: + type: string + timezone: + type: string + recordKind: + type: string + const: managed + description: Always `managed` here. Published so a client can file the new record without re-deriving what it just + created; the same value arrives on the account payload's entry for it. + required: + - id + - displayName + - dateOfBirth + - locale + - timezone + - recordKind + additionalProperties: false + description: "A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint: + the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me, + because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change + here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner + until the next boot." + DeletedManagedProfileEnvelope: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfileGuardianListEnvelope: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/ManagedProfileGuardian" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + ManagedProfileGuardian: + type: object + properties: + grantId: + type: string + description: The grant row. This is the value `DELETE /api/managed-profiles/{id}/guardians/{grantId}` takes. + account: + $ref: "#/components/schemas/AccountGrantParty" + state: + type: string + enum: + - PENDING + - ACTIVE + description: "PENDING means the invitation has been sent and not yet accepted. A PENDING Guardian does NOT count toward + the last-Guardian floor: the server counts accepted, unexpired MANAGE grants, so inviting somebody and then + trying to leave is still refused until they accept. Say so before the act rather than letting it arrive as a + 409." + invitedAt: + type: string + acceptedAt: + anyOf: + - type: string + - type: "null" + required: + - grantId + - account + - state + - invitedAt + - acceptedAt + additionalProperties: false + description: "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than + listed with a state: the question is who looks after this record now. The invitation route answers with this + same shape, so a Guardian is described one way everywhere; it carries no `expiresAt`, because an expiry bounds + one invitation rather than describing who is looking after the record." + ManagedProfileGuardianEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/ManagedProfileGuardian" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EndedManagedProfileGuardianEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/EndedManagedProfileGuardian" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + EndedManagedProfileGuardian: + type: object + properties: + grantId: + type: string + revokedAt: + type: string + required: + - grantId + - revokedAt + additionalProperties: false + description: "What ended, and when. Two fields on purpose: who held it is on the roster the caller re-reads, and the + grant row's remaining columns are storage rather than contract. Nothing is deleted — the row survives as the + consent record with `revokedBy: GRANTOR`, the managed profile being the grantor of every Guardian grant." AccountSwitchEnvelope: type: object properties: @@ -37045,6 +37985,13 @@ components: AccountSwitchState: type: object properties: + recordSession: + anyOf: + - $ref: "#/components/schemas/RecordSessionState" + - type: "null" + description: "The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an + unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the + moment the target equalled the current selector. Adopt this value; do not derive it." actingAs: anyOf: - type: object @@ -37062,6 +38009,7 @@ components: enum: - READ - WRITE + - MANAGE required: - accountId - username @@ -37070,6 +38018,7 @@ components: additionalProperties: false - type: "null" required: + - recordSession - actingAs additionalProperties: false description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and @@ -37510,6 +38459,34 @@ components: sync engine, device or notification registration, or any auth/refresh call: those are about the person holding the token, not the record being read, and the account payload refuses the header outright for exactly that reason." + RecordEpoch: + name: X-HealthLog-Record-Epoch + in: header + required: false + schema: + type: string + maxLength: 15 + description: "The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET + /api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it. + Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been + pointed at another record is served without either header, which is what keeps a tab open across the deploy + working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a + mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry), + and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers + by leaving the record and reloading)." + RecordScope: + name: X-HealthLog-Record-Scope + in: header + required: false + schema: + type: string + maxLength: 64 + description: The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self` + standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one + without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are + echoed back on any response that actually resolved a record scope, and a client discards a response whose echo + contradicts the context it is now on — a response carrying no echo resolved no record scope and is served + normally. securitySchemes: bearerAuth: type: http diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md index 46222f719..abd6a128a 100644 --- a/docs/self-hosting/account-sharing.md +++ b/docs/self-hosting/account-sharing.md @@ -5,8 +5,12 @@ can open the other's health record. A parent and an adult child, two partners, somebody keeping an eye on a relative's blood pressure — the case this exists for is a household, not an organisation. -This page describes what that connection does, what it deliberately does -not do, and which of the instance's existing trust properties it leaves +Since v1.37.0 there is a second arrangement beside it: a **managed +profile**, a health record for somebody who has no account at all. That +half is described further down, under "Records with no login". + +This page describes what these arrangements do, what they deliberately do +not do, and which of the instance's existing trust properties they leave exactly as they were. ## The shape of it @@ -25,7 +29,7 @@ the settings copy these are the owner and the person they invited. - Once accepted, the record shows up in that person's user menu under "Open a record". Opening it reloads the app into the owner's record. - A banner across the top of every page names whose record is open and - says it is read-only, with a button back to the person's own. It is + what the access admits, with a button back to the person's own. It is loud on purpose: somebody who forgets they are switched will log their own reading into another person's record, and there is no undo for an entry that was never theirs. @@ -36,11 +40,61 @@ the settings copy these are the owner and the person they invited. ## What the other person can and cannot do -**Read the health record.** Measurements, medications, mood, labs, the -illness journal, preventive-care reminders, documents. In this release -that is the whole of it: every grant is read-only. The write level exists -in the data model so that adding it later is enforcement work rather than -a migration, and no invitation can create one today. +An invitation carries two answers: how much of the record it opens, and +what may be done inside it. + +### How much of the record + +Either the entire record, which is what sharing has always meant and what +every grant written before v1.37.0 carries, or a set of named sections +chosen when the invitation is written: + +| Section | What it covers | +| ----------------- | -------------------------------------------------------------------------- | +| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics | +| Medications | The medication list, its schedules, doses taken, side effects and stock | +| Lab results | Lab results and the analytes behind them | +| Health background | Allergies, family history and the facts in the health profile | +| Illness | Illness episodes and the day logs kept under them | +| Mood and mind | Mood entries and the mental-health questionnaire history | +| Cycle | Cycle tracking | +| Documents | Everything in the document vault | + +A section that was not shared is refused exactly the way a record nobody +shared is, so the shape of what was held back is not readable from +outside. Sections are route families and nothing finer: there is no row +filtering, no field filtering and no redaction inside a section that WAS +shared. The invitation screen says this in as many words, because a note +or a document name inside one section can be about any other. + +Aggregate surfaces (the dashboard overview, the health score, the daily +digest) appear only on a whole-record grant. A figure derived from part of +a record reads as a figure about the person, and it would not be one. + +A stored scope never grows. A section added by a later release is not in +a set somebody ticked before it existed. + +### What may be done inside it + +Three levels, fixed when the invitation is written. Raising one means +revoking and inviting again, because widening a grant in place would carry +a consent the other person never gave. + +| Level | Adds | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Read | Read what the grant opens, and change nothing. | +| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. | +| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | + +Offering **manage** asks the owner for a fresh second factor if they have +one enrolled, which makes it a browser-only act either way: a Bearer +caller is refused before anything else happens and told to use a browser. +Accepting it has no such restriction. Manage never carries a section +scope, because "they can do anything, but only to part of you" promises a +boundary an edit cannot keep. + +Everything a manager does is written to the trail under their own name, +with a verb naming the act rather than a generic "made a change". **Nothing about the account around the record.** This is the line the whole feature is built on, and it is drawn on the server: @@ -53,10 +107,16 @@ whole feature is built on, and it is drawn on the server: | Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record | | Notification channels and push devices | The owner's health alerts redirected to another phone or chat | | Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | -| Insights and the Coach | AI processing of the owner's data, under the owner's own consent | +| Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent | +| Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record | | Sharing itself | Inviting somebody else, widening the access, or passing it on | | Locale, theme, dashboard layout | Presentation belongs to the person, not to the record | +This table is about a grant between two accounts. A managed profile is +the one place where some of it is deliberately different, because a record +with no login has nobody else to keep its own configuration: see "Records +with no login" below for exactly which part of it a guardian may reach. + The last two rows are worth a sentence each. Sharing is not transferable: somebody given access cannot invite a third person, cannot raise their own level, and cannot hand it on. And display preferences @@ -66,8 +126,11 @@ does not change your own theme, language or dashboard. The AI row is the one that looks arbitrary and is not. Server-managed AI features send the record's data to a provider under a consent the record's OWNER gave, for their own use. Somebody else triggering that would create -a consent-shaped act the owner never made, so those surfaces stay -owner-only. +a consent-shaped act the owner never made. So a manage grant can READ +insights that were already generated, and nothing a delegate does at any +level causes an outbound provider call or queues a generation job. The +owner's own path is unchanged and is the control that proves the +difference. These are not checks that a page forgot to render. Every route in the product refuses by default while a browser is acting on another account; @@ -94,6 +157,62 @@ dates and with who ended it, because "who had access, from when to when, and who ended it" is a question a deleted row cannot answer. Re-inviting the same person creates a new row beside the old one. +## Records with no login + +A **managed profile** is a health record for somebody who does not sign +in: a child, or an adult who is cared for. It is a real account row with +no password, no passkey and no e-mail address, and it is never presented +as something to log in as. + +- It is created from **Settings → Shared access**, under a fresh second + factor. An account with no second factor enrolled is refused rather than + waved through, because the act creates a permanent management + relationship over a person who cannot object to it. +- Creation takes a display name, optionally a real date of birth, and the + language and timezone the record's own days and reminders are measured + in. A year on its own is never turned into the first of January. The + language belongs to the record, not to the guardian reading it, so the + guardian's own screens stay in their own language. +- The creator becomes the first **guardian** in the same transaction, over + an ordinary accepted MANAGE grant with the profile as grantor. If either + half fails, neither happens: there is no window in which the profile + exists with nobody looking after it. +- A second guardian is invited and accepts exactly like any other + invitation. An accepted guardian grant does not expire; the invitation + token still can. +- **A managed profile can never be left guardian-less.** Revoking, + handing back, deleting a guardian's own account and deleting the + profile all take the same lock on the profile and re-check the count + inside it, so two people cannot both leave at once. The last guardian is + refused, with a message naming the two ways out: add another guardian, + or delete the profile under a fresh second factor. Deleting the profile + deletes everything recorded for it. +- A guardian may keep that record's settings: modules, units, thresholds, + its language and timezone, and its notification preferences. Connected + services are visible as a status only. Connect, callback, sync, + credential, disconnect, resume and test are not guardian surfaces in + this release, and their deep links land on the shared-record unavailable + page rather than on a raw refusal. +- **Its reminders go to its guardians.** A record with no login has no + channels of its own, so medication reminders, measurement reminders, + safety-floor alerts and low-stock alerts fan out to every active + guardian, in that guardian's own language and over the channels that + guardian already configured. Nothing else fans out; every other event + stays on the path it was already on. The payloads carry no take or skip + action, on any channel, so a notification about somebody else's record + cannot be acted on from a lock screen. One guardian suppressing + server-side reminders on their own device does not suppress anybody + else's. +- Delivery attempts record both the record the message is about and the + person it was sent to, so the admin notification diagnostic can answer + "who was this for" and "whose record was it about" separately. + +There is no emancipation flow: no route, no control and no copy suggests +a managed profile can be turned into an ordinary account. The data model +leaves room for it (the credentials are nullable and the managed marker +can be cleared) and clearing that marker stops the notification fan-out, +but nothing in v1.37.0 exercises any of that. + ## Seeing what happened **Settings → Shared access** answers three questions for the owner, at @@ -104,9 +223,21 @@ Below that, a list of the days somebody else opened it and how many times That list is bounded by the instance's audit retention, and the page says so with the actual number rather than a hardcoded one. The default is 365 days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens -this view too, and the sentence under the heading changes to match. An -empty list means nobody opened the record within that window — not that -nobody ever did. +this view too, and the sentence under the heading changes to match. The +query is bounded to that window, so the sentence is the real limit of the +list rather than a caption over an unbounded one. + +Two things the list does not claim, both worth reading before treating it +as an answer: + +- **An empty list is not proof that nobody opened the record.** It means + nobody did so within the window, and that any earlier row has been + purged. +- **The list is capped.** It returns at most the hundred most recent rows + inside the window; when it hits that ceiling it says so under the + heading. A busy household can reach the cap in far less time than the + retention window, and the oldest line on screen is then the oldest line + shown, not the oldest that exists. ## What this does not change about the instance @@ -130,8 +261,9 @@ re-reads the trust model: Nothing to configure. Sharing is available on every instance, needs no environment variable, and does nothing until one account invites another. +The same is true of managed profiles. -Two operational notes: +Four operational notes: - **Invitations disclose whether a username exists.** An invitation to a name nobody uses is refused with "no account with that name". That is @@ -143,4 +275,20 @@ Two operational notes: rows naming that account as the one who opened somebody's record stay, and render as "a deleted account" — nulling them would turn "your daughter opened your record" into "you opened your record", which is - worse than an unresolvable id. + worse than an unresolvable id. Deleting the last guardian's account is + refused while the managed profile would survive it, so an account + deletion cannot strand a record. +- **A managed profile is a user row and appears as one.** It is listed in + the admin user list like any other account, with no e-mail address + beside it and an internal username nobody is ever asked to type. No + login path will accept it: there is no password hash to verify against + and no address to look it up by. +- **The second-factor requirement is not the same on both features, and + the difference is deliberate.** Creating a managed profile, deleting + one and inviting a second guardian require a second factor + unconditionally: an account with none enrolled is refused and told to + set one up, because those acts create or end a permanent relationship + over somebody who cannot object. Offering manage access on an ordinary + invitation asks for a fresh factor only from an account that has one, so + an instance where nobody has enrolled a factor can still use every level + of ordinary sharing. All of them are browser-only either way. diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index b7e0a7984..0046c15d5 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -203,10 +203,19 @@ test.describe("account sharing", () => { // date mapping, and BOTH still pass with the card wired back to `null`. // Only reading the posted body proves the value a person typed is the // value the server receives, which is the half that was missing. - const lapse = ownerPage.locator('[data-slot="grant-invite-expires"]'); + // The VISIBLE overlay, not the hidden ISO mirror: `data-slot` rides + // `{...rest}` onto `DateField`'s hidden input, so the old selector resolved + // to an element that can never be visible and typed into nothing. + const lapse = ownerPage.getByTestId("grant-invite-expires-input"); await expect(lapse).toBeVisible(); const chosenDay = isoDayFromNow(30); await lapse.fill(chosenDay); + // The overlay commits on blur; without it the ISO text sits in local state + // and the hidden mirror still holds the old value when the form submits. + await lapse.blur(); + await expect( + ownerPage.locator('input[data-slot="grant-invite-expires"]'), + ).toHaveValue(chosenDay); // The level, through the real control and onto the real wire. // diff --git a/e2e/chrome-header-seam-banners.spec.ts b/e2e/chrome-header-seam-banners.spec.ts index a40089772..87e470274 100644 --- a/e2e/chrome-header-seam-banners.spec.ts +++ b/e2e/chrome-header-seam-banners.spec.ts @@ -2,7 +2,7 @@ import type { Page } from "@playwright/test"; import { expect, test } from "./setup/test"; -import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { SEAM_BANNERS_STORAGE_STATE_PATH } from "./setup/global-setup"; /** * The seam again, this time with chrome above it. @@ -33,18 +33,24 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * alive (`about:blank`): `I18nProvider` rewrites `healthlog-locale` from * its own active locale on mount, so a cookie written under a live English * page is reverted within the second. - * - **Shared record** paints from `accountAccess.active` on `/api/auth/me`. - * The real payload is captured first and served back with that block - * filled, rather than performing a real switch: the switch is stamped on - * the SESSION ROW, so switching the shared cookie jar would switch it for - * every other spec holding the same cookie (see - * `DELEGATE_STORAGE_STATE_PATH` in `global-setup.ts` for what that costs). - * A captured-and-patched payload keeps this file's reach inside this file, - * and it keeps working after the offline stage because it never touches the - * network again. + * - **Shared record** paints while this session is inside somebody else's + * record, so this file switches into one. It used to fake the state instead + * — capture `/api/auth/me`, serve it back with the sharing block filled — + * because the switch is stamped on the SESSION ROW and moving the shared + * jar's row would move it for every spec holding that cookie. v1.37.0 took + * that shortcut away and gave back the thing that made it necessary: the + * record context is now proved across the payload, the fence store and the + * transition machine together, so a payload alone cannot produce it; and a + * spec that switches gets a login of its own + * (`SEAM_BANNERS_STORAGE_STATE_PATH`), which is what keeps this file's reach + * inside this file. The grant is the seeded whole-record READ on + * `e2e-level-read` — whole-record deliberately, because a narrowed one drops + * nav destinations and that is a different spec's subject. * - **Offline** paints while `navigator.onLine` is false, which * `context.setOffline(true)` produces for real, event and all. It is armed - * last because it is the only stage a navigation cannot follow. + * last because it is the only stage a navigation cannot follow, and it is + * armed after the switch for the same reason: entering a record is a + * network act. * * The demo banner is the one that cannot be forced from a spec: `DEMO_MODE` is * a server env var read by the root layout, and the suite shares one server @@ -65,9 +71,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup"; * `h-dvh` budget, not added on top of it, so no depth of stack introduces * a page-level scrollbar. `
` remains the only vertical scroller. * - * Property 2 runs on mobile too, where there is no sidebar and therefore no - * seam, because the scroll model is the half of the layout that the hoist could - * have broken at that breakpoint. + * Property 2 is measured below `md` as well as above it, where there is no + * sidebar and therefore no seam, because the scroll model is the half of the + * layout that the hoist could have broken at that breakpoint. It used to get + * there by running the file in the `chromium-mobile` project. It cannot any + * more: this file moves a session's record selector, and a second PROJECT is + * the one thing a separate cookie jar cannot fix — it would drive the same + * session row from two browsers at once. So the narrow viewport is measured + * inside the desktop project instead. Nothing is lost by that: the shell picks + * its column layout and its scroller from width-driven rules, and the property + * under test is a scroll height in CSS pixels. */ const LOCALE_COOKIE = "healthlog-locale"; @@ -130,46 +143,81 @@ async function readShell(page: Page): Promise { ); } +/** The seeded whole-record READ grant this file switches into. */ +const TARGET_USERNAME = "e2e-level-read"; + +const sharedRecordBanner = (page: Page) => + page.locator('[data-slot="shared-record-banner"]'); + /** - * The live `/api/auth/me` body, read through the page so it carries the page's - * own cookies. Captured before anything else is armed, so the mock that later - * replaces it differs from the real payload in exactly one block. + * Wait until the shell is usable rather than merely present. + * + * The hydration gate is the shell's own hold while it settles which record it + * is showing. Measuring geometry through it would measure the placeholder. The + * budget is deliberately larger than the suite default: ten seconds is a + * statement about machine load, thirty about the gate never releasing. + * + * The paired positive control is the top bar and not an accessible name: half + * of this file runs under the French strip, and a helper that reached for an + * English label would be a helper that only works before the locale swap. */ -async function captureAccountPayload(page: Page): Promise { - const raw = await page.evaluate(async () => { - const res = await fetch("/api/auth/me", { credentials: "same-origin" }); - return res.ok ? await res.text() : null; +async function expectShellReady(page: Page): Promise { + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.locator('[data-slot="top-bar"]')).toBeVisible({ + timeout: 30_000, }); - expect( - raw, - "could not capture /api/auth/me for the sharing mock", - ).not.toBeNull(); - return raw!; } /** - * Serve the captured payload back with the sharing block filled in, so the - * shell believes this session is inside somebody else's record. Everything else - * the shell reads (modules, role, onboarding flags) stays what the server said. + * Start outside any record. + * + * The first stage asserts that nothing is painted above the top bar, and this + * jar's session row keeps whatever the previous test — or the previous attempt, + * on a CI retry — left it pointed at. Without this the "no banner" stage is a + * statement about run order. */ -async function armSharedRecord(page: Page, captured: string): Promise { - const payload = JSON.parse(captured) as { data: Record }; - const entry = { - accountId: "seam-fixture-account", - username: "seam-owner", - displayName: null, - access: "read" as const, - canWrite: false, - }; - payload.data.accountAccess = { - accounts: [entry], - active: entry, - canSwitch: true, - }; - const body = JSON.stringify(payload); - await page.route("**/api/auth/me", (route) => - route.fulfill({ status: 200, contentType: "application/json", body }), - ); +async function ensureOwnRecord(page: Page): Promise { + const banner = sharedRecordBanner(page); + if ((await banner.count()) === 0) return; + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(banner).toHaveCount(0, { timeout: 30_000 }); + await expectShellReady(page); +} + +/** + * Switch into the target record, and re-press the button if the switch did not + * land. + * + * `POST /api/account/switch` is a compare-and-set on the session's record epoch + * and the client reconciles and retries exactly once, which is the right call + * for a person at a keyboard and one attempt short for a fixture that has just + * navigated. Bounded at three and asserted: if the record is not entered by + * then, that is a failure and it fails. + */ +async function enterSharedRecord(page: Page): Promise { + const banner = sharedRecordBanner(page); + if ((await banner.count()) > 0) return; + for (let attempt = 0; attempt < 3; attempt++) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`, + ); + await expect(entry).toBeVisible(); + await entry.click(); + try { + await expect(banner).toBeVisible({ timeout: 15_000 }); + await expectShellReady(page); + return; + } catch { + // The switcher menu may still be open over the page; start clean. + await page.goto("/"); + await expectShellReady(page); + } + } + await expect(banner).toBeVisible({ timeout: 15_000 }); } /** @@ -225,21 +273,22 @@ async function armOffline(page: Page): Promise { await page.evaluate(() => window.dispatchEvent(new Event("offline"))); } -test.describe("app-chrome header seam under banners", () => { - test.use({ storageState: STORAGE_STATE_PATH }); +/** + * Serial, and for the same reason the file has its own jar: both tests move + * this session row's record selector, and two workers running them at once + * would move it under each other. + */ +test.describe.serial("app-chrome header seam under banners", () => { + test.use({ storageState: SEAM_BANNERS_STORAGE_STATE_PATH }); test("the header seam survives every depth of the banner stack", async ({ page, }) => { - test.skip( - test.info().project.name === "chromium-mobile", - "the sidebar is desktop-only (md+), so below md there is no seam to measure", - ); - // Four stages measured at two widths, with a full page load per stage and - // a network settle either side of the offline one. Comfortably inside the - // suite default when it runs alone; the default 30s is not enough headroom - // for it on a loaded machine. - test.setTimeout(60_000); + // Four stages measured at two widths, with a full page load per stage, a + // real record switch and a network settle either side of the offline one. + // Comfortably inside the suite default when it runs alone; the default 30s + // is not enough headroom for it on a loaded machine. + test.setTimeout(90_000); await page.goto("/", { waitUntil: "domcontentloaded" }); @@ -247,8 +296,8 @@ test.describe("app-chrome header seam under banners", () => { const sidebarHeader = page.locator('[data-slot="sidebar-header"]'); await expect(topBar).toBeVisible(); await expect(sidebarHeader).toBeVisible(); - - const captured = await captureAccountPayload(page); + await expectShellReady(page); + await ensureOwnRecord(page); // Each stage adds one banner and leaves the previous ones up, so the last // stage measures the whole stack rather than one banner at a time. @@ -266,26 +315,30 @@ test.describe("app-chrome header seam under banners", () => { beforeResize?: () => Promise; afterResize?: () => Promise; }; + // The shared record is armed FIRST, before the locale swap: entering a + // record is done through the switcher, and the switcher's controls are + // reached by their accessible names. Under the French strip those names are + // French, and a spec that drove the UI in one language while asserting the + // labels of another would be testing the translation bundle. const stages: Stage[] = [ { label: "no banner", arm: async () => {} }, { - label: "1 banner (maintainership)", + label: "1 banner (shared record)", arm: async () => { - await armUnmaintainedLocale(page); - await page.goto("/", { waitUntil: "domcontentloaded" }); - await expect( - page.locator('[data-slot="maintainership-banner"]'), - ).toBeVisible(); + await enterSharedRecord(page); + await expect(sharedRecordBanner(page)).toBeVisible(); }, }, { - label: "2 banners (+ shared record)", + label: "2 banners (+ maintainership)", arm: async () => { - await armSharedRecord(page, captured); - await page.reload({ waitUntil: "domcontentloaded" }); + await armUnmaintainedLocale(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expectShellReady(page); await expect( - page.locator('[data-slot="shared-record-banner"]'), + page.locator('[data-slot="maintainership-banner"]'), ).toBeVisible(); + await expect(sharedRecordBanner(page)).toBeVisible(); }, }, { @@ -380,43 +433,64 @@ test.describe("app-chrome header seam under banners", () => { test("the banner stack never pushes the shell past the viewport", async ({ page, }) => { - // Mobile as well as desktop. Below `md` the shell stacks in a column and - // there is no sidebar, so there is no seam to measure — but the scroll - // model is what hoisting the banners could have broken there, and that - // half is measurable at both breakpoints. + // A real switch, a page load either side of it and a network settle before + // the offline stage, at two widths. + test.setTimeout(90_000); + await page.goto("/", { waitUntil: "domcontentloaded" }); await expect(page.locator('[data-slot="top-bar"]')).toBeVisible(); + await expectShellReady(page); - const captured = await captureAccountPayload(page); - await armSharedRecord(page, captured); + await enterSharedRecord(page); await armUnmaintainedLocale(page); await page.goto("/", { waitUntil: "domcontentloaded" }); + await expectShellReady(page); await expect( page.locator('[data-slot="maintainership-banner"]'), ).toBeVisible(); - await expect( - page.locator('[data-slot="shared-record-banner"]'), - ).toBeVisible(); + await expect(sharedRecordBanner(page)).toBeVisible(); await armOffline(page); await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); - const shell = await readShell(page); - expect(shell.bannerCount, "the stack did not arm").toBe(3); - expect(shell.bannerHeight).toBeGreaterThan(0); - expect( - shell.documentOverflow, - `the document scrolls by ${shell.documentOverflow.toFixed( - 2, - )}px under a ${shell.bannerHeight.toFixed(2)}px banner stack`, - ).toBeLessThan(1); + // 393px is the narrow end — a Pixel-5 viewport, below `md`, where the shell + // stacks in a column and there is no sidebar. 1280px is the wide end. The + // network comes back for the resize and the stage re-arms after it, for the + // reason written at the offline stage of the test above: a chunk fetch that + // fails with no network takes the whole shell down to the error boundary. + for (const [width, height] of [ + [393, 851], + [1280, 900], + ] as const) { + await page.context().setOffline(false); + await expect.poll(() => page.evaluate(() => navigator.onLine)).toBe(true); + await page.setViewportSize({ width, height }); + await expect + .poll(() => page.evaluate(() => window.innerWidth)) + .toBe(width); + await armOffline(page); + await expect(page.locator('[data-slot="offline-banner"]')).toBeVisible(); - // The top bar is still fully painted below the stack rather than pushed - // off the bottom of the viewport. - const top = shell.topBar!; - expect(top.height).toBeGreaterThan(32); - expect( - top.bottom, - "the banner stack pushed the top bar out of the viewport", - ).toBeLessThanOrEqual(shell.viewportHeight); + const shell = await readShell(page); + const at = `at ${width}px`; + expect(shell.bannerCount, `${at}: the stack did not arm`).toBe(3); + expect(shell.bannerHeight).toBeGreaterThan(0); + expect( + shell.documentOverflow, + `${at}: the document scrolls by ${shell.documentOverflow.toFixed( + 2, + )}px under a ${shell.bannerHeight.toFixed(2)}px banner stack`, + ).toBeLessThan(1); + + // The top bar is still fully painted below the stack rather than pushed + // off the bottom of the viewport. + const top = shell.topBar!; + expect(top.height, `${at}: the top bar paints no height`).toBeGreaterThan( + 32, + ); + expect( + top.bottom, + `${at}: the banner stack pushed the top bar out of the viewport`, + ).toBeLessThanOrEqual(shell.viewportHeight); + } }); }); diff --git a/e2e/documents.spec.ts b/e2e/documents.spec.ts index 0232fd2fc..96d537e0d 100644 --- a/e2e/documents.spec.ts +++ b/e2e/documents.spec.ts @@ -235,6 +235,19 @@ test.describe("document vault", () => { await expect(openButton(page, "MRT Knie")).toBeVisible(); await openButton(page, "MRT Knie").click(); const detail = page.getByRole("dialog", { name: "MRT Knie" }); + // The sheet is CLOSED is a different claim from the sheet is showing this + // document, and only one of them can be made by a name-matched locator. + // + // The sheet takes its accessible name from the document title and falls + // back to "Untitled document" while the detail read is pending, so + // `expect(detail).not.toBeVisible()` also passes on an OPEN sheet that has + // gone back to its skeleton — measured, not theorised: a slow detail read + // reports the named dialog invisible and the unnamed one visible at the + // same instant. That resolves immediately instead of waiting, which is the + // wait this test needs after a history traversal, and the assertion that + // depended on it then reads a URL the app has not finished with. The + // deep-link test above already uses the unnamed role for this reason. + const sheet = page.getByRole("dialog"); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) @@ -246,15 +259,18 @@ test.describe("document vault", () => { expect(new URL(page.url()).searchParams.get("q")).toBe("MRT"); await page.goBack(); - await expect(detail).not.toBeVisible(); + await expect(sheet).not.toBeVisible(); expect(new URL(page.url()).searchParams.get("doc")).toBeNull(); expect(new URL(page.url()).searchParams.get("view")).toBe("compact"); await page.goForward(); + // Both: the sheet is open AND it is this document, so Escape is pressed on + // a settled sheet rather than on one still resolving its title. + await expect(sheet).toBeVisible(); await expect(detail).toBeVisible(); await page.keyboard.press("Escape"); - await expect(detail).not.toBeVisible(); + await expect(sheet).not.toBeVisible(); await expect .poll(() => new URL(page.url()).searchParams.get("doc")) .toBeNull(); diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 80d3765d3..981a55c33 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -65,6 +65,83 @@ export const E2E_OWNER = { role: "USER", } as const; +/** + * v1.37.0 — the account that creates and administers managed profiles. + * + * Its own account, and not one of the others, for one reason: every route in + * the managed-profile family resolves `requireFreshMfa`, which refuses an + * account with NO second factor enrolled (`auth.stepup.mfa_not_enrolled`). So + * this account carries a confirmed TOTP secret, and an account with a confirmed + * secret cannot be logged in by the password-only capture below. The setup + * therefore logs it in FIRST and stamps the factor afterwards — see the + * enrolment step at the end of `globalSetup`. + * + * Enrolling the factor on a shared account would have changed behaviour for + * every spec that touches a step-up-gated surface, starting with the MANAGE + * invitation on the sharing panel. + */ +export const E2E_GUARDIAN = { + email: "e2e-guardian@healthlog.test", + username: "e2e-guardian", + password: "Rt5!Nm8xQ3wZ6bJp", + role: "USER", +} as const; + +/** A separate delegate session for the eight scoped-record browser journeys. */ +export const E2E_SCOPE_DELEGATE = { + email: "e2e-scope-delegate@healthlog.test", + username: "e2e-scope-delegate", + password: "Qz8!Vp4rL2nX7mKs", + role: "USER", +} as const; + +/** One non-personal target record per closed sharing domain. */ +export const E2E_SCOPE_RECORDS = [ + { + domain: "measurements", + username: "e2e-scope-measurements", + href: "/measurements", + }, + { + domain: "medications", + username: "e2e-scope-medications", + href: "/medications", + }, + { domain: "labs", username: "e2e-scope-labs", href: "/labs" }, + { domain: "profile", username: "e2e-scope-profile", href: "/profile" }, + { domain: "illness", username: "e2e-scope-illness", href: "/illness" }, + { domain: "mind", username: "e2e-scope-mind", href: "/mood" }, + { domain: "cycle", username: "e2e-scope-cycle", href: "/cycle" }, + { domain: "documents", username: "e2e-scope-documents", href: "/documents" }, +] as const; + +/** + * Whole-record grants used to prove the three adult access levels and the + * separate managed-profile boundary in a real browser session. + */ +export const E2E_LEVEL_RECORDS = [ + { + username: "e2e-level-read", + access: "READ", + recordKind: "shared", + }, + { + username: "e2e-level-write", + access: "WRITE", + recordKind: "shared", + }, + { + username: "e2e-level-manage", + access: "MANAGE", + recordKind: "shared", + }, + { + username: "e2e-level-managed", + access: "MANAGE", + recordKind: "managed", + }, +] as const; + /** * Weights seeded one per account, chosen so neither can be mistaken for the * other in rendered markup and neither collides with a value any other spec @@ -105,6 +182,80 @@ export const DELEGATE_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateDelegate.json", ); +export const SCOPE_DELEGATE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeDelegate.json", +); + +export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeA11y.json", +); + +/** + * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at + * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as + * well. + * + * The record-session fence made the cost visible. Four specs moved the same + * session row's record selector — the two fence specs, the cross-tab journey + * and the scoped-sharing journey — and the fence's whole subject is that row's + * context and its monotonic epoch. On one worker they never overlapped; on two + * they moved the epoch under each other, and the failure was the harness racing + * itself. Passing only at `--workers=1` is not a passing suite: CI runs two. + * + * A separate LOGIN, not a separate account: the selector lives on the session + * row, so two jars for one account are two independent contexts. The grants, + * the fixtures and the assertions all stay exactly as they were. + */ +export const FENCE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateFence.json", +); + +export const FENCE_OFFLINE_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateFenceOffline.json", +); + +/** + * The managed-profile journey's own jar. Its own session row, like every other + * switching spec, so a run at CI's worker count cannot have two specs moving + * the same session's record selector. + */ +export const GUARDIAN_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateGuardian.json", +); + +export const CROSS_TAB_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateCrossTab.json", +); + +/** + * The banner-geometry spec's jar, and the reason it exists is a release-shaped + * one rather than a load-shaped one. + * + * `chrome-header-seam-banners.spec.ts` needs a session that is INSIDE somebody + * else's record, because one of the strips it stacks only paints there. It used + * to produce that state by capturing `/api/auth/me` and serving it back with + * the sharing block filled — cheaper than a switch, and self-contained, which is + * exactly why it was written that way. + * + * v1.37.0 ended that. The record context is now proved across three places at + * once — the payload's own validator (fail-closed, no partial credit), the fence + * store's scope, and the transition machine that has to agree with both — so a + * payload claiming a shared record while the session row still says `self` is a + * state the shell refuses on purpose. A single route mock can no longer make it, + * and the honest fix is to stop faking it: switch for real, and take a jar of + * its own so moving one session row cannot move anybody else's. + */ +export const SEAM_BANNERS_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateSeamBanners.json", +); + async function hashPassword(password: string): Promise { return hash(password, { memoryCost: 19456, @@ -201,6 +352,202 @@ export default async function globalSetup(config: FullConfig): Promise { ], ); + // v1.37.0 — the managed-profile guardian. Seeded exactly like the other + // fixture accounts; its second factor is stamped AFTER the login capture, + // because the password-only capture cannot complete a TOTP login. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + totp_confirmed_at = NULL`, + [ + cuid(), + E2E_GUARDIAN.username, + E2E_GUARDIAN.email, + await hashPassword(E2E_GUARDIAN.password), + now, + ], + ); + + // Whatever a previous run created. A managed profile is a real account row, + // so a suite that left one behind would find the guardian looking after two + // records on the next run and the roster assertions would read the wrong + // one. Deleting the profile takes its grants with it. + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [E2E_GUARDIAN.username], + ); + + // The scoped-record journey uses a dedicated delegate so its preseeded + // grants cannot affect the invitation lifecycle exercised by the sharing + // journey above. Its own module preferences are explicitly off: a target + // record's resolved scope, rather than the actor's preferences, must keep + // each granted doorway visible. + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, $4, 'USER', $5, $5, $5, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = EXCLUDED.password_hash, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + E2E_SCOPE_DELEGATE.username, + E2E_SCOPE_DELEGATE.email, + await hashPassword(E2E_SCOPE_DELEGATE.password), + now, + JSON.stringify({ + medications: false, + labs: false, + illness: false, + mood: false, + mentalHealth: false, + inboundDocuments: false, + }), + ], + ); + + await pool.query( + `DELETE FROM account_grants + WHERE grantee_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_SCOPE_DELEGATE.username], + ); + await pool.query( + `UPDATE sessions SET acting_as_user_id = NULL + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_SCOPE_DELEGATE.username], + ); + + for (const record of E2E_SCOPE_RECORDS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + onboarding_completed_at, onboarding_tour_completed, + module_preferences_json) + VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $4, true, $5::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + updated_at = EXCLUDED.updated_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + record.username, + `${record.username}@healthlog.test`, + now, + JSON.stringify({ + medications: true, + labs: true, + illness: true, + mood: true, + mentalHealth: true, + inboundDocuments: true, + }), + ], + ); + + await pool.query( + `INSERT INTO account_grants + (id, grantor_id, grantee_id, access, scope_json, invited_at, + accepted_at, created_at) + SELECT $1, owner.id, delegate.id, 'READ', $2::jsonb, $3, $3, $3 + FROM users owner, users delegate + WHERE owner.username = $4 AND delegate.username = $5`, + [ + cuid(), + JSON.stringify([record.domain]), + now, + record.username, + E2E_SCOPE_DELEGATE.username, + ], + ); + } + + // Whole-record fixtures intentionally sit beside the narrow records + // above. They make the browser prove the presentation and mutation + // boundaries of READ, WRITE, MANAGE, and a guardian-managed record + // without changing the invitation lifecycle fixture below. + for (const record of E2E_LEVEL_RECORDS) { + await pool.query( + `INSERT INTO users + (id, username, email, password_hash, role, created_at, updated_at, + managed_profile_at, onboarding_completed_at, + onboarding_tour_completed, module_preferences_json) + VALUES ($1, $2, $3, NULL, 'USER', $4, $4, $5, $4, true, $6::jsonb) + ON CONFLICT (username) DO UPDATE SET + email = EXCLUDED.email, + password_hash = NULL, + updated_at = EXCLUDED.updated_at, + managed_profile_at = EXCLUDED.managed_profile_at, + onboarding_completed_at = EXCLUDED.onboarding_completed_at, + onboarding_tour_completed = EXCLUDED.onboarding_tour_completed, + module_preferences_json = EXCLUDED.module_preferences_json`, + [ + cuid(), + record.username, + `${record.username}@healthlog.test`, + now, + record.recordKind === "managed" ? now : null, + JSON.stringify({ + medications: true, + labs: true, + illness: true, + mood: true, + mentalHealth: true, + inboundDocuments: true, + }), + ], + ); + + await pool.query( + `INSERT INTO account_grants + (id, grantor_id, grantee_id, access, scope_json, invited_at, + accepted_at, created_at) + SELECT $1, owner.id, delegate.id, $2, NULL, $3, $3, $3 + FROM users owner, users delegate + WHERE owner.username = $4 AND delegate.username = $5`, + [ + cuid(), + record.access, + now, + record.username, + E2E_SCOPE_DELEGATE.username, + ], + ); + } + + // Cycle is delegated to its profile gate rather than module preferences. + await pool.query( + `INSERT INTO cycle_profiles + (id, user_id, cycle_tracking_enabled, created_at, updated_at) + SELECT $1, u.id, true, $2, $2 + FROM users u WHERE u.username = $3 + ON CONFLICT (user_id) DO UPDATE SET + cycle_tracking_enabled = true, + updated_at = EXCLUDED.updated_at`, + [cuid(), now, "e2e-scope-cycle"], + ); + // Repeated local E2E runs reuse the same seeded account and database. // Its owner-scoped share-link bucket lasts an hour, so otherwise the third // run can start above the 20-operation ceiling and fail before exercising @@ -281,12 +628,12 @@ export default async function globalSetup(config: FullConfig): Promise { WHERE key LIKE 'sharing:%'`, ); - // The login bucket, for the same reason. This setup signs in THREE times - // now (the shared jar, the owner, and the sharing journey's own delegate - // jar), and the ceiling is five attempts per IP per quarter-hour — so two - // local runs in a row would otherwise end with a 429 from the fixture - // rather than a failure from the product. Only the auth surfaces' own - // buckets are cleared; nothing else in the table is touched. + // The login bucket, for the same reason. This setup signs in TEN times now + // (the shared jar, the owner, and one jar apiece for every spec that moves + // a session's record selector), and the ceiling is five attempts per IP per + // quarter-hour — so two local runs in a row would otherwise end with a 429 + // from the fixture rather than a failure from the product. Only the auth + // surfaces' own buckets are cleared; nothing else in the table is touched. await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); // One marker weight per record. Re-seeded by delete-then-insert so a @@ -313,6 +660,29 @@ export default async function globalSetup(config: FullConfig): Promise { ); } + // v1.37.0 — clear what the browser suite WRITES, so the suite can be run + // twice. + // + // The adult-WRITE journey saves a blood-pressure reading through the real + // form, and the form stamps a minute-rounded `measuredAt`. Two runs inside + // the same minute therefore collide on the natural key + // (user, type, measured_at, source) — the second save is a duplicate, and + // the journey asserts its POST succeeded. It has always been that way; it + // used to surface as a 500 and now surfaces as the 409 the route learned to + // give, which is what made it legible. A suite that cannot be re-run is a + // suite whose green is a statement about the clock. + // + // Only rows the journey itself creates: MANUAL, and carrying no + // `external_id`, which is what distinguishes them from the seeded markers + // above. + await pool.query( + `DELETE FROM measurements + WHERE user_id = (SELECT id FROM users WHERE username = $1) + AND source = 'MANUAL' + AND external_id IS NULL`, + ["e2e-level-write"], + ); + // Console (instead of structured logging) is intentional here — // global-setup runs outside the app's logging context, and the // line is useful when debugging a CI failure where the seed didn't @@ -320,31 +690,74 @@ export default async function globalSetup(config: FullConfig): Promise { console.log( `[e2e/global-setup] seeded user ${E2E_USER.username} (${E2E_USER.email})`, ); + // Log the fixture accounts in here and persist their cookie jars so specs + // can reuse them via `test.use({ storageState })`. This is the documented + // Playwright pattern (`docs/auth.md`) and the only way to avoid the + // per-spec login + rate-limit dance. + const baseURL = + config.projects[0]?.use.baseURL ?? + process.env.E2E_BASE_URL ?? + "http://localhost:3000"; + + /** + * Log one account in, clearing the login bucket first. + * + * The ceiling is FIVE attempts per IP per quarter-hour and this setup now + * signs in ten times, so clearing once before the batch is no longer + * enough — the sixth would be answered by the fixture's own 429 rather + * than by the product. Only the auth surfaces' buckets are touched, and + * only between logins this setup is itself performing. + */ + const capture = async ( + account: { username: string; password: string }, + path: string, + ): Promise => { + await pool.query(`DELETE FROM rate_limits WHERE key LIKE 'auth:%'`); + await captureAuthState(baseURL, account, path); + }; + + // The shared jar every authenticated spec reads. + await capture(E2E_USER, STORAGE_STATE_PATH); + + // v1.36.0 — the owner's jar. The sharing journey needs both sides of a + // grant signed in at once, and the login endpoint is IP-rate-limited, so + // the owner is logged in here rather than inside the spec. + await capture(E2E_OWNER, OWNER_STORAGE_STATE_PATH); + + // The delegate's own jar for that journey. Same account as the shared one, + // deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH + // for what happens when the journey switches the shared row instead. + await capture(E2E_USER, DELEGATE_STORAGE_STATE_PATH); + + await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); + + // v1.37.0 — one jar per switching spec. See the block comment on + // FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's + // row with the scoped-sharing journey, which is why the suite only passed + // on a single worker. + await capture(E2E_SCOPE_DELEGATE, FENCE_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, FENCE_OFFLINE_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, CROSS_TAB_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SEAM_BANNERS_STORAGE_STATE_PATH); + + // v1.37.0 — the guardian's jar, and only then its second factor. + // + // Order is the whole of it. `requireFreshMfa` refuses an account with no + // factor enrolled, so the managed-profile routes are unreachable without + // this; and `captureAuthState` posts a password to `/api/auth/login`, which + // for a TOTP account answers with a challenge rather than a session. Log in + // first, enrol second. The freshness stamp on the session is refreshed by + // the spec immediately before it acts — the window is five minutes and a + // full suite run is longer than that. + await capture(E2E_GUARDIAN, GUARDIAN_STORAGE_STATE_PATH); + await pool.query( + `UPDATE users SET totp_confirmed_at = $2 WHERE username = $1`, + [E2E_GUARDIAN.username, now], + ); } finally { await pool.end(); } - - // Log the fixture accounts in here and persist their cookie jars so specs - // can reuse them via `test.use({ storageState })`. This is the documented - // Playwright pattern (`docs/auth.md`) and the only way to avoid the per-spec - // login + rate-limit dance. - const baseURL = - config.projects[0]?.use.baseURL ?? - process.env.E2E_BASE_URL ?? - "http://localhost:3000"; - - // The shared jar every authenticated spec reads. - await captureAuthState(baseURL, E2E_USER, STORAGE_STATE_PATH); - - // v1.36.0 — the owner's jar. The sharing journey needs both sides of a grant - // signed in at once, and the login endpoint is IP-rate-limited, so the owner - // is logged in here rather than inside the spec. - await captureAuthState(baseURL, E2E_OWNER, OWNER_STORAGE_STATE_PATH); - - // The delegate's own jar for that journey. Same account as the shared one, - // deliberately a different session row — see DELEGATE_STORAGE_STATE_PATH for - // what happens when the journey switches the shared row instead. - await captureAuthState(baseURL, E2E_USER, DELEGATE_STORAGE_STATE_PATH); } /** diff --git a/e2e/setup/managed-profile-fixture.ts b/e2e/setup/managed-profile-fixture.ts new file mode 100644 index 000000000..f010017db --- /dev/null +++ b/e2e/setup/managed-profile-fixture.ts @@ -0,0 +1,111 @@ +/** + * v1.37.0 — the step-up the managed-profile journey needs, and nothing else. + * + * ## Why a fixture exists at all + * + * Every route in the managed-profile family resolves + * `requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS)`. That gate has two halves: + * the account must have a second factor enrolled, and the SESSION must carry a + * `mfaVerifiedAt` newer than five minutes. `global-setup.ts` supplies the first + * half once; the second cannot be supplied once, because a full suite run takes + * longer than the window and the journey would be gated on how long the specs + * ahead of it took. + * + * So the freshness stamp is refreshed immediately before the journey acts. It + * is not a bypass and it does not weaken the route: the route still runs its + * gate, the session still has to carry a stamp, and a session without one is + * still refused. What it simulates is the person having just proved their + * factor — which is the state the product requires and the only state in which + * this surface is usable at all. + * + * Seeded through `pg`, the same transport `global-setup.ts` and + * `vault-fixture.ts` use, because there is no product endpoint that says "I + * proved a factor" without a real factor to prove. + */ +import pg from "pg"; + +import { E2E_GUARDIAN } from "./global-setup"; + +function connect(): pg.Pool { + const url = process.env.DATABASE_URL; + if (!url) { + throw new Error("[managed-profile-fixture] DATABASE_URL is not set"); + } + return new pg.Pool({ connectionString: url }); +} + +/** + * Make the guardian's step-up fresh, and clear anything an earlier run left. + * + * Idempotent, and safe to call from a `beforeEach`: both statements are + * unconditional writes scoped to this one fixture account. + */ +export async function refreshGuardianStepUp(): Promise { + const pool = connect(); + try { + const stamped = await pool.query( + `UPDATE sessions SET mfa_verified_at = NOW() + WHERE user_id = (SELECT id FROM users WHERE username = $1)`, + [E2E_GUARDIAN.username], + ); + // A zero-row update means the jar this journey runs under points at a + // session that is not there, and every act below would be refused for a + // reason that has nothing to do with what it tests. Fail loudly here. + if ((stamped.rowCount ?? 0) === 0) { + throw new Error( + `[managed-profile-fixture] no session for ${E2E_GUARDIAN.username}`, + ); + } + // And the factor itself, in case a spec ran before global setup stamped it. + await pool.query( + `UPDATE users SET totp_confirmed_at = COALESCE(totp_confirmed_at, NOW()) + WHERE username = $1`, + [E2E_GUARDIAN.username], + ); + // The guardian-invitation bucket, for the same reason `global-setup.ts` + // clears the auth bucket between its own logins: the ceiling is ten + // invitations an hour per caller (and ten creations), and this journey spends + // one of each every time it + // runs. On CI that never matters; locally, the eleventh run inside an hour + // is answered by the product's own 429 rather than by what the test is + // about, and the failure reads as a broken invitation form. Only this one + // fixture account's bucket is touched. + await pool.query( + `DELETE FROM rate_limits + WHERE key IN ( + 'managed-profile:guardian-invite:' || + (SELECT id FROM users WHERE username = $1), + 'managed-profile:create:' || + (SELECT id FROM users WHERE username = $1) + )`, + [E2E_GUARDIAN.username], + ); + } finally { + await pool.end(); + } +} + +/** + * Remove every managed profile this account looks after. + * + * A managed profile is a real account row, so a journey that created one and + * left it behind makes the next run's roster assertions read the wrong record. + * Deleting the profile takes its grants with it. + */ +export async function clearGuardianProfiles(): Promise { + const pool = connect(); + try { + await pool.query( + `DELETE FROM users + WHERE managed_profile_at IS NOT NULL + AND id IN ( + SELECT g.grantor_id FROM account_grants g + JOIN users u ON u.id = g.grantee_id + WHERE u.username = $1 + )`, + [E2E_GUARDIAN.username], + ); + } finally { + await pool.end(); + } +} diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9ae2f7a18..9b075c908 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -9,9 +9,19 @@ export { E2E_USER, E2E_OWNER, + E2E_GUARDIAN, + E2E_SCOPE_DELEGATE, + E2E_SCOPE_RECORDS, + E2E_LEVEL_RECORDS, E2E_DELEGATE_MARKER_KG, E2E_OWNER_MARKER_KG, STORAGE_STATE_PATH, OWNER_STORAGE_STATE_PATH, DELEGATE_STORAGE_STATE_PATH, + SCOPE_DELEGATE_STORAGE_STATE_PATH, + SCOPE_A11Y_STORAGE_STATE_PATH, + FENCE_STORAGE_STATE_PATH, + FENCE_OFFLINE_STORAGE_STATE_PATH, + CROSS_TAB_STORAGE_STATE_PATH, + GUARDIAN_STORAGE_STATE_PATH, } from "./global-setup"; diff --git a/e2e/v137-record-session-fence-offline.spec.ts b/e2e/v137-record-session-fence-offline.spec.ts new file mode 100644 index 000000000..d2e281d6e --- /dev/null +++ b/e2e/v137-record-session-fence-offline.spec.ts @@ -0,0 +1,478 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { FENCE_OFFLINE_STORAGE_STATE_PATH } from "./setup/test-helpers"; + +/** + * FENCE-AC-07 — the disk layers cannot serve one record's bytes inside + * another. + * + * ## Why this is a spec of its own + * + * `playwright.config.ts` sets `serviceWorkers: "block"` for every other + * project, and for a good reason written there: a worker-originated `fetch` is + * not subject to `page.route`, so the SW would bypass the per-spec route mocks. + * + * This case is the exact inverse. Its whole claim is about what the + * `healthlog-data-*` cache can and cannot serve across a record switch — and + * with the worker blocked that cache is never populated at all, so the positive + * control ("the owner snapshot really WAS on disk before the switch") could + * never pass no matter what the application did. A check that cannot pass is + * worse than no check, so this runs in the `chromium-service-worker` project + * with the worker allowed, and uses no route mocks. + * + * ## What is asserted, in order + * + * 1. the owner's record reads really are on disk before the switch — a test + * that seeded nothing proves nothing; + * 2. only current-version caches exist, which is the eviction the fence's + * design relies on to make "a response with no echo" safe; + * 3. the switch empties the data cache before it reloads; + * 4. every response now on disk carries the TARGET's record scope and never + * the previous one — the claim, read off the disk layer itself rather + * than inferred from what a page did or did not paint; + * 5. and offline, that cache is what answers a record read, still carrying + * the target's context. The non-empty scope set is the paired positive + * control: an empty cache would satisfy "no previous-record bytes" while + * proving nothing. + */ + +/** + * Pages whose reads the service worker is configured to hold on disk + * (`sw-cache-policy.ts` allowlists `/api/dashboard/snapshot`, + * `/api/measurements` and `/api/medications`). Visited rather than fetched, so + * the requests are the app's own and carry the record-session assertion. + */ +const WARM_PAGES = ["/", "/measurements", "/medications"] as const; + +/** + * A WHOLE-record READ grant, deliberately not a narrowed one. + * + * The narrowed `e2e-scope-labs` fixture opens only its labs section, so the + * nav drops the other destinations and the record reads this file drives are + * refused as out-of-scope before the fence ever matters. Section narrowing is + * `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that + * hides the thing under test. + */ +const TARGET_USERNAME = "e2e-level-read"; + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); +} + +/** + * Enter the target record, retrying the click if the switch did not land. + * + * See the twin in `v137-record-session-fence.spec.ts`: the switch is a + * compare-and-set the client retries exactly once, and a harness driving it at + * machine speed can legitimately lose twice. Bounded, and a third failure is a + * real one. + */ +async function enterRecord(page: Page) { + const banner = page.locator('[data-slot="shared-record-banner"]'); + for (let attempt = 0; attempt < 3; attempt++) { + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`, + ); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + await entry.click(); + try { + await expect(banner).toBeVisible({ timeout: 15_000 }); + return accountId; + } catch { + await page.goto("/"); + await expectShellReady(page); + } + } + await expect(banner).toBeVisible({ timeout: 15_000 }); + return null; +} + +async function expectShellReady(page: Page) { + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + // A real budget, not the 10 s default. The gate IS the cross-tab hold: a + // peer sits behind it until its own `/api/auth/me` resolves, and that read + // queues behind whatever the other worker's browser is doing. Ten seconds + // is a statement about machine load; thirty is about the hold actually + // never releasing, which is the failure worth reporting. + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({ + timeout: 30_000, + }); +} + +/** What the `healthlog-data-*` caches hold right now. */ +async function dataCacheState(page: Page) { + return page.evaluate(async () => { + const names = await caches.keys(); + const data = names.filter((n) => n.startsWith("healthlog-data-")); + let entries = 0; + for (const name of data) { + entries += (await (await caches.open(name)).keys()).length; + } + return { names, dataCaches: data, entries }; + }); +} + +/** + * The record context every cached response was stored WITH. + * + * This is the disk-layer claim, read off the disk layer. A cached record + * response carries the echo it was served under, so if a previous record's + * bytes were still reachable they would show up here as that record's scope. + */ +async function cachedScopes(page: Page): Promise { + return page.evaluate(async () => { + const scopes: string[] = []; + for (const name of (await caches.keys()).filter((n) => + n.startsWith("healthlog-data-"), + )) { + const cache = await caches.open(name); + for (const request of await cache.keys()) { + const cached = await cache.match(request); + const scope = cached?.headers.get("x-healthlog-record-scope"); + if (scope) scopes.push(scope); + } + } + return scopes; + }); +} + +test.describe.serial("FENCE-AC-07 record-fence disk layers", () => { + test("FENCE-AC-07 the disk layers cannot serve one record's bytes inside another", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: FENCE_OFFLINE_STORAGE_STATE_PATH, + }); + const page = await context.newPage(); + + try { + await page.goto("/"); + // The gate is spelled out here rather than left inside a helper because + // the next act is a one-shot `page.evaluate`, and + // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source + // text between the navigation and the read. + await expect( + page.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expectShellReady(page); + // The session is shared with the other sharing journeys, so "not inside + // a record" is not something a fresh page can assume. + const banner = page.locator('[data-slot="shared-record-banner"]'); + if ((await banner.count()) > 0) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(banner).toHaveCount(0); + } + + // Wait for the worker to control the page — until it does, nothing it + // would cache is going through it. + await page.waitForFunction( + () => navigator.serviceWorker?.controller !== null, + undefined, + { timeout: 20_000 }, + ); + + // Warm the disk cache by VISITING the pages, not by issuing bare + // fetches. A bare `fetch` in `page.evaluate` carries no record-session + // assertion, so on a session that has been inside a shared record the + // fence refuses it and nothing cacheable comes back — the cache filled + // from the app's own reads instead, and those were the ones that happened + // to resolve no record scope. Driving the app is what puts real, + // echo-carrying record responses on disk. + for (const path of WARM_PAGES) { + await page.goto(path); + // Spelled out rather than left inside `expectShellReady`, because the + // next act is a one-shot `page.evaluate` and + // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source + // text between the navigation and the read. A gate hidden in a helper + // is invisible to that guard and to the next reader. + await expect( + page.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expectShellReady(page); + } + await expect + .poll(async () => (await dataCacheState(page)).entries, { + timeout: 15_000, + }) + .toBeGreaterThan(0); + + // (1) POSITIVE CONTROL: the owner's bytes really are on disk. + const seeded = await dataCacheState(page); + expect(seeded.dataCaches.length).toBeGreaterThan(0); + expect(seeded.entries).toBeGreaterThan(0); + + // (2) Only current-version caches survive activation. This is the + // eviction the fence's "a response with no echo is served normally" rule + // depends on: a PRE-fence cached response carries no echo, and it can + // only be served if its cache is still there. + const version = seeded.dataCaches[0].replace("healthlog-data-", ""); + const stale = seeded.names.filter( + (n) => + /^healthlog-(static|pages|data)-/.test(n) && !n.endsWith(version), + ); + expect(stale).toEqual([]); + + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${TARGET_USERNAME}"]`, + ); + await expect(entry).toBeVisible(); + const targetAccountId = await entry.getAttribute("data-account-id"); + expect(targetAccountId).not.toBeNull(); + await entry.click(); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + + // (3) NOT asserted: "the data cache is momentarily empty". + // + // The switch does wipe the disk layers — `clearOfflineCachesForRecordSwitch` + // runs before the reload — but the reload immediately refills them from + // the target's own reads, so an empty cache is a state that exists for a + // few milliseconds and cannot be sampled reliably. Polling for it made + // this case fail for a reason that had nothing to do with the claim. + // + // The claim is asserted directly below instead, and it is the stronger + // statement anyway: what matters is not that the cache was briefly empty + // but that nothing in it belongs to the previous record. + + // Visit inside the TARGET record so there is something of its own on + // disk to find. + for (const path of WARM_PAGES) { + await page.goto(path); + // Spelled out rather than left inside `expectShellReady`, because the + // next act is a one-shot `page.evaluate` and + // `src/__tests__/e2e-measurement-gating-guard.test.ts` reads the source + // text between the navigation and the read. A gate hidden in a helper + // is invisible to that guard and to the next reader. + await expect( + page.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expectShellReady(page); + } + await expect + .poll(async () => (await dataCacheState(page)).entries, { + timeout: 15_000, + }) + .toBeGreaterThan(0); + + // (4) + (5) The disk layer now holds the TARGET's bytes and only those, + // read off the disk layer itself. + // + // The first draft asserted the shared-record banner after an offline + // reload. That could never pass and had nothing to do with the claim: + // `/api/auth/me` is not a cached read, so offline it fails, `useAuth` + // errors and the shell paints its offline gate rather than a banner. The + // banner's absence says nothing about what is on disk. + // + // What IS on disk is checkable, and it is the whole point: every cached + // record response carries the echo it was served under, so a previous + // record's bytes surviving the switch would appear here as that record's + // scope. + const scopes = await cachedScopes(page); + // POSITIVE CONTROL: something really is cached with a context, so the + // assertion below is about a non-empty set. + expect(scopes.length).toBeGreaterThan(0); + for (const scope of scopes) { + expect(scope).toBe(targetAccountId); + } + expect(scopes).not.toContain("self"); + + // And the target's own record read really is one of the entries on disk, + // so the scope assertion above is about the reads that matter rather + // than about some incidental cached response. + const cachedUrls = await page.evaluate(async () => { + const urls: string[] = []; + for (const name of (await caches.keys()).filter((n) => + n.startsWith("healthlog-data-"), + )) { + for (const request of await (await caches.open(name)).keys()) { + urls.push(new URL(request.url).pathname); + } + } + return urls; + }); + expect(cachedUrls).toContain("/api/measurements"); + + // NOT asserted: that a bare `fetch` from page context can read those + // bytes back while offline. Since v1.37.0 an echoing response also + // carries `Vary: x-healthlog-record-epoch, x-healthlog-record-scope`, and + // the Cache API honours `Vary` — so a request WITHOUT the fence headers + // does not match the stored entry at all. That is the correct behaviour + // and a second line of defence worth naming: bytes cached under one + // record context cannot be retrieved by a request asserting a different + // one, or none. The app's own reads carry the headers and do match. + } finally { + await context.setOffline(false); + // Hand the shared session back to its own record; see the note in + // `v137-record-session-fence.spec.ts`. + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); + + test("FENCE-AC-07 a cached record response cannot be SERVED under a different context", async ({ + browser, + }) => { + /** + * The serving path, which the case above does not exercise. + * + * The test above asserts what is IN the cache. That is worth asserting and + * it is not the same claim: an entry can sit on disk under one context and + * still be handed to a request made under another, and on the EXTERNAL + * switch path nothing wipes it — the app never initiated the switch, so + * `clearOfflineCachesForRecordSwitch` never runs. On that path `Vary` is + * the only thing standing between a cached delegated response and the next + * request for the same URL. + * + * So this asks the Cache API the question a service worker asks it: + * `cache.match(request)` honours the stored response's `Vary`, so a request + * whose fence headers differ from the ones the entry was stored with does + * not match at all. Remove the `Vary` block from `attachRecordContextEcho` + * and the second lookup starts hitting. + */ + const context = await browser.newContext({ + storageState: FENCE_OFFLINE_STORAGE_STATE_PATH, + }); + const page = await context.newPage(); + + try { + await page.goto("/"); + await expect( + page.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expectShellReady(page); + const banner = page.locator('[data-slot="shared-record-banner"]'); + if ((await banner.count()) > 0) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(banner).toHaveCount(0); + } + await page.waitForFunction( + () => navigator.serviceWorker?.controller !== null, + undefined, + { timeout: 20_000 }, + ); + + // Enter the record and let its reads land on disk. + await enterRecord(page); + await page.goto("/measurements"); + await expect( + page.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expectShellReady(page); + + // The context those bytes were stored under, read off the entry itself. + // + // Polled, not sampled once. The service worker writes its `healthlog-data-*` + // entry AFTER the response has been handed to the page, so + // `expectShellReady` returning is not evidence that the write has landed — + // it is evidence that the read arrived. Sampling here read `null` on a + // loaded machine and failed the positive control below, which then said + // "there is no cache entry" about a cache that was about to have one. + // Waiting on the entry itself waits for the signal rather than for a + // number of milliseconds, and it still fails if the entry never appears. + const readStoredEntry = async () => + page.evaluate(async () => { + for (const name of (await caches.keys()).filter((n) => + n.startsWith("healthlog-data-"), + )) { + const cache = await caches.open(name); + for (const request of await cache.keys()) { + if ( + !new URL(request.url).pathname.startsWith("/api/measurements") + ) + continue; + const response = await cache.match(request); + if (!response) continue; + return { + cacheName: name, + url: request.url, + vary: response.headers.get("Vary"), + epoch: response.headers.get("x-healthlog-record-epoch"), + scope: response.headers.get("x-healthlog-record-scope"), + }; + } + } + return null; + }); + + // POSITIVE CONTROL: there really is an entry, stored with a context. + await expect.poll(readStoredEntry, { timeout: 20_000 }).not.toBeNull(); + const stored = await readStoredEntry(); + expect(stored).not.toBeNull(); + expect(stored!.epoch).toMatch(/^\d+$/); + expect(stored!.scope).not.toBe("self"); + + // The EXTERNAL switch: driven from a request context, so the app never + // runs its cache wipe and the entry above survives. + const left = await context.request.post("/api/account/switch", { + data: { accountId: null }, + }); + expect(left.status()).toBe(200); + const me = await context.request.get("/api/auth/me"); + const now = (await me.json()).data.recordSession as { + epoch: number; + scope: string | null; + }; + // The context genuinely moved, or the comparison below is vacuous. + expect(String(now.epoch)).not.toBe(stored!.epoch); + + const lookups = await page.evaluate( + async ({ + cacheName, + url, + storedEpoch, + storedScope, + nowEpoch, + nowScope, + }) => { + const cache = await caches.open(cacheName); + const ask = async (epoch: string, scope: string) => + (await cache.match( + new Request(url, { + headers: { + "x-healthlog-record-epoch": epoch, + "x-healthlog-record-scope": scope, + }, + }), + )) !== undefined; + return { + underStoredContext: await ask(storedEpoch, storedScope), + underNewContext: await ask(nowEpoch, nowScope), + withNoAssertion: + (await cache.match(new Request(url))) !== undefined, + }; + }, + { + cacheName: stored!.cacheName, + url: stored!.url, + storedEpoch: stored!.epoch!, + storedScope: stored!.scope!, + nowEpoch: String(now.epoch), + nowScope: now.scope ?? "self", + }, + ); + + // POSITIVE CONTROL: the entry IS servable to the context it belongs to. + // Without this, "the other context misses" would pass for an entry that + // was simply not there. + expect(lookups.underStoredContext).toBe(true); + // The claim. Remove the `Vary` and this starts hitting. + expect(lookups.underNewContext).toBe(false); + expect(lookups.withNoAssertion).toBe(false); + } finally { + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); +}); diff --git a/e2e/v137-record-session-fence.spec.ts b/e2e/v137-record-session-fence.spec.ts new file mode 100644 index 000000000..5868b39b6 --- /dev/null +++ b/e2e/v137-record-session-fence.spec.ts @@ -0,0 +1,615 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { FENCE_STORAGE_STATE_PATH } from "./setup/test-helpers"; + +/** + * The record-session fence, in a real browser. + * + * `e2e/v137-sharing-cross-tab-session.spec.ts` proves the COOPERATIVE layer: + * a peer tab holds while the initiator's switch is in flight, because the + * initiator told it to. This file proves the cases that layer cannot reach — + * the ones where nobody tells anybody anything: + * + * * FENCE-AC-01 peers reconcile to the exact new epoch and scope, not merely + * to "something changed"; + * * FENCE-AC-02 the initiator's response never arrives, because the tab is + * torn off mid-flight; + * * FENCE-AC-03 the initiator is CLOSED after the server committed, so no + * commit broadcast is ever published; + * * FENCE-AC-06 a record response delayed across the switch is discarded by + * the client rather than painted; + * * FENCE-AC-07 the disk layers — CacheStorage and the service worker's + * `healthlog-data-*` — cannot serve one record's bytes inside another. + * + * ## Why every absence assertion here is paired + * + * "The peer never showed the owner's data" passes on a page that is + * permanently wedged, which is a worse outcome than the bug. So each of those + * legs additionally asserts the peer REACHES a usable state on the target + * scope once `/me` resolves. An absence-only assertion in this file would be a + * check that cannot fail. + * + * Uses the existing synthetic `e2e/setup` fixtures; introduces no new account + * anchors. + */ + +const RECORD_READS = [ + "/api/dashboard/snapshot", + "/api/insights/targets", + "/api/medications", + "/api/medications/compliance", + "/api/labs", +] as const; + +const EPOCH_HEADER = "x-healthlog-record-epoch"; +const SCOPE_HEADER = "x-healthlog-record-scope"; + +/** + * A WHOLE-record READ grant, deliberately not a narrowed one. + * + * The narrowed `e2e-scope-labs` fixture opens only its labs section, so the + * nav drops the other destinations and the record reads this file drives are + * refused as out-of-scope before the fence ever matters. Section narrowing is + * `v137-sharing-managed-profiles.spec.ts`'s subject; here it is noise that + * hides the thing under test. + */ +const TARGET_USERNAME = "e2e-level-read"; + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); +} + +function switcherEntry(page: Page, username = TARGET_USERNAME) { + return page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +/** + * Leave any record this session is already inside. + * + * These specs each begin by switching INTO a record, so they need to start + * outside one — and the session is shared with the other sharing journeys, so + * "outside one" is not something a fresh page can assume. Without this the + * file passes in isolation and fails whenever it runs after a spec that left + * the session switched, which is the least useful kind of failure. + */ +async function ensureOwnRecord(page: Page) { + const banner = page.locator('[data-slot="shared-record-banner"]'); + if ((await banner.count()) === 0) return; + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(banner).toHaveCount(0, { timeout: 30_000 }); + // And WAIT for the reload to finish. + // + // Leaving a record is a full document navigation, and the banner vanishing + // only means that navigation started. Returning there hands the next step a + // page that has not re-read `/api/auth/me` yet — so its adopted epoch is + // still `bootstrap`, its switch sends `expectedEpoch: 0`, the compare-and-set + // loses against the epoch the exit itself just moved, and the switch quietly + // does not happen. The symptom is a banner that never appears, thirty + // seconds later, with nothing wrong in the product. + await expectShellReady(page); +} + +/** + * Enter the target record, retrying the click if the switch did not land. + * + * Not a workaround for a flaky product — a fixture acknowledging a contract the + * product chose deliberately. `POST /api/account/switch` is a compare-and-set + * on the session's record epoch, and the client reconciles and retries exactly + * ONCE; a second lost CAS surfaces as a failed toast rather than being retried + * forever, which is the right call for a person at a keyboard. + * + * These tests are not a person at a keyboard. Some drive two tabs of ONE + * session at machine speed while the peer is reloading and re-adopting, which + * is precisely the "the browser is racing itself" case that contract names — + * so the switcher click legitimately loses sometimes, and the product is + * behaving as designed when it does. The fixture re-presses the button, which + * is what the person would do. + * + * Bounded and asserted: if three attempts cannot enter the record, that is a + * real failure and it fails. + */ +async function enterRecord(page: Page, username = TARGET_USERNAME) { + const banner = page.locator('[data-slot="shared-record-banner"]'); + let accountId: string | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + await openSwitcher(page); + const entry = switcherEntry(page, username); + await expect(entry).toBeVisible(); + accountId ??= await entry.getAttribute("data-account-id"); + await entry.click(); + try { + await expect(banner).toBeVisible({ timeout: 15_000 }); + return accountId; + } catch { + // The switcher menu may still be open over the page; start clean. + await page.goto("/"); + await expectShellReady(page); + } + } + await expect(banner).toBeVisible({ timeout: 15_000 }); + return accountId; +} + +/** Wait for the shell to be usable again — the paired positive control. */ +async function expectShellReady(page: Page) { + await expect( + page.locator('[data-slot="record-scope-hydration-gate"]'), + // A real budget, not the 10 s default. The gate IS the cross-tab hold: a + // peer sits behind it until its own `/api/auth/me` resolves, and that read + // queues behind whatever the other worker's browser is doing. Ten seconds + // is a statement about machine load; thirty is about the hold actually + // never releasing, which is the failure worth reporting. + ).toHaveCount(0, { timeout: 30_000 }); + await expect(page.getByRole("button", { name: "User menu" })).toBeVisible({ + timeout: 30_000, + }); +} + +/** + * The furthest-along assertion in a collected set. + * + * The epoch is monotonic, so the highest one is the freshest context this + * browser has reached — independent of the order the requests happened to be + * observed in. + */ +function latestAssertion( + collected: { epoch: string; scope: string }[], +): { epoch: string; scope: string } | null { + let best: { epoch: string; scope: string } | null = null; + for (const assertion of collected) { + if (!/^\d+$/.test(assertion.epoch)) continue; + if (best === null || Number(assertion.epoch) > Number(best.epoch)) { + best = assertion; + } + } + return best; +} + +/** The record context a page asserted on its most recent record read. */ +function collectAssertions( + page: Page, + into: { epoch: string; scope: string }[], +) { + page.on("request", (request) => { + const path = new URL(request.url()).pathname; + if (!(RECORD_READS as readonly string[]).includes(path)) return; + const headers = request.headers(); + const epoch = headers[EPOCH_HEADER]; + const scope = headers[SCOPE_HEADER]; + if (epoch === undefined || scope === undefined) return; + into.push({ epoch, scope }); + }); +} + +test.describe.serial("FENCE record-session fence in the browser", () => { + test("FENCE-AC-01 a peer reconciles to the exact new epoch and scope, in both directions", async ({ + browser, + }) => { + // Two full switches, two peer reloads each, and a poll that reloads until + // the server write is visible to the second tab. That does not fit the + // 30 s default, and a test that times out reports a failure that says + // nothing about the fence. + test.setTimeout(120_000); + const context = await browser.newContext({ + storageState: FENCE_STORAGE_STATE_PATH, + }); + const initiator = await context.newPage(); + const peer = await context.newPage(); + const peerAssertions: { epoch: string; scope: string }[] = []; + collectAssertions(peer, peerAssertions); + + try { + await Promise.all([initiator.goto("/"), peer.goto("/")]); + await expectShellReady(peer); + await expectShellReady(initiator); + await ensureOwnRecord(initiator); + await peer.reload(); + await expectShellReady(peer); + + const accountId = await enterRecord(initiator); + expect(accountId).not.toBeNull(); + + // Cleared HERE, not before the click: between the click and the reload + // the peer is still issuing reads under the context it had, and counting + // those made the assertion below compare a pre-switch `self` against the + // post-switch account. Only what the peer asserts once it has reconciled + // is evidence about reconciliation. + peerAssertions.length = 0; + + // The peer reaches a usable shell again — the positive control, without + // which every assertion below would pass on a wedged page. + await peer.reload(); + await expectShellReady(peer); + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible({ timeout: 30_000 }); + + // And every record read it now issues names the record it is actually + // in, not "something". A peer that reconciled to a guess would show up + // here as a scope naming the wrong account or a stale epoch. + // The HIGHEST-epoch assertion, not every collected one. + // + // The epoch is monotonic, and requests do not arrive in the order they + // were issued — a read from the page before the reload can be observed + // after one from the page after it. Requiring EVERY collected assertion + // to name the new record is therefore a claim about network ordering + // rather than about reconciliation, and it fails intermittently for a + // reason that has nothing to do with the fence. What reconciliation + // means is that the peer CONVERGES on the new context, which is exactly + // the highest epoch it ever asserts. + const inRecord = latestAssertion(peerAssertions); + expect(inRecord).not.toBeNull(); + expect(inRecord!.scope).toBe(accountId); + const inRecordEpoch = Number(inRecord!.epoch); + expect(inRecordEpoch).toBeGreaterThan(0); + + // ── and the same on the way out ─────────────────────────────────── + await initiator + .locator('[data-slot="shared-record-banner-exit"]') + .click(); + await expect( + initiator.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0, { timeout: 30_000 }); + + peerAssertions.length = 0; + // Polled with reloads rather than reloaded once. The initiator's banner + // disappearing means its own document navigated, not that the server + // write has landed and become visible to another tab — asserting on one + // reload made this leg a race against the switch-out commit. + await expect + .poll( + async () => { + await peer.reload(); + await expectShellReady(peer); + return peer.locator('[data-slot="shared-record-banner"]').count(); + }, + { timeout: 40_000 }, + ) + .toBe(0); + + const backHome = latestAssertion(peerAssertions); + expect(backHome).not.toBeNull(); + expect(backHome!.scope).toBe("self"); + // Leaving moved the epoch too: a tab believing it is still inside the + // record is the same defect as the reverse. + expect(Number(backHome!.epoch)).toBeGreaterThan(inRecordEpoch); + } finally { + // Hand the shared session back to its own record. + // + // These fixtures' storage state is shared with the other sharing + // journeys, and every test here deliberately moves the session's record + // selector. Leaving it switched makes the NEXT spec fail for a reason + // that has nothing to do with it — which is how a green file and a red + // suite happen at the same time. + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); + + test("FENCE-AC-02 an aborted switch response leaves peers correct, and they still reach ready", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: FENCE_STORAGE_STATE_PATH, + }); + const initiator = await context.newPage(); + const peer = await context.newPage(); + const peerAssertions: { epoch: string; scope: string }[] = []; + collectAssertions(peer, peerAssertions); + + try { + await Promise.all([initiator.goto("/"), peer.goto("/")]); + await expectShellReady(peer); + await expectShellReady(initiator); + await ensureOwnRecord(initiator); + + // Drive the switch server-side and abort the response on the way back: + // the write lands, the initiator never learns it did, and no commit + // broadcast is ever published. + await openSwitcher(initiator); + const entry = switcherEntry(initiator); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + expect(accountId).not.toBeNull(); + + await initiator.route("**/api/account/switch", async (route) => { + await route.fetch(); + await route.abort(); + }); + await entry.click({ noWaitAfter: true }); + await initiator.waitForTimeout(500); + + // The peer must never paint owner-ready state it guessed at … + const guessed = peerAssertions.filter( + (a) => a.scope === accountId && a.epoch === "bootstrap", + ); + expect(guessed).toEqual([]); + + // … and must still REACH ready on the target scope once `/me` answers. + // Without this the leg above would pass on a permanently wedged page. + await peer.reload(); + await expectShellReady(peer); + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + expect(peerAssertions.some((a) => a.scope === accountId)).toBe(true); + } finally { + // Hand the shared session back to its own record. + // + // These fixtures' storage state is shared with the other sharing + // journeys, and every test here deliberately moves the session's record + // selector. Leaving it switched makes the NEXT spec fail for a reason + // that has nothing to do with it — which is how a green file and a red + // suite happen at the same time. + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); + + test("FENCE-AC-03 a peer reaches ready after the initiator is closed post-commit", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: FENCE_STORAGE_STATE_PATH, + }); + const initiator = await context.newPage(); + const peer = await context.newPage(); + + try { + await Promise.all([initiator.goto("/"), peer.goto("/")]); + await expectShellReady(peer); + await expectShellReady(initiator); + await ensureOwnRecord(initiator); + await peer.reload(); + await expectShellReady(peer); + + await openSwitcher(initiator); + const entry = switcherEntry(initiator); + await expect(entry).toBeVisible(); + + // Hold the response, close the tab while it is in flight. The server has + // committed; nothing will ever broadcast the commit. + const held = deferred(); + await initiator.route("**/api/account/switch", async (route) => { + await route.fetch(); + await held.promise; + await route.abort(); + }); + await entry.click({ noWaitAfter: true }); + await initiator.waitForTimeout(300); + held.release(); + await initiator.close(); + + // The surviving tab is not permanently gated: `/me` is the bootstrap and + // it answers regardless of who began the transition. + await peer.reload(); + await expectShellReady(peer); + } finally { + // Hand the shared session back to its own record. + // + // These fixtures' storage state is shared with the other sharing + // journeys, and every test here deliberately moves the session's record + // selector. Leaving it switched makes the NEXT spec fail for a reason + // that has nothing to do with it — which is how a green file and a red + // suite happen at the same time. + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); + + test("FENCE-AC-04 an external switch refuses the stale peer's next record read", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: FENCE_STORAGE_STATE_PATH, + }); + const peer = await context.newPage(); + // Collected from the very start: the refusal can land on a poll the app + // issues by itself, before this test navigates anywhere. + const refusals: string[] = []; + peer.on("response", (response) => { + const path = new URL(response.url()).pathname; + if (response.status() !== 409) return; + // ANY refused API read, not one of a named five. Which cells a page + // mounts is a rendering decision that moves with the product, and a + // hard-coded list turns "the fence refused the stale tab" into "the + // fence refused one of the paths I happened to name" — which is how + // this case first passed while observing nothing. + if (!path.startsWith("/api/")) return; + refusals.push(path); + }); + + try { + await peer.goto("/"); + await expectShellReady(peer); + await ensureOwnRecord(peer); + + // A switch driven from a request context that runs no client journal at + // all — the "raw or external" case the cooperative layer cannot see. + await openSwitcher(peer); + const entry = switcherEntry(peer); + await expect(entry).toBeVisible(); + const accountId = await entry.getAttribute("data-account-id"); + expect(accountId).not.toBeNull(); + await peer.keyboard.press("Escape"); + + const response = await context.request.post("/api/account/switch", { + data: { accountId }, + }); + expect(response.status()).toBe(200); + + // The tab is now asserting a context the row has left — but observing + // the refusal takes one deliberate step, and the reason is worth stating + // because it is a property of the design rather than a quirk of the test. + // + // `GET /api/auth/me` is UNFENCED (it is the reconciliation bootstrap and + // must answer while the context is unknown) and it is one of the two + // responses a client may ADOPT from. So whenever the app happens to + // re-read `/me` before it reads a record, it silently adopts the new + // context and no stale assertion is ever sent. That is correct — and it + // means a test that just navigates observes nothing, which is how the + // first draft of this case passed while proving nothing. + // + // The window the fence exists for is the opposite ordering: a suspended + // peer whose RECORD read goes out before any reconciliation. Holding + // `/me` produces exactly that ordering, deterministically. + // Held exactly once, and never unrouted: calling `unroute` while a + // handler is still parked inside its `await` abandons that route, and + // the `continue()` on the far side then throws "Route is already + // handled". Letting the handler fall through after the first hold keeps + // one owner for the route from start to finish. + const heldMe: { release: (() => void) | null; done: boolean } = { + release: null, + done: false, + }; + await peer.route("**/api/auth/me", async (route) => { + if (heldMe.done) { + await route.continue(); + return; + } + heldMe.done = true; + await new Promise((resolve) => { + heldMe.release = resolve; + }); + await route.continue(); + }); + + const soft = peer.locator('aside[aria-label="Sidebar"]'); + await expect(soft).toBeVisible(); + await soft.getByRole("link", { name: "Measurements" }).click(); + + await expect + .poll(() => refusals.length, { timeout: 20_000 }) + .toBeGreaterThan(0); + + // Let the reconciliation through and confirm the tab recovers rather + // than sitting on the refusal. + heldMe.release?.(); + + // Reconciled, not evicted: the tab ends up INSIDE the record the + // external switch put it in, with a usable shell. + await expect( + peer.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible({ timeout: 20_000 }); + await expectShellReady(peer); + } finally { + // Hand the shared session back to its own record. + // + // These fixtures' storage state is shared with the other sharing + // journeys, and every test here deliberately moves the session's record + // selector. Leaving it switched makes the NEXT spec fail for a reason + // that has nothing to do with it — which is how a green file and a red + // suite happen at the same time. + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); + + test("FENCE-AC-06 a record response delayed across the switch is discarded", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: FENCE_STORAGE_STATE_PATH, + }); + const page = await context.newPage(); + + try { + // The intercept goes on BEFORE the first navigation. Installed after the + // page had already loaded, it matched nothing — the only request for that + // path had come and gone, and the case passed while observing nothing. + // + // And it holds `/api/measurements`, a FENCED record read. The first + // version held `/api/auth/me` because that one is alive on every boot — + // but `/me` is an actor surface, the fence never runs on it, and it + // therefore echoes no record context at all. Holding it captured a null + // echo forever, which is the same "observed nothing" failure wearing a + // different path. + const held: { + armed: boolean; + release: (() => void) | null; + epoch: string | null; + scope: string | null; + } = { armed: false, release: null, epoch: null, scope: null }; + + await page.route("**/api/measurements**", async (route) => { + if (!held.armed) { + await route.continue(); + return; + } + held.armed = false; + const response = await route.fetch(); + held.epoch = response.headers()["x-healthlog-record-epoch"] ?? null; + held.scope = response.headers()["x-healthlog-record-scope"] ?? null; + await new Promise((resolve) => { + held.release = resolve; + }); + await route.fulfill({ response }).catch(() => {}); + }); + + await page.goto("/"); + await expectShellReady(page); + await ensureOwnRecord(page); + + const accountId = await enterRecord(page); + + // Arm the hold, then make the app issue the read inside the record. + held.armed = true; + const sidebar = page.locator('aside[aria-label="Sidebar"]'); + await expect(sidebar).toBeVisible(); + await sidebar + .getByRole("link", { name: "Measurements" }) + .click({ noWaitAfter: true }); + + await expect + .poll(() => held.release !== null, { timeout: 20_000 }) + .toBe(true); + + // The response was admitted under the record's context and says so. + // Proof the intercept saw a real fenced read rather than matching + // nothing, which is the whole failure mode this case kept falling into. + expect(held.epoch).toMatch(/^\d+$/); + expect(held.scope).toBe(accountId); + + // Now move the context out from under it, and only then let it land. + const leaving = page + .locator('[data-slot="shared-record-banner-exit"]') + .click({ noWaitAfter: true }); + await page.waitForTimeout(500); + held.release?.(); + await leaving.catch(() => {}); + + // The page settles on the record it is actually in — its own — without + // painting the one the held response described. + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0, { timeout: 20_000 }); + await expectShellReady(page); + } finally { + // Hand the shared session back to its own record. + await context.request + .post("/api/account/switch", { data: { accountId: null } }) + .catch(() => {}); + await context.close(); + } + }); +}); diff --git a/e2e/v137-sharing-cross-tab-session.spec.ts b/e2e/v137-sharing-cross-tab-session.spec.ts new file mode 100644 index 000000000..e4058bf4a --- /dev/null +++ b/e2e/v137-sharing-cross-tab-session.spec.ts @@ -0,0 +1,116 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { CROSS_TAB_STORAGE_STATE_PATH } from "./setup/test-helpers"; + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); +} + +function deferred() { + let release!: () => void; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +test.describe.serial("cross-tab shared-record session switches", () => { + test("holds a peer tab before both switch-in and switch-out change the shared session", async ({ + browser, + }) => { + const context = await browser.newContext({ + storageState: CROSS_TAB_STORAGE_STATE_PATH, + }); + const switchingPage = await context.newPage(); + const peerPage = await context.newPage(); + const pausedSwitches: ReturnType[] = []; + const peerRecordReads: string[] = []; + + await switchingPage.route("**/api/account/switch", async (route) => { + const pause = pausedSwitches.shift(); + if (pause) await pause.promise; + await route.continue(); + }); + peerPage.on("request", (request) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if ( + [ + "/api/dashboard/snapshot", + "/api/insights/targets", + "/api/medications", + "/api/medications/compliance", + "/api/labs", + "/api/labs/ocr/capability", + "/api/insights/coach/nudge-status", + ].includes(path) + ) { + peerRecordReads.push(path); + } + }); + + try { + await Promise.all([switchingPage.goto("/"), peerPage.goto("/")]); + await expect( + peerPage.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + + await openSwitcher(switchingPage); + const entry = switchingPage.locator( + '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]', + ); + await expect(entry).toBeVisible(); + const switchIn = deferred(); + pausedSwitches.push(switchIn); + peerRecordReads.length = 0; + const switchInClick = entry.click({ noWaitAfter: true }); + + await expect( + peerPage.locator('[data-slot="record-scope-hydration-gate"]'), + ).toBeVisible(); + await expect( + peerPage.getByRole("button", { name: "User menu" }), + ).toHaveCount(0); + await peerPage.waitForTimeout(200); + expect(peerRecordReads).toEqual([]); + + switchIn.release(); + await switchInClick; + await expect( + switchingPage.locator('[data-slot="shared-record-banner"]'), + ).toBeVisible(); + await expect( + peerPage.locator('[data-slot="record-scope-hydration-gate"]'), + ).toHaveCount(0); + + const switchOut = deferred(); + pausedSwitches.push(switchOut); + peerRecordReads.length = 0; + const switchOutClick = switchingPage + .locator('[data-slot="shared-record-banner-exit"]') + .click({ noWaitAfter: true }); + + await expect( + peerPage.locator('[data-slot="record-scope-hydration-gate"]'), + ).toBeVisible(); + await expect( + peerPage.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + await peerPage.waitForTimeout(200); + expect(peerRecordReads).toEqual([]); + + switchOut.release(); + await switchOutClick; + await expect( + switchingPage.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + await expect( + peerPage.getByRole("button", { name: "User menu" }), + ).toBeVisible(); + } finally { + await context.close(); + } + }); +}); diff --git a/e2e/v137-sharing-managed-profiles-a11y.spec.ts b/e2e/v137-sharing-managed-profiles-a11y.spec.ts new file mode 100644 index 000000000..53ca7303f --- /dev/null +++ b/e2e/v137-sharing-managed-profiles-a11y.spec.ts @@ -0,0 +1,379 @@ +import AxeBuilder from "@axe-core/playwright"; +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { SCOPE_A11Y_STORAGE_STATE_PATH } from "./setup/test-helpers"; + +/** + * The five states of a shared record, each proved usable rather than merely + * reachable. + * + * Accessibility work concentrates on the state where everything worked, + * because that is the state the person building the screen is looking at. The + * other four are where somebody who cannot see the screen is most stranded: a + * spinner nobody announces, an error card that reads as an empty page, a + * refusal whose only way out is a control no keyboard reaches. This file had + * four tests for five states, success and empty shared one, and neither of + * them entered an empty state at all. + * + * Each test now names its state in brackets, enters it, and runs four proofs: + * an axe scan, the reading order, either a keyboard reach or an announcement, + * and — where the state has copy somebody has to act on — that the copy is + * content rather than muted meta. + * + * `src/__tests__/sharing-accessibility-states.test.tsx` reads this file back + * against `tests/fixtures/v137/e2e-journeys.ts` and fails if a state loses its + * test or one of its proofs. A browser suite reports what it ran, never what + * it did not, so the enumeration lives where a fast test can check it. + */ + +/** WCAG 2.1 AA, as the rest of the suite scans it. */ +async function scanForViolations(page: Page) { + const result = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(result.violations).toEqual([]); +} + +/** + * The named elements appear in this order in the document. + * + * Reading order is what a screen reader follows and what a keyboard walks, and + * CSS can put a control visually above an explanation while the DOM keeps it + * below. Every selector must resolve: an absent element would otherwise make + * an order of one trivially correct, which is the shape of check that passes + * for the whole life of a regression. + */ +async function expectReaderOrder(page: Page, selectors: string[]) { + expect(selectors.length).toBeGreaterThan(1); + const positions = await page.evaluate((list) => { + const all = Array.from(document.querySelectorAll("*")); + return list.map((selector) => { + const element = document.querySelector(selector); + return element ? all.indexOf(element) : -1; + }); + }, selectors); + + const missing = selectors.filter((_, i) => positions[i] === -1); + expect(missing, `not on the page: ${missing.join(", ")}`).toEqual([]); + expect(positions, `reading order is ${selectors.join(" then ")}`).toEqual( + [...positions].sort((a, b) => a - b), + ); +} + +/** + * Tab from the top of the document until focus lands on the target. + * + * Real Tab presses rather than `element.focus()`, for two reasons: a control + * that is present but not in the tab order fails here, which is the defect + * this proves the absence of; and `:focus-visible` only applies to keyboard + * focus, so the ring can be asserted at the same time. The invitation form's + * own radios shipped `sr-only` inputs whose focus ring landed on a zero-size + * box — the whole class of defect is invisible to `element.focus()`. + */ +async function expectKeyboardReach(page: Page, selector: string) { + await expect(page.locator(selector).first()).toBeVisible(); + await page.evaluate(() => { + (document.activeElement as HTMLElement | null)?.blur(); + document.body.focus(); + }); + + const MAX_TABS = 120; + let reached = false; + for (let i = 0; i < MAX_TABS && !reached; i += 1) { + await page.keyboard.press("Tab"); + reached = await page.evaluate( + (sel) => document.activeElement?.matches(sel) ?? false, + selector, + ); + } + expect(reached, `${selector} is not reachable by Tab`).toBe(true); + + // And the person can see where they are. A focusable control with no + // visible indicator is WCAG 2.4.7 and is exactly what an `sr-only` input + // produces. + const outlined = await page.evaluate((sel) => { + const element = document.querySelector(sel); + if (!element) return false; + if (element.matches(":focus-visible")) return true; + // Some rings are painted by an ancestor via `has-[:focus-visible]`. + return Boolean(element.closest(":has(:focus-visible)")); + }, selector); + expect(outlined, `${selector} takes focus without showing it`).toBe(true); +} + +/** + * The state says out loud that it is happening. + * + * For the one state with nothing to press. A spinner is a picture; without a + * live region and a label it is a blank page to a screen reader, and the hold + * before the active record resolves is precisely when somebody must not be + * told nothing. + */ +async function expectAnnounced(page: Page, selector: string) { + const region = page.locator(selector).first(); + await expect(region).toBeVisible(); + await expect(region).toHaveAttribute("role", /status|alert/); + const label = await region.evaluate((node) => + (node.textContent ?? "").trim(), + ); + expect(label.length, `${selector} announces nothing`).toBeGreaterThan(0); +} + +/** + * Copy somebody has to act on is content, not meta. + * + * Two halves, and both are needed. The class check is the design standard — + * UI-STANDARDS §3 reserves `text-muted-foreground` for meta, and consent or + * safety text rendered as meta is text people skip. The ratio check is the + * measurable one: it walks up for an opaque background and asserts the AA + * floor for body text, so a future token change that keeps the class and + * loses the contrast still fails. + */ +async function expectContentContrast(page: Page, selector: string) { + const result = await page.evaluate((sel) => { + const element = document.querySelector(sel); + if (!element) return null; + + const parse = (value: string): [number, number, number, number] | null => { + const nums = value.match(/[\d.]+/g); + if (!nums || nums.length < 3) return null; + return [ + Number(nums[0]), + Number(nums[1]), + Number(nums[2]), + nums.length > 3 ? Number(nums[3]) : 1, + ]; + }; + + const luminance = ([r, g, b]: [number, number, number, number]): number => { + const channel = (c: number) => { + const v = c / 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); + }; + + const foreground = parse(getComputedStyle(element).color); + let node: Element | null = element; + let background: [number, number, number, number] | null = null; + while (node && !background) { + const parsed = parse(getComputedStyle(node).backgroundColor); + if (parsed && parsed[3] > 0) background = parsed; + node = node.parentElement; + } + if (!foreground || !background) return null; + + const light = Math.max(luminance(foreground), luminance(background)); + const dark = Math.min(luminance(foreground), luminance(background)); + + // The class list of the element and of any descendant carrying the text: + // the copy is sometimes a wrapper's child. + const classes = [element, ...Array.from(element.querySelectorAll("*"))] + .map((n) => n.className) + .filter((c): c is string => typeof c === "string") + .join(" "); + + return { ratio: (light + 0.05) / (dark + 0.05), classes }; + }, selector); + + expect(result, `${selector} is not on the page`).not.toBeNull(); + expect( + result!.classes, + `${selector} renders material copy in the muted token`, + ).not.toContain("text-muted-foreground"); + expect( + result!.ratio, + `${selector} is below the AA floor for body text`, + ).toBeGreaterThanOrEqual(4.5); +} + +async function openProfileRecord(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + const entry = page.locator( + '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-profile"]', + ); + await expect(entry).toHaveCount(1); + await entry.click(); + await expect(page).toHaveURL(/\/profile$/); +} + +async function leaveRecord(page: Page) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + ); +} + +test.describe("shared record accessibility states", () => { + test.describe.configure({ mode: "serial" }); + test.use({ storageState: SCOPE_A11Y_STORAGE_STATE_PATH }); + + test("[success] the shared profile is scannable, ordered and escapable", async ({ + page, + }) => { + await openProfileRecord(page); + await expect(page.locator('[data-slot="profile-summary"]')).toBeVisible(); + + await scanForViolations(page); + // Whose record this is comes before what is in it. A reader who meets the + // record before the banner has already read somebody else's health data + // believing it is their own. + await expectReaderOrder(page, [ + '[data-slot="shared-record-banner"]', + '[data-slot="shared-record-banner-context"]', + '[data-slot="profile-summary"]', + ]); + await expectContentContrast( + page, + '[data-slot="shared-record-banner-context"]', + ); + await expectKeyboardReach(page, '[data-slot="shared-record-banner-exit"]'); + + await leaveRecord(page); + }); + + test("[empty] nothing shared yet reads as an answer, not a failure", async ({ + page, + }) => { + // The delegate's own shared-access panel. They hold grants and have given + // none, so the "who can open my record" card is genuinely empty — which is + // a state the suite claimed to cover and never entered. + await page.goto("/settings/access"); + await expect( + page.locator('[data-slot="grants-given-card"] [data-slot="empty-state"]'), + ).toBeVisible(); + + await scanForViolations(page); + await expectReaderOrder(page, [ + '[data-slot="grant-invite-card"]', + '[data-slot="grants-given-card"]', + '[data-slot="grants-given-card"] [data-slot="empty-state"]', + ]); + await expectContentContrast( + page, + '[data-slot="grants-given-card"] [data-slot="empty-state"] p', + ); + // The way out of an empty state is the act that fills it. + await expectKeyboardReach(page, '[data-slot="grant-invite-identifier"]'); + }); + + test("[loading] the hold before the active record is known is announced", async ({ + page, + }) => { + const gate = '[data-slot="record-scope-hydration-gate"]'; + + // Hold `/api/auth/me` open for exactly as long as the assertions need, + // then release it once. + // + // A fixed sleep in the handler cannot do that, and it was the cause of a + // CI-only failure here. Three things go wrong with it. The hold ends 1500 + // ms after the FIRST request whatever the axe scan is doing, so on a slow + // runner the state being scanned is not the state the test names. Every + // retry and every refetch queues another sleeping handler, so the number + // of live handlers is a function of machine speed. And a handler still + // sleeping when its request is cancelled — a navigation, a React Query + // abort, teardown — answers `route.continue()` with "Route is already + // handled", which is what the runner reported. + // + // An explicit gate fixes all three: one release, at a point the test + // chooses, after which the hold is provably over. + let release!: () => void; + const held = new Promise((resolve) => { + release = resolve; + }); + let holds = 0; + + await page.route("**/api/auth/me", async (route) => { + holds += 1; + await held; + // Continuing a route the browser has already given up on is not a + // failure of anything this test asserts, and it is the one call here + // that can reject for a reason that has nothing to do with the subject. + await route.continue().catch(() => {}); + }); + + await page.goto("/measurements"); + await expect(page.locator(gate)).toBeVisible(); + + await scanForViolations(page); + await expectAnnounced(page, gate); + // The label lives inside the region, so a reader that reaches the region + // reaches the sentence. A label rendered as a sibling would be announced + // separately or not at all. + await expectReaderOrder(page, [gate, `${gate} .sr-only`]); + + // The hold was real. Without this the whole test passes on a page that was + // merely slow, and the state it claims to have scanned would be a + // coincidence rather than something it caused. + expect( + holds, + "nothing was held, so the gate was not this test's doing", + ).toBeGreaterThan(0); + + release(); + await page.unroute("**/api/auth/me"); + // And the gate was waiting on that hold rather than on something else: + // releasing it ends the state. An entry assertion with no exit assertion + // cannot tell a hold apart from a stuck page. + await expect(page.locator(gate)).toHaveCount(0); + }); + + test("[error] a failed read says so and hands the retry to a keyboard", async ({ + page, + }) => { + await page.route("**/api/profile/summary**", (route) => + route.fulfill({ status: 500, body: "{}" }), + ); + await openProfileRecord(page); + await expect(page.locator('[data-slot="query-error-card"]')).toBeVisible(); + + await scanForViolations(page); + await expectReaderOrder(page, [ + '[data-slot="shared-record-banner"]', + '[data-slot="query-error-card"]', + '[data-slot="query-error-retry"]', + ]); + // The sentence that says the read failed. Muted here would put "this did + // not load" in the same register as a timestamp. + await expectContentContrast(page, '[data-slot="query-error-card"] p'); + await expectKeyboardReach(page, '[data-slot="query-error-retry"]'); + + await page.unroute("**/api/profile/summary**"); + await leaveRecord(page); + }); + + test("[refusal] a surface sharing does not cover keeps a way back", async ({ + page, + }) => { + await openProfileRecord(page); + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + + await scanForViolations(page); + // The explanation before the button. A reader who meets "leave this + // record" first is being offered an act before being told why. + await expectReaderOrder(page, [ + '[data-slot="shared-record-unavailable"]', + '[data-slot="shared-record-unavailable"] p', + '[data-slot="shared-record-unavailable-leave"]', + ]); + await expectContentContrast( + page, + '[data-slot="shared-record-unavailable"] p', + ); + await expectKeyboardReach( + page, + '[data-slot="shared-record-unavailable-leave"]', + ); + + await page.locator('[data-slot="shared-record-unavailable-leave"]').click(); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + }); +}); diff --git a/e2e/v137-sharing-managed-profiles.spec.ts b/e2e/v137-sharing-managed-profiles.spec.ts new file mode 100644 index 000000000..6fc2c9aaa --- /dev/null +++ b/e2e/v137-sharing-managed-profiles.spec.ts @@ -0,0 +1,713 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + E2E_LEVEL_RECORDS, + E2E_SCOPE_DELEGATE, + E2E_SCOPE_RECORDS, + GUARDIAN_STORAGE_STATE_PATH, + SCOPE_DELEGATE_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; +import { + clearGuardianProfiles, + refreshGuardianStepUp, +} from "./setup/managed-profile-fixture"; + +const DOMAIN_READS = { + measurements: "/api/measurements", + medications: "/api/medications", + labs: "/api/labs", + profile: "/api/profile/summary", + illness: "/api/illness/episodes", + mind: "/api/mood-entries", + cycle: "/api/cycle/calendar", + documents: "/api/documents/inbound", +} as const; + +/** + * v1.37.0 — the record-session assertion the app is currently sending. + * + * The two API probes in this file are deliberate: they check a status code the + * UI does not surface (a MANAGE-only generated read) and a payload the UI + * paginates. But a bare `fetch` inside `page.evaluate` carries no + * record-session headers, and the fence refuses an unasserted request on any + * session that has been inside a shared record — so the probes started failing + * with 403 for a reason that had nothing to do with what they test. + * + * They are NOT an old-bundle simulation and must not become one: that case is + * covered deliberately in `src/__tests__/record-session-fence-compat.test.ts` + * and `tests/integration/record-session-fence.test.ts`, where the 403 and the + * recovery are the subject. Here the subject is the grant level, so the probe + * asserts exactly what the app beside it asserts — copied off a real app + * request rather than computed, so it cannot drift from what the client + * actually believes. + */ +function trackFenceAssertion(page: Page): () => { + "x-healthlog-record-epoch": string; + "x-healthlog-record-scope": string; +} { + // The HIGHEST epoch seen, not the most recent one observed. + // + // The counter is monotonic, and requests do not arrive in the order they + // were issued: a request from the page before a switch can be seen after one + // from the page after it, and last-writer-wins then leaves the probe holding + // a context the session has already left — a 409, intermittently, on a test + // about grant levels. Taking the maximum removes the ordering question + // rather than trying to win it. + let latest = { epoch: "bootstrap", scope: "bootstrap", seen: -1 }; + page.on("request", (request) => { + const headers = request.headers(); + const epoch = headers["x-healthlog-record-epoch"]; + const scope = headers["x-healthlog-record-scope"]; + if (epoch === undefined || scope === undefined) return; + if (!/^\d+$/.test(epoch)) return; + const value = Number(epoch); + if (value < latest.seen) return; + latest = { epoch, scope, seen: value }; + }); + return () => ({ + "x-healthlog-record-epoch": latest.epoch, + "x-healthlog-record-scope": latest.scope, + }); +} + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + await expect( + page.locator('[data-slot="account-switcher-menu"]'), + ).toBeVisible(); +} + +async function openRecord(page: Page, username: string) { + await page.goto("/"); + await openSwitcher(page); + + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); + await expect(entry).toHaveCount(1); + const accountId = await entry.getAttribute("data-account-id"); + if (accountId === null) { + throw new Error(`Account switcher entry for ${username} has no account id`); + } + const accessLevel = await entry.getAttribute("data-access-level"); + const recordKind = await entry.getAttribute("data-record-kind"); + await entry.click(); + return { accountId, accessLevel, recordKind }; +} + +async function leaveRecord(page: Page) { + await page.locator('[data-slot="shared-record-banner-exit"]').click(); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + ); +} + +function withDivergentActiveAccountAccess(body: unknown): unknown { + if (body === null || typeof body !== "object") { + throw new Error("Expected an object from /api/auth/me"); + } + + const envelope = body as { data?: unknown }; + const payload = "data" in envelope ? envelope.data : envelope; + if (payload === null || typeof payload !== "object") { + throw new Error("Expected an auth payload from /api/auth/me"); + } + + const auth = payload as { + accountAccess?: { + active?: { + access?: unknown; + level?: unknown; + canWrite?: unknown; + } | null; + }; + }; + if (!auth.accountAccess?.active) { + throw new Error("Expected a switched account-access payload"); + } + + const corrupted = { + ...auth, + accountAccess: { + ...auth.accountAccess, + active: { + ...auth.accountAccess.active, + access: "write", + level: "write", + canWrite: true, + }, + }, + }; + return "data" in envelope ? { ...envelope, data: corrupted } : corrupted; +} + +test.describe.serial("scoped sharing browser journeys", () => { + test.use({ storageState: SCOPE_DELEGATE_STORAGE_STATE_PATH }); + + for (const record of E2E_SCOPE_RECORDS) { + test(`opens only the ${record.domain} record doorway`, async ({ page }) => { + await page.goto("/"); + await openSwitcher(page); + + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${record.username}"]`, + ); + await expect(entry).toHaveCount(1); + + // The labs doorway must not pull the OCR capability probe: OCR is an + // owner surface and a scoped READ grant does not reach it. + // + // Proved with a listener rather than `waitForRequest(…, { timeout })`, + // and the difference is not stylistic. A timed wait is an instrument + // that FAILS by its own mechanics: the rejection it produces is the + // pass condition, so it has to be created early and awaited late, and in + // between it is an unhandled rejection that lands on whichever test + // happens to be finishing. Its window is wrong too — the 500 ms starts + // before the switch is even clicked, so on a slow runner it can expire + // before the labs page has loaded, which is the one moment it exists to + // watch. + // + // A listener has no timer to lose. It covers everything from before the + // click to after the record read has landed, which is strictly more than + // 500 ms, and it is asserted below with a paired positive control: the + // domain's own read must appear in the same set, so a matcher that + // stopped seeing requests fails instead of reporting an absence. + const seenPaths = new Set(); + page.on("request", (request) => { + if (request.method() !== "GET") return; + seenPaths.add(new URL(request.url()).pathname); + }); + + const read = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === DOMAIN_READS[record.domain], + ); + await entry.click(); + + await expect(page).toHaveURL(new RegExp(`${record.href}$`)); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toContainText(record.username); + await expect( + page.locator(`a[href="${record.href}"]`).first(), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toHaveCount(0); + await read; + + // The positive control first: if this path is missing the listener saw + // nothing useful and the absence below would be a statement about the + // matcher rather than about the product. + expect( + [...seenPaths], + `the ${record.domain} read never reached the request listener`, + ).toContain(DOMAIN_READS[record.domain]); + if (record.domain === "labs") { + expect( + [...seenPaths], + "a scoped labs grant pulled the owner-only OCR capability probe", + ).not.toContain("/api/labs/ocr/capability"); + } + + await leaveRecord(page); + }); + } + + test("refuses a malformed switched payload before target or owner reads mount", async ({ + page, + }) => { + await page.goto("/"); + await openSwitcher(page); + + const entry = page.locator( + '[data-slot="account-switcher-entry"][data-account-username="e2e-scope-labs"]', + ); + await expect(entry).toHaveCount(1); + + // The switch normally writes this mirror before its full reload. Remove it + // in the new document so the global preloaders cannot guess an own-record + // scope while `/api/auth/me` is still deciding whether the target is safe. + await page.addInitScript(() => { + localStorage.removeItem("healthlog-record-scope"); + }); + + let corruptAuthPayload = true; + await page.route("**/api/auth/me", async (route) => { + const response = await route.fetch(); + if (!corruptAuthPayload) { + await route.fulfill({ response }); + return; + } + await route.fulfill({ + response, + json: withDivergentActiveAccountAccess(await response.json()), + }); + }); + + const ownerOnlyReads: string[] = []; + const trackOwnerOnlyRead = (request: { + method(): string; + url(): string; + }) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if ( + [ + "/api/labs/ocr/capability", + "/api/insights/coach/nudge-status", + "/api/coach/about-me/questions", + "/api/auth/me/notification-prefs", + ].includes(path) + ) { + ownerOnlyReads.push(path); + } + }; + page.on("request", trackOwnerOnlyRead); + const targetPreloads: string[] = []; + const trackTargetPreload = (request: { + method(): string; + url(): string; + }) => { + if (request.method() !== "GET") return; + const path = new URL(request.url()).pathname; + if ( + [ + "/api/dashboard/snapshot", + "/api/medications", + "/api/medications/compliance", + ].includes(path) + ) { + targetPreloads.push(path); + } + }; + page.on("request", trackTargetPreload); + try { + await entry.click(); + + await expect(page).toHaveURL(/\/labs$/); + await expect( + page.locator('[data-slot="invalid-record-access-refusal"]'), + ).toBeVisible(); + await expect( + page.locator('[data-slot="shared-record-unavailable-leave"]'), + ).toBeVisible(); + await expect(page.locator('[data-tour-id="labs-hero"]')).toHaveCount(0); + await expect( + page.locator('[data-slot="shared-record-banner"]'), + ).toHaveCount(0); + + // Both global preloader routes must remain inert while the malformed + // response is refused, even though the scope mirror was intentionally + // cleared before each reload. + await page.goto("/"); + await expect( + page.locator('[data-slot="invalid-record-access-refusal"]'), + ).toBeVisible(); + await page.goto("/medications"); + await expect( + page.locator('[data-slot="invalid-record-access-refusal"]'), + ).toBeVisible(); + expect(ownerOnlyReads).toEqual([]); + expect(targetPreloads).toEqual([]); + + // Leave the refused record, then demand fresh own-record reads. This + // catches a response that was parked under the owner hash while the + // refusal was on screen and would otherwise paint without a new request. + corruptAuthPayload = false; + const freshDashboard = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/dashboard/snapshot", + ); + await page + .locator('[data-slot="shared-record-unavailable-leave"]') + .click(); + await expect(page).toHaveURL(/\/$/); + await freshDashboard; + + const freshMedications = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === "/api/medications", + ); + await page.goto("/medications"); + await freshMedications; + } finally { + page.off("request", trackOwnerOnlyRead); + page.off("request", trackTargetPreload); + await page.unroute("**/api/auth/me"); + } + }); + + test("[J1-adult-levels-lifecycle] keeps an adult READ grant read-only and outside Settings", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === "READ", + ); + if (!record) throw new Error("READ fixture is missing"); + + const { accessLevel, recordKind } = await openRecord(page, record.username); + expect(accessLevel).toBe("view"); + expect(recordKind).toBe("shared"); + + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-access-level", "view"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + await expect(banner).toContainText(record.username); + + await page.goto("/measurements"); + await expect(page.locator('[data-slot="measurement-add"]')).toHaveCount(0); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("[J1-adult-levels-lifecycle] lets an adult WRITE grant add only to the selected record", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.access === "WRITE", + ); + if (!record) throw new Error("WRITE fixture is missing"); + + const { accountId, accessLevel, recordKind } = await openRecord( + page, + record.username, + ); + expect(accessLevel).toBe("view-and-add"); + expect(recordKind).toBe("shared"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-account-id", accountId); + await expect(banner).toHaveAttribute("data-access-level", "view-and-add"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + + await page.goto("/measurements"); + await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible(); + const post = page.waitForResponse( + (response) => + response.request().method() === "POST" && + new URL(response.url()).pathname === "/api/measurements", + ); + await page.locator('[data-slot="measurement-add"]').click(); + await page.locator("#sys").fill("124"); + await page.locator("#dia").fill("78"); + await page.getByRole("button", { name: /^save$/i }).click(); + expect((await post).status()).toBeLessThan(300); + + // Read the value back THROUGH THE APP rather than with a hand-rolled + // probe. + // + // The probe copied the fence headers off a live request so it would not be + // refused — and then raced them: requests do not arrive in the order they + // were issued, so it could pick up a context the session had already left + // and answer 409 on a test about grant levels. Copying the app's state to + // stand beside the app is the wrong shape; asking the app is the right one, + // and it is also closer to what this test claims — that the reading landed + // in the SELECTED record and is visible there. + await page.reload(); + await expect(page.locator('[data-slot="measurement-add"]')).toBeVisible(); + await expect(page.getByText("124", { exact: false }).first()).toBeVisible({ + timeout: 20_000, + }); + await expect(banner).toHaveAttribute("data-account-id", accountId); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("[J2-manage-edits-without-settings] opens MANAGE-only generated reads without opening adult Settings", async ({ + page, + }) => { + const fenceHeaders = trackFenceAssertion(page); + const record = E2E_LEVEL_RECORDS.find( + (candidate) => + candidate.access === "MANAGE" && candidate.recordKind === "shared", + ); + if (!record) throw new Error("adult MANAGE fixture is missing"); + + const { accessLevel, recordKind } = await openRecord(page, record.username); + expect(accessLevel).toBe("manage"); + expect(recordKind).toBe("shared"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + await expect(banner).toHaveAttribute("data-record-kind", "shared"); + + await page.goto("/"); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + const generatedRead = await page.evaluate(async (headers) => { + const response = await fetch("/api/dashboard/summary", { headers }); + return response.status; + }, fenceHeaders()); + expect(generatedRead).toBeLessThan(300); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toHaveCount(0); + + await page.goto("/settings/account"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + await leaveRecord(page); + }); + + test("[J2-manage-edits-without-settings] keeps managed Settings on the active record and integrations status-only", async ({ + page, + }) => { + const record = E2E_LEVEL_RECORDS.find( + (candidate) => candidate.recordKind === "managed", + ); + if (!record) throw new Error("managed record fixture is missing"); + + const { accountId, accessLevel, recordKind } = await openRecord( + page, + record.username, + ); + expect(accessLevel).toBe("manage"); + expect(recordKind).toBe("managed"); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-account-id", accountId); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + + await page.goto("/settings/account"); + const profile = page.locator('[data-record-settings-family="profile"]'); + await expect(profile).toBeVisible(); + await expect(profile).toHaveAttribute("data-record-id", accountId); + + const integrationRead = page.waitForResponse( + (response) => + response.request().method() === "GET" && + new URL(response.url()).pathname === + "/api/record-settings/integrations", + ); + await page.goto("/settings/integrations"); + const integrationResponse = await integrationRead; + expect(integrationResponse.status()).toBeLessThan(300); + const integrationPayload = (await integrationResponse.json()) as { + data?: { recordId?: string }; + }; + expect(integrationPayload.data?.recordId).toBe(accountId); + const integrations = page.locator( + '[data-record-settings-family="integrations"]', + ); + await expect(integrations).toBeVisible(); + await expect(integrations).toHaveAttribute("data-record-id", accountId); + await expect( + integrations.locator("button, input, select, textarea"), + ).toHaveCount(0); + + await page.goBack(); + await expect(profile).toHaveAttribute("data-record-id", accountId); + await page.goForward(); + await expect(integrations).toHaveAttribute("data-record-id", accountId); + await expect(banner).toHaveAttribute("data-account-id", accountId); + + await leaveRecord(page); + await page.goto("/measurements"); + await expect(banner).toHaveCount(0); + }); + + test("[J1-adult-levels-lifecycle] refuses direct ungranted routes before coach reads start", async ({ + page, + }) => { + await openRecord(page, "e2e-scope-measurements"); + const protectedCoachReads: string[] = []; + const trackCoachRead = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if ( + [ + "/api/insights/coach/nudge-status", + "/api/coach/about-me/questions", + "/api/auth/me/notification-prefs", + ].includes(path) + ) { + protectedCoachReads.push(path); + } + }; + page.on("request", trackCoachRead); + try { + await page.goto("/coach"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + expect(protectedCoachReads).toEqual([]); + + await page.goto("/settings/access"); + await expect( + page.locator('[data-slot="shared-record-unavailable"]'), + ).toBeVisible(); + expect(protectedCoachReads).toEqual([]); + } finally { + page.off("request", trackCoachRead); + } + await leaveRecord(page); + }); +}); + +/** + * [J3-managed-profile-lifecycle] — the half of the release goal that had no + * browser at all until this plan. + * + * The routes have shipped with integration coverage since the profile family + * landed, and nothing under `src/components` or `src/app` called any of them. + * These two tests are the person's side of it: an adult with no managed profile + * creates one, finds it in the switcher, enters it, leaves it, invites a second + * Guardian and is told — before acting rather than by a refusal afterwards — + * that the invitation does not count until it is accepted. + * + * Its own session jar and its own account, because this is the only account in + * the suite with a second factor enrolled: every route here resolves + * `requireFreshMfa`, which refuses an unenrolled account outright. + */ +test.describe.serial("managed profile browser journey", () => { + test.use({ storageState: GUARDIAN_STORAGE_STATE_PATH }); + + const PROFILE_NAME = "E2E managed record"; + + test.beforeEach(async () => { + await clearGuardianProfiles(); + await refreshGuardianStepUp(); + }); + + test.afterAll(async () => { + await clearGuardianProfiles(); + }); + + async function createProfile(page: Page): Promise { + await page.goto("/settings/access"); + const card = page.locator('[data-slot="managed-profile-card"]'); + await expect(card).toBeVisible(); + await card.locator('[data-slot="managed-profile-name"]').fill(PROFILE_NAME); + await card.locator('[data-slot="managed-profile-create-submit"]').click(); + await expect( + card.locator('[data-slot="managed-profile-created"]'), + ).toBeVisible(); + } + + test("[J3-managed-profile-lifecycle] creates a record with no login and reaches it from the switcher", async ({ + page, + }) => { + await createProfile(page); + + // The row appears without a reload, which is the account-payload + // invalidation doing its job rather than a lucky refetch. + const row = page.locator('[data-slot="managed-profile-row"]'); + await expect(row).toHaveCount(1); + const profileId = await row.getAttribute("data-managed-profile-id"); + expect(profileId).not.toBeNull(); + + // The switcher lists it as a managed record. Asserted on the stable + // attribute rather than on the label, so the copy stays free to change. + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-id="${profileId}"]`, + ); + await expect(entry).toHaveCount(1); + await expect(entry).toHaveAttribute("data-record-kind", "managed"); + await entry.click(); + + // Inside it, the banner says which kind of record this is. + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toBeVisible(); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + // The WORDING, not only the attribute, and this is the one place in the + // suite where pinning copy is the right call rather than the lazy one: the + // claim is that the banner NAMES it as a managed record, and the attribute + // is not what a person reads. `recordKindLabel`'s ternary chooses between + // two sentences that mean different things, and inverting it left every + // attribute assertion green while telling a Guardian they were inside an + // ordinary shared record. The pure function is pinned in + // `src/components/layout/__tests__/shared-record-banner-label.test.tsx`; + // this line proves the rendered banner really calls it. + await expect( + page.locator('[data-slot="shared-record-banner-context"]'), + ).toContainText("Managed profile"); + + // And back out, with the record still on the list. + // + // Leaving ends in a full document reload, and a `goto` issued while that + // reload is still in flight is aborted by Chromium — `net::ERR_ABORTED`, + // reproducibly at CI's worker count and not at one. The banner being gone + // is satisfied by the pre-reload DOM, so it cannot be the thing waited on. + // Retrying the navigation is the honest fix: the claim is that the record + // survives the round trip, not that the first navigation attempt wins a + // race with the app's own. + await leaveRecord(page); + await expect(async () => { + await page.goto("/settings/access"); + await expect( + page.locator( + `[data-slot="managed-profile-row"][data-managed-profile-id="${profileId}"]`, + ), + ).toHaveCount(1); + }).toPass({ timeout: 15_000 }); + }); + + test("[J3-managed-profile-lifecycle] states the last-guardian floor before an invitation is accepted", async ({ + page, + }) => { + await createProfile(page); + + const guardians = page.locator('[data-slot="managed-profile-guardians"]'); + await expect(guardians).toBeVisible(); + + // One guardian, and it is the creator: no access here can be ended, and + // the card says so rather than offering a control that would 404. + await expect( + guardians.locator('[data-slot="managed-profile-guardian"]'), + ).toHaveCount(1); + await expect( + guardians.locator('[data-slot="managed-profile-guardian-remove"]'), + ).toHaveCount(0); + await expect( + guardians.locator('[data-slot="managed-profile-sole-guardian"]'), + ).toBeVisible(); + + // Invite a second guardian. + await guardians + .locator('[data-slot="managed-profile-guardian-identifier"]') + .fill(E2E_SCOPE_DELEGATE.username); + await guardians + .locator('[data-slot="managed-profile-guardian-submit"]') + .click(); + await expect( + guardians.locator('[data-slot="managed-profile-guardian-invited"]'), + ).toBeVisible(); + + // The roster shows them, and shows them as PENDING — which is the release + // goal's sentence in the only form a browser can falsify: the invitation + // reached the server, came back as a grant that confers nothing yet, and + // the panel says which of the two states it is in. Delete the invitation + // feature and this line goes red. + await expect( + guardians.locator( + '[data-slot="managed-profile-guardian"][data-guardian-state="PENDING"]', + ), + ).toHaveCount(1); + + // And the floor is unchanged BECAUSE of that state: the sole-guardian + // sentence is still on screen with a second person already invited. + // + // The `managed-profile-pending-note` line that used to sit here was + // removed rather than kept. That node renders unconditionally inside the + // invite form, so the assertion passed with the invitation feature deleted + // — it proved the copy exists, which is a claim about a string and not + // about anything the journey did. The note's own leg lives in + // `managed-profile-affordances.test.tsx`, where deleting the node makes it + // fail; here it would only have been a second look at the same string. + await expect( + guardians.locator('[data-slot="managed-profile-sole-guardian"]'), + ).toBeVisible(); + }); +}); diff --git a/messages/de.json b/messages/de.json index c75f67bee..f3cf1a8af 100644 --- a/messages/de.json +++ b/messages/de.json @@ -341,6 +341,7 @@ }, "cycle": "Zyklus", "labs": "Laborwerte", + "profile": "Profil", "vorsorge": "Vorsorge", "illness": "Krankheit", "documents": "Dokumente", @@ -4433,6 +4434,121 @@ "system": "System" } }, + "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profil", + "displayName": "Anzeigename", + "height": "Größe (cm)", + "dateOfBirth": "Geburtsdatum", + "gender": "Geschlecht", + "notSpecified": "Nicht angegeben", + "male": "Männlich", + "female": "Weiblich", + "other": "Andere Angabe", + "locale": "Sprache", + "systemDefault": "Systemstandard", + "timezone": "Zeitzone", + "units": "Einheiten", + "metric": "Metrisch", + "imperial": "Imperial", + "timeFormat": "Zeitformat", + "automatic": "Automatisch", + "hour12": "12 Stunden", + "hour24": "24 Stunden", + "dateFormat": "Datumsformat", + "dateDmy": "Tag / Monat / Jahr", + "dateMdy": "Monat / Tag / Jahr", + "dateYmd": "Jahr / Monat / Tag" + }, + "modules": { + "title": "Module" + }, + "notifications": { + "title": "Benachrichtigungen", + "moodReminder": "Stimmungserinnerung aktivieren", + "reminderHour": "Uhrzeit der Stimmungserinnerung", + "lowStockRunwayDays": "Tage bis zum niedrigen Bestand", + "reorderLeadDays": "Vorlaufzeit für Nachbestellung" + }, + "thresholds": { + "title": "Zielwerte", + "metric": "Messwert", + "minimum": "Minimum", + "maximum": "Maximum" + }, + "coach": { + "title": "Coach", + "disable": "Coach deaktivieren", + "tone": "Ton", + "warm": "Herzlich", + "neutral": "Neutral", + "concise": "Knapp", + "verbosity": "Ausführlichkeit", + "brief": "Kurz", + "default": "Standard", + "detailed": "Ausführlich", + "defaultWindow": "Standardzeitraum", + "last7days": "Letzte 7 Tage", + "last30days": "Letzte 30 Tage", + "last90days": "Letzte 90 Tage", + "allTime": "Gesamter Zeitraum", + "showEvidence": "Evidenz standardmäßig anzeigen", + "excludedMetrics": "Ausgeschlossene Messwerte", + "dataClusters": "Datenbereiche", + "customClusters": "Eigene Auswahl von Datenbereichen verwenden", + "metrics": { + "bp": "Blutdruck", + "weight": "Gewicht", + "pulse": "Puls", + "mood": "Stimmung", + "compliance": "Medikamenteneinnahme", + "hrv": "Herzfrequenzvariabilität", + "sleep": "Schlaf", + "restingHr": "Ruhepuls", + "steps": "Schritte", + "medications": "Medikamente", + "anthropometrics": "Körpermaße" + }, + "clusters": { + "cardio": "Herz-Kreislauf", + "body": "Körperzusammensetzung", + "activity": "Aktivität", + "workouts": "Trainings", + "sleep": "Schlaf", + "mood": "Stimmung", + "glucose": "Glukose", + "medication": "Medikation", + "mobility": "Mobilität", + "environment": "Umgebung" + } + }, + "insights": { + "title": "Erkenntnisse", + "sections": "Übersichtsbereiche", + "tiles": "Navigationskacheln", + "order": "Reihenfolge", + "unknownItem": "Nicht verfügbare Erkenntnis", + "sectionLabels": { + "wellnessScores": "Wellnesswerte", + "dailyBriefing": "Tagesüberblick", + "vitals": "Vitalwerte", + "trends": "Trends", + "periodReview": "Periodenrückblick", + "cycleSummary": "Zyklusübersicht", + "signals": "Signale", + "rhythmEvents": "Rhythmusereignisse", + "healthStatus": "Gesundheitsstatus", + "breathing": "Atmung", + "labsChanges": "Laborveränderungen", + "ecg": "EKG" + } + } + }, + "unavailableTitle": "In diesem geteilten Datensatz nicht verfügbar", + "unavailableDescription": "Dieser Einstellungsbereich ist beim Anzeigen des Datensatzes einer anderen Person bewusst nicht verfügbar.", + "integrationStatusDescription": "Verbindungsstatus für diesen verwalteten Datensatz. Verbindungssteuerungen sind nicht verfügbar." + }, "sections": { "account": { "title": "Konto", @@ -9406,7 +9522,7 @@ }, "unavailable": { "title": "Gehört nicht zum geteilten Zugriff", - "description": "Geteilt wird die Gesundheitsakte selbst. Einstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.", + "description": "Geteilt wird die Gesundheitsakte selbst, einschließlich der Vorgeschichte unter Anamnese. Kontoeinstellungen, verbundene Dienste, Benachrichtigungen und die KI-Funktionen bleiben bei der Person, der das Konto gehört.", "leave": "Zurück zu meiner Akte" }, "state": { @@ -9418,6 +9534,10 @@ "row": { "readOnly": "Darf die Akte lesen", "canWrite": "Darf lesen und Einträge anlegen", + "canManage": "Darf lesen, Einträge anlegen sowie Einträge ändern und löschen", + "entireRecord": "Umfasst die gesamte Akte", + "sections": "Umfasst: {sections}", + "noSections": "Umfasst nichts: dieser Zugriff öffnet keinen Teil der Akte", "invited": "Eingeladen", "accepted": "Angenommen", "expires": "Läuft ab", @@ -9439,6 +9559,19 @@ "accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.", "accessWriteLabel": "Darf lesen und hinzufügen", "accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.", + "accessManageLabel": "Darf verwalten", + "accessManageCapability": "Sie kann Einträge in deiner Akte anlegen, ändern und löschen, auch solche, die du selbst erfasst hast, und sie kann die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.", + "manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.", + "scopeLegend": "Was die Person öffnen darf", + "scopeAllLabel": "Gesamte Akte", + "scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.", + "scopeSomeLabel": "Nur die Bereiche, die du auswählst", + "scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.", + "scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.", + "scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.", + "scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.", + "scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.", + "scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.", "expiresLabel": "Zugriff endet am (optional)", "expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.", "submit": "Einladung senden", @@ -9449,7 +9582,11 @@ "errorSelf": "Das ist dein eigenes Konto.", "errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.", "errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.", - "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen." + "errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.", + "errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.", + "errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.", + "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", + "errorOffline": "Das hat den Server nicht erreicht. Deine Einladung steht noch hier — prüfe deine Verbindung und versuche es erneut." }, "given": { "title": "Wer meine Akte öffnen kann", @@ -9474,6 +9611,7 @@ "accept": "Annehmen", "accepting": "Wird angenommen…", "writeConsent": "Wenn du annimmst, wird das, was du hinzufügst, Teil der Akte von {name}. Ändern oder entfernen kannst du deine Einträge danach nicht mehr; das kann nur {name}.", + "manageConsent": "Wenn du annimmst, kannst du die Akte von {name} ergänzen und außerdem ändern oder löschen, was schon darin steht, auch Einträge von {name} selbst. Alles, was du tust, steht mit deinem Namen in der Aktivitätsübersicht, und {name} kann deinen Zugriff jederzeit beenden.", "open": "Öffnen", "renounce": "Zurückgeben", "renounceTitle": "Akte von {name} zurückgeben?", @@ -9489,7 +9627,8 @@ "loadError": "Die Aktivität konnte nicht geladen werden.", "opened": "{name} hat deine Akte {count}× geöffnet", "acted": "{name} hat etwas in deiner Akte geändert", - "deletedAccount": "Ein gelöschtes Konto" + "deletedAccount": "Ein gelöschtes Konto", + "truncated": "Diese Liste zeigt die neueste Aktivität, nicht die gesamte." }, "delegate": { "addOnlyNote": "Du kannst zu dieser Akte etwas hinzufügen. Einträge ändern oder löschen kann nur {name}." @@ -9499,8 +9638,65 @@ "description": "Akten, die andere mit dir geteilt haben.", "levelRead": "Darf lesen", "levelWrite": "Darf lesen und hinzufügen", + "kindShared": "Geteilte Akte", + "kindManaged": "Verwaltetes Profil", "open": "Akte von {name} öffnen" }, + "managed": { + "title": "Profile, die du betreust", + "description": "Gesundheitsakten ohne eigenen Login.", + "explainer": "Ein verwaltetes Profil ist eine Gesundheitsakte für jemanden, der sich nicht selbst anmeldet — ein Kind oder eine Person, um die du dich kümmerst. Es hat keinen Login und keine E-Mail-Adresse, und du wirst die erste betreuende Person. Später kannst du weitere hinzufügen, und ihr alle könnt in die Akte wechseln, um sie zu pflegen.", + "nameLabel": "Name", + "namePlaceholder": "Der Name, an dem du diese Akte erkennst", + "nameHint": "Bis zu 80 Zeichen. Wird überall dort angezeigt, wo diese Akte auftaucht.", + "nameRequired": "Gib der Akte einen Namen.", + "nameTooLong": "Dieser Name ist länger als 80 Zeichen.", + "dobLabel": "Geburtsdatum (optional)", + "dobHint": "Lass das Feld leer, wenn du keines hinterlegen willst. Aus einem Jahrgang wird nichts abgeleitet.", + "localeLabel": "Sprache dieser Akte", + "localeHint": "Die Sprache für Texte, die in diese Akte geschrieben werden. Deine eigenen Ansichten bleiben in deiner Sprache.", + "timezoneHint": "Die Zeitzone, in der die Tage dieser Akte gezählt werden.", + "timezoneInvalid": "Diese Zeitzone kennt der Browser nicht.", + "create": "Profil anlegen", + "creating": "Wird angelegt…", + "created": "{name} ist eingerichtet. Über das Kontomenü kannst du in diese Akte wechseln.", + "errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut.", + "errorOffline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.", + "errorInvalid": "Der Server hat diese Angaben abgelehnt. Prüfe Name, Datum und Zeitzone.", + "errorFailed": "Das Profil konnte nicht angelegt werden.", + "delete": "Profil löschen", + "deleteTitle": "{name} löschen?", + "deleteBody": "Alles, was für {name} erfasst wurde, verschwindet mit dem Profil — Messwerte, Medikamente, Dokumente und Notizen. Das lässt sich nicht rückgängig machen.", + "deleteGuardiansOne": "{count} Person betreut diese Akte derzeit und verliert sie.", + "deleteGuardiansFew": "{count} Personen betreuen diese Akte derzeit und verlieren sie.", + "deleteGuardiansOther": "{count} Personen betreuen diese Akte derzeit und verlieren sie.", + "deleteFloorNote": "Das ist auch der Ausweg, wenn du die letzte betreuende Person bist: Du darfst die Akte beenden, aber nicht ohne Betreuung zurücklassen.", + "deleteConfirm": "Profil löschen", + "guardiansTitle": "Wer sich um {name} kümmert", + "guardianYou": "(du)", + "guardianSince": "Betreut diese Akte seit {date}", + "guardianPending": "Eingeladen am {date}, noch nicht angenommen", + "guardianRemove": "Zugriff beenden", + "guardianRemoveTitle": "Zugriff von {name} beenden?", + "guardianRemoveBody": "{name} kann {profile} dann nicht mehr öffnen. Was diese Person eingetragen hat, bleibt in der Akte.", + "guardianWithdrawBody": "Die Einladung an {name} wird zurückgezogen. Die Person hat {profile} nie geöffnet, in der Akte ändert sich nichts.", + "guardianRemoveConfirm": "Zugriff beenden", + "soleGuardian": "Du bist die einzige Person, die sich um {name} kümmert — hier gibt es also keinen Zugriff, den du beenden könntest. Lade jemanden ein und warte auf die Annahme, gib deinen eigenen Zugriff unter „Akten, die ich öffnen kann“ zurück, oder lösche das Profil.", + "guardianInviteLabel": "Weitere betreuende Person einladen", + "guardianInvite": "Einladung senden", + "guardianInviting": "Wird gesendet…", + "guardianInvited": "Einladung an {name} gesendet.", + "guardianExpiresHint": "Optional. Der Zugriff endet am Ende dieses Tages in deiner Zeitzone.", + "guardianPendingNote": "Eine eingeladene Person zählt erst, wenn sie annimmt. Bis dahin bist du weiterhin die letzte, und die Rückgabe deines eigenen Zugriffs wird weiterhin abgelehnt.", + "guardianErrorDuplicate": "Dieses Konto hat bereits Zugriff auf diese Akte.", + "guardianErrorManagedInvitee": "Ein verwaltetes Profil kann keine andere Akte betreuen. Lade ein Konto ein, das sich anmeldet.", + "guardianErrorNoAccount": "Auf dieser Instanz gibt es kein Konto mit diesem Namen oder dieser E-Mail-Adresse.", + "guardianErrorRateLimit": "Zu viele Einladungen in der letzten Stunde. Warte etwas und versuche es erneut.", + "guardianErrorFailed": "Die Einladung konnte nicht gesendet werden.", + "errorMfaRequired": "Dafür ist ein zweiter Faktor nötig, und dein Konto hat noch keinen. Richte unter Einstellungen → Sicherheit einen ein und versuche es dann erneut.", + "rosterLoadError": "Es ließ sich nicht laden, wer sich um diese Akte kümmert.", + "errorRateLimit": "Zu viele Profile in der letzten Stunde angelegt. Warte etwas und versuche es erneut." + }, "toast": { "savedTo": "In der Akte von {name} gespeichert." }, @@ -9515,7 +9711,143 @@ "medicationSideEffectCreate": "{name} hat eine Nebenwirkung notiert", "medicationCreate": "{name} hat ein Medikament angelegt", "medicationIntake": "{name} hat eine Dosis eingetragen", - "medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen" + "medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen", + "measurementUpdate": "{name} hat einen Messwert geändert", + "measurementDelete": "{name} hat einen Messwert gelöscht", + "measurementDeleteBulk": "{name} hat mehrere Messwerte auf einmal gelöscht", + "measurementRestore": "{name} hat einen Messwert wiederhergestellt", + "measurementReminderCreate": "{name} hat eine Messerinnerung angelegt", + "measurementReminderUpdate": "{name} hat eine Messerinnerung geändert", + "measurementReminderDelete": "{name} hat eine Messerinnerung gelöscht", + "measurementReminderComplete": "{name} hat eine Messerinnerung erledigt", + "measurementReminderSatisfy": "{name} hat eine Messerinnerung abgehakt", + "labResultUpdate": "{name} hat einen Laborwert geändert", + "labResultDelete": "{name} hat einen Laborwert gelöscht", + "labResultRestore": "{name} hat einen Laborwert wiederhergestellt", + "biomarkerUpdate": "{name} hat einen Biomarker geändert", + "biomarkerDelete": "{name} hat einen Biomarker gelöscht", + "allergyUpdate": "{name} hat eine Allergie geändert", + "allergyDelete": "{name} hat eine Allergie gelöscht", + "familyHistoryUpdate": "{name} hat einen Eintrag zur Familienanamnese geändert", + "familyHistoryDelete": "{name} hat einen Eintrag zur Familienanamnese gelöscht", + "illnessEpisodeUpdate": "{name} hat einen Krankheitseintrag geändert", + "illnessEpisodeDelete": "{name} hat einen Krankheitseintrag gelöscht", + "illnessEpisodeResolve": "{name} hat eine Erkrankung als beendet markiert", + "illnessEpisodeRestore": "{name} hat einen Krankheitseintrag wiederhergestellt", + "illnessDayLogUpsert": "{name} hat einen Krankheitstag erfasst", + "medicationUpdate": "{name} hat ein Medikament geändert", + "medicationDelete": "{name} hat ein Medikament gelöscht", + "medicationGlp1Update": "{name} hat eine GLP-1-Therapie geändert", + "medicationScheduleCreate": "{name} hat einen Einnahmeplan angelegt", + "medicationScheduleUpdate": "{name} hat einen Einnahmeplan geändert", + "medicationScheduleDelete": "{name} hat einen Einnahmeplan gelöscht", + "medicationInventoryCreate": "{name} hat eine Packung angelegt", + "medicationInventoryUpdate": "{name} hat eine Packung geändert", + "medicationInventoryDelete": "{name} hat eine Packung gelöscht", + "medicationSideEffectDelete": "{name} hat eine Nebenwirkung gelöscht", + "medicationIntakeUpdate": "{name} hat eine Einnahme geändert", + "medicationIntakeDelete": "{name} hat eine Einnahme gelöscht", + "medicationIntakeBulkDelete": "{name} hat mehrere Einnahmen auf einmal gelöscht", + "medicationIntakePurge": "{name} hat die gesamte Einnahmehistorie eines Medikaments gelöscht", + "medicationIntakeImport": "{name} hat einen Einnahme-Import für ein Medikament gestartet", + "medicationIntakeHistoryImport": "{name} hat einen Import der Einnahmehistorie gestartet", + "moodEntryCreate": "{name} hat einen Stimmungseintrag angelegt", + "moodEntryUpdate": "{name} hat einen Stimmungseintrag geändert", + "moodEntryDelete": "{name} hat einen Stimmungseintrag gelöscht", + "moodDeleteBulk": "{name} hat mehrere Stimmungseinträge auf einmal gelöscht", + "moodRestore": "{name} hat einen Stimmungseintrag wiederhergestellt", + "mentalHealthCreate": "{name} hat ein Fragebogenergebnis erfasst", + "cycleDelete": "{name} hat einen Zyklus gelöscht", + "cycleDayLogUpsert": "{name} hat einen Zyklustag erfasst", + "cycleDayLogUpdate": "{name} hat einen Zyklustag geändert", + "cycleDayLogDelete": "{name} hat einen Zyklustag gelöscht", + "cyclePeriodBoundary": "{name} hat den Beginn oder das Ende einer Periode geändert", + "cycleSymptomCreate": "{name} hat ein Zyklussymptom angelegt", + "nutrientWaterWrite": "{name} hat die Trinkmenge erfasst", + "healthRecordExport": "{name} hat die Akte exportiert", + "rejectedMeasurementQuery": "{name} hat eine Messwert-Suche gesendet, die diese Akte nicht lesen konnte", + "rejectedDoseQuery": "{name} hat eine Einnahme-Suche gesendet, die diese Akte nicht lesen konnte", + "rejectedMoodQuery": "{name} hat eine Stimmungs-Suche gesendet, die diese Akte nicht lesen konnte", + "rejectedMeasurementCreate": "{name} wollte einen Messwert eintragen und wurde abgewiesen", + "rejectedMeasurementBatch": "{name} wollte mehrere Messwerte eintragen und wurde abgewiesen", + "rejectedMeasurementUpdate": "{name} wollte einen Messwert ändern und wurde abgewiesen", + "rejectedLabCreate": "{name} wollte einen Laborwert eintragen und wurde abgewiesen", + "rejectedLabUpdate": "{name} wollte einen Laborwert ändern und wurde abgewiesen", + "rejectedBiomarkerCreate": "{name} wollte einen Biomarker anlegen und wurde abgewiesen", + "rejectedBiomarkerUpdate": "{name} wollte einen Biomarker ändern und wurde abgewiesen", + "rejectedReminderCreate": "{name} wollte eine Messerinnerung anlegen und wurde abgewiesen", + "rejectedReminderUpdate": "{name} wollte eine Messerinnerung ändern und wurde abgewiesen", + "rejectedMoodCreate": "{name} wollte einen Stimmungseintrag anlegen und wurde abgewiesen", + "rejectedMoodUpdate": "{name} wollte einen Stimmungseintrag ändern und wurde abgewiesen", + "rejectedDoseCreate": "{name} wollte eine Einnahme eintragen und wurde abgewiesen", + "rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen", + "rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen", + "rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen", + "deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden", + "anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen", + "customMetricCreate": "{name} hat eine eigene Messgröße angelegt", + "customMetricUpdate": "{name} hat eine eigene Messgröße geändert", + "customMetricDelete": "{name} hat eine eigene Messgröße gelöscht", + "documentStore": "{name} hat ein Dokument hinzugefügt", + "documentUpdate": "{name} hat ein Dokument geändert", + "documentDiscard": "{name} hat ein Dokument verworfen", + "medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig", + "medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen" + }, + "section": { + "measurements": { + "label": "Messwerte", + "detail": "Gewicht, Blutdruck, Puls, Glukose, Schlaf, Training und die Werte, die du selbst verfolgst." + }, + "medications": { + "label": "Medikamente", + "detail": "Die Medikamentenliste, ihre Zeitpläne, eingenommene Dosen, Nebenwirkungen und der Bestand." + }, + "labs": { + "label": "Laborwerte", + "detail": "Laborergebnisse und die Liste der Kennwerte dahinter." + }, + "profile": { + "label": "Gesundheitliche Vorgeschichte", + "detail": "Allergien, Familienanamnese und die Angaben in deinem Gesundheitsprofil." + }, + "illness": { + "label": "Krankheiten", + "detail": "Krankheitsverläufe und die Tageseinträge darunter." + }, + "mind": { + "label": "Stimmung und Psyche", + "detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen." + }, + "cycle": { + "label": "Zyklus", + "detail": "Die Zyklusaufzeichnung." + }, + "documents": { + "label": "Dokumente", + "detail": "Alles, was im Dokumententresor liegt." + } + }, + "actionError": { + "retry": "Erneut versuchen", + "offline": "Das hat den Server nicht erreicht. Es hat sich nichts geändert — prüfe deine Verbindung und versuche es erneut.", + "rateLimited": "Zu viele Versuche hintereinander. Warte eine Minute und versuche es erneut.", + "acceptExpired": "Diese Einladung ist abgelaufen. Bitte um eine neue.", + "acceptWithdrawn": "Diese Einladung wurde zurückgezogen.", + "acceptNotPending": "Diese Einladung wurde bereits angenommen.", + "acceptNotFound": "Diese Einladung gibt es nicht mehr.", + "acceptFailed": "Die Einladung konnte nicht angenommen werden.", + "lastGuardian": "Diese Person kümmert sich als Einzige um dieses Profil, deshalb kann der Zugriff noch nicht enden. Füge zuerst eine weitere betreuende Person hinzu oder lösche das Profil.", + "alreadyEnded": "Dieser Zugriff ist bereits beendet.", + "notFound": "Diesen Zugriff gibt es nicht mehr.", + "revokeFailed": "Der Zugriff konnte nicht beendet werden.", + "renounceFailed": "Der Zugriff konnte nicht zurückgegeben werden.", + "profileNotFound": "Dieses Profil gibt es nicht mehr — es wurde vielleicht schon gelöscht, oder dein Zugriff darauf ist beendet.", + "deleteProfileFailed": "Das Profil konnte nicht gelöscht werden.", + "removeGuardianFailed": "Dieser Zugriff konnte nicht beendet werden." + }, + "anamnesis": { + "relativeNote": "Ein Eintrag zur Familienanamnese beschreibt die Angehörigen dieser Person. Trage ihn nur ein, wenn sie das hier festgehalten haben möchte." } } } diff --git a/messages/en.json b/messages/en.json index 6b46a70ef..9969e7de4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -341,6 +341,7 @@ }, "cycle": "Cycle", "labs": "Labs", + "profile": "Profile", "vorsorge": "Checkups", "illness": "Illness", "documents": "Documents", @@ -4433,6 +4434,121 @@ "system": "System" } }, + "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profile", + "displayName": "Display name", + "height": "Height (cm)", + "dateOfBirth": "Date of birth", + "gender": "Gender", + "notSpecified": "Not specified", + "male": "Male", + "female": "Female", + "other": "Other", + "locale": "Locale", + "systemDefault": "System default", + "timezone": "Timezone", + "units": "Units", + "metric": "Metric", + "imperial": "Imperial", + "timeFormat": "Time format", + "automatic": "Automatic", + "hour12": "12-hour", + "hour24": "24-hour", + "dateFormat": "Date format", + "dateDmy": "Day / month / year", + "dateMdy": "Month / day / year", + "dateYmd": "Year / month / day" + }, + "modules": { + "title": "Modules" + }, + "notifications": { + "title": "Notifications", + "moodReminder": "Enable mood reminder", + "reminderHour": "Mood reminder hour", + "lowStockRunwayDays": "Low-stock runway days", + "reorderLeadDays": "Reorder lead days" + }, + "thresholds": { + "title": "Targets", + "metric": "Metric", + "minimum": "Minimum", + "maximum": "Maximum" + }, + "coach": { + "title": "Coach", + "disable": "Disable Coach", + "tone": "Tone", + "warm": "Warm", + "neutral": "Neutral", + "concise": "Concise", + "verbosity": "Verbosity", + "brief": "Brief", + "default": "Default", + "detailed": "Detailed", + "defaultWindow": "Default window", + "last7days": "Last 7 days", + "last30days": "Last 30 days", + "last90days": "Last 90 days", + "allTime": "All time", + "showEvidence": "Show evidence by default", + "excludedMetrics": "Excluded metrics", + "dataClusters": "Data clusters", + "customClusters": "Use a custom cluster selection", + "metrics": { + "bp": "Blood pressure", + "weight": "Weight", + "pulse": "Pulse", + "mood": "Mood", + "compliance": "Medication adherence", + "hrv": "Heart rate variability", + "sleep": "Sleep", + "restingHr": "Resting heart rate", + "steps": "Steps", + "medications": "Medications", + "anthropometrics": "Body measurements" + }, + "clusters": { + "cardio": "Cardiovascular health", + "body": "Body composition", + "activity": "Activity", + "workouts": "Workouts", + "sleep": "Sleep", + "mood": "Mood", + "glucose": "Glucose", + "medication": "Medication", + "mobility": "Mobility", + "environment": "Environment" + } + }, + "insights": { + "title": "Insights", + "sections": "Overview sections", + "tiles": "Navigation tiles", + "order": "Order", + "unknownItem": "Unavailable insight", + "sectionLabels": { + "wellnessScores": "Wellness scores", + "dailyBriefing": "Daily briefing", + "vitals": "Vitals", + "trends": "Trends", + "periodReview": "Period review", + "cycleSummary": "Cycle summary", + "signals": "Signals", + "rhythmEvents": "Rhythm events", + "healthStatus": "Health status", + "breathing": "Breathing", + "labsChanges": "Lab changes", + "ecg": "ECG" + } + } + }, + "unavailableTitle": "Unavailable in this shared record", + "unavailableDescription": "This Settings area is intentionally unavailable while viewing another person's record.", + "integrationStatusDescription": "Connection status for this managed record. Connection controls are unavailable." + }, "sections": { "account": { "title": "Account", @@ -9406,7 +9522,7 @@ }, "unavailable": { "title": "Not part of shared access", - "description": "Sharing covers the health record itself. Settings, connected services, notifications and the AI features stay with the person who owns the account.", + "description": "Sharing covers the health record itself, including the health background under Anamnesis. Account settings, connected services, notifications and the AI features stay with the person who owns the account.", "leave": "Back to my record" }, "state": { @@ -9418,6 +9534,10 @@ "row": { "readOnly": "Can read the record", "canWrite": "Can read and add entries", + "canManage": "Can read the record, add to it, and change or remove what is in it", + "entireRecord": "Covers the entire record", + "sections": "Covers: {sections}", + "noSections": "Covers nothing — this access opens no part of the record", "invited": "Invited", "accepted": "Accepted", "expires": "Lapses", @@ -9439,6 +9559,19 @@ "accessReadCapability": "They can read everything this record shares. They cannot add or change anything.", "accessWriteLabel": "Can view and add", "accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.", + "accessManageLabel": "Can manage", + "accessManageCapability": "They can add, change and remove entries in your record, including ones you entered yourself, and they can read the insights generated from it. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.", + "manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.", + "scopeLegend": "What they can open", + "scopeAllLabel": "Entire record", + "scopeAllCapability": "Every part of it, the way sharing has always worked.", + "scopeSomeLabel": "Only the sections you pick", + "scopeSomeCapability": "Everything you do not pick stays closed to them.", + "scopeManageNote": "Manage access always covers the entire record.", + "scopeLeakNote": "A section can mention the rest of your health in its notes and names. Sharing a section shares everything written in it.", + "scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.", + "scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.", + "scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.", "expiresLabel": "Access ends on (optional)", "expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.", "submit": "Send invitation", @@ -9449,7 +9582,11 @@ "errorSelf": "That is your own account.", "errorInvalid": "Please enter a username or e-mail address.", "errorRateLimited": "Too many invitations. Please try again later.", - "errorFailed": "The invitation could not be sent. Please try again." + "errorFailed": "The invitation could not be sent. Please try again.", + "errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.", + "errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.", + "errorStepUp": "Confirm your second factor, then try again.", + "errorOffline": "That did not reach the server. Your invitation is still here — check your connection and try again." }, "given": { "title": "Who can open my record", @@ -9474,6 +9611,7 @@ "accept": "Accept", "accepting": "Accepting…", "writeConsent": "If you accept, what you add becomes part of {name}'s record. You will not be able to change or remove entries afterwards; only {name} can.", + "manageConsent": "If you accept, you can add to {name}'s record and also change or remove what is already in it, including entries {name} made themselves. Everything you do is recorded under your name in their activity view, and {name} can end your access at any time.", "open": "Open", "renounce": "Hand back", "renounceTitle": "Hand {name}'s record back?", @@ -9489,7 +9627,8 @@ "loadError": "The activity could not be loaded.", "opened": "{name} opened your record {count}×", "acted": "{name} made a change in your record", - "deletedAccount": "A deleted account" + "deletedAccount": "A deleted account", + "truncated": "This list shows the most recent activity, not all of it." }, "delegate": { "addOnlyNote": "You can add to this record. Changing or removing entries is something only {name} can do." @@ -9499,8 +9638,65 @@ "description": "Records other people have shared with you.", "levelRead": "Can view", "levelWrite": "Can view and add", + "kindShared": "Shared record", + "kindManaged": "Managed profile", "open": "Open {name}'s record" }, + "managed": { + "title": "Records you look after", + "description": "Health records with no login of their own.", + "explainer": "A managed profile is a health record for somebody who does not sign in themselves — a child, or a person you care for. It has no login and no e-mail address, and you become its first guardian. You can add more guardians later, and any of you can switch into the record to keep it up to date.", + "nameLabel": "Name", + "namePlaceholder": "The name you will recognise this record by", + "nameHint": "Up to 80 characters. Shown wherever this record appears.", + "nameRequired": "Give the record a name.", + "nameTooLong": "That name is longer than 80 characters.", + "dobLabel": "Date of birth (optional)", + "dobHint": "Leave it empty if you would rather not record one. Nothing is guessed from a year.", + "localeLabel": "Language of this record", + "localeHint": "The language used for text written into this record. Your own screens stay in your language.", + "timezoneHint": "The zone this record's days are measured in.", + "timezoneInvalid": "This browser does not know that timezone.", + "create": "Create profile", + "creating": "Creating…", + "created": "{name} is set up. You can switch into that record from the account menu.", + "errorStepUp": "Confirm your second factor, then try again.", + "errorOffline": "That did not reach the server. Nothing has changed — check your connection and try again.", + "errorInvalid": "The server refused those details. Check the name, the date and the timezone.", + "errorFailed": "The profile could not be created.", + "delete": "Delete profile", + "deleteTitle": "Delete {name}?", + "deleteBody": "Everything recorded for {name} goes with the profile — readings, medications, documents and notes. This cannot be undone.", + "deleteGuardiansOne": "{count} person looks after this record today and will lose it.", + "deleteGuardiansFew": "{count} people look after this record today and will lose it.", + "deleteGuardiansOther": "{count} people look after this record today and will lose it.", + "deleteFloorNote": "This is also the way out when you are the last guardian: you may end the record, but you may not leave it with nobody looking after it.", + "deleteConfirm": "Delete profile", + "guardiansTitle": "Who looks after {name}", + "guardianYou": "(you)", + "guardianSince": "Looking after this record since {date}", + "guardianPending": "Invited {date}, not accepted yet", + "guardianRemove": "End access", + "guardianRemoveTitle": "End {name}'s access?", + "guardianRemoveBody": "{name} stops being able to open {profile}. Anything they entered stays in the record.", + "guardianWithdrawBody": "The invitation to {name} is withdrawn. They never opened {profile}, so nothing changes in the record.", + "guardianRemoveConfirm": "End access", + "soleGuardian": "You are the only person looking after {name}, so there is no access here you can end. Invite somebody and wait for them to accept, hand your own access back under “Records I can open”, or delete the profile.", + "guardianInviteLabel": "Invite another guardian", + "guardianInvite": "Send invitation", + "guardianInviting": "Sending…", + "guardianInvited": "Invitation sent to {name}.", + "guardianExpiresHint": "Optional. Access ends at the end of that day in your timezone.", + "guardianPendingNote": "An invited guardian counts only once they accept. Until then you are still the last one, and handing your own access back is still refused.", + "guardianErrorDuplicate": "That account already has access to this record.", + "guardianErrorManagedInvitee": "A managed profile cannot look after another record. Invite an account that signs in.", + "guardianErrorNoAccount": "No account on this instance carries that name or e-mail.", + "guardianErrorRateLimit": "Too many invitations in the last hour. Wait a while and try again.", + "guardianErrorFailed": "The invitation could not be sent.", + "errorMfaRequired": "This needs a second factor, and your account does not have one yet. Set one up under Settings → Security, then try again.", + "rosterLoadError": "Could not load who looks after this record.", + "errorRateLimit": "Too many profiles created in the last hour. Wait a while and try again." + }, "toast": { "savedTo": "Saved to {name}'s record." }, @@ -9515,7 +9711,143 @@ "medicationSideEffectCreate": "{name} noted a side effect", "medicationCreate": "{name} added a medication", "medicationIntake": "{name} marked a dose", - "medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused" + "medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused", + "measurementUpdate": "{name} changed a reading", + "measurementDelete": "{name} removed a reading", + "measurementDeleteBulk": "{name} removed several readings at once", + "measurementRestore": "{name} restored a reading", + "measurementReminderCreate": "{name} added a measurement reminder", + "measurementReminderUpdate": "{name} changed a measurement reminder", + "measurementReminderDelete": "{name} removed a measurement reminder", + "measurementReminderComplete": "{name} completed a measurement reminder", + "measurementReminderSatisfy": "{name} cleared a measurement reminder", + "labResultUpdate": "{name} changed a lab result", + "labResultDelete": "{name} removed a lab result", + "labResultRestore": "{name} restored a lab result", + "biomarkerUpdate": "{name} changed a biomarker", + "biomarkerDelete": "{name} removed a biomarker", + "allergyUpdate": "{name} changed an allergy", + "allergyDelete": "{name} removed an allergy", + "familyHistoryUpdate": "{name} changed a family history entry", + "familyHistoryDelete": "{name} removed a family history entry", + "illnessEpisodeUpdate": "{name} changed an illness entry", + "illnessEpisodeDelete": "{name} removed an illness entry", + "illnessEpisodeResolve": "{name} marked an illness as over", + "illnessEpisodeRestore": "{name} restored an illness entry", + "illnessDayLogUpsert": "{name} recorded an illness day", + "medicationUpdate": "{name} changed a medication", + "medicationDelete": "{name} removed a medication", + "medicationGlp1Update": "{name} changed a GLP-1 course", + "medicationScheduleCreate": "{name} added a dosing schedule", + "medicationScheduleUpdate": "{name} changed a dosing schedule", + "medicationScheduleDelete": "{name} removed a dosing schedule", + "medicationInventoryCreate": "{name} added a medication package", + "medicationInventoryUpdate": "{name} changed a medication package", + "medicationInventoryDelete": "{name} removed a medication package", + "medicationSideEffectDelete": "{name} removed a side effect", + "medicationIntakeUpdate": "{name} changed a dose", + "medicationIntakeDelete": "{name} removed a dose", + "medicationIntakeBulkDelete": "{name} removed several doses at once", + "medicationIntakePurge": "{name} deleted the whole dose history of a medication", + "medicationIntakeImport": "{name} started a dose import for one medication", + "medicationIntakeHistoryImport": "{name} started a dose history import", + "moodEntryCreate": "{name} added a mood entry", + "moodEntryUpdate": "{name} changed a mood entry", + "moodEntryDelete": "{name} removed a mood entry", + "moodDeleteBulk": "{name} removed several mood entries at once", + "moodRestore": "{name} restored a mood entry", + "mentalHealthCreate": "{name} recorded a questionnaire result", + "cycleDelete": "{name} removed a cycle", + "cycleDayLogUpsert": "{name} recorded a cycle day", + "cycleDayLogUpdate": "{name} changed a cycle day", + "cycleDayLogDelete": "{name} removed a cycle day", + "cyclePeriodBoundary": "{name} changed the start or end of a period", + "cycleSymptomCreate": "{name} added a cycle symptom", + "nutrientWaterWrite": "{name} recorded water intake", + "healthRecordExport": "{name} exported the record", + "rejectedMeasurementQuery": "{name} sent a reading search this record could not read", + "rejectedDoseQuery": "{name} sent a dose search this record could not read", + "rejectedMoodQuery": "{name} sent a mood search this record could not read", + "rejectedMeasurementCreate": "{name} tried to add a reading and was refused", + "rejectedMeasurementBatch": "{name} tried to add several readings and was refused", + "rejectedMeasurementUpdate": "{name} tried to change a reading and was refused", + "rejectedLabCreate": "{name} tried to add a lab result and was refused", + "rejectedLabUpdate": "{name} tried to change a lab result and was refused", + "rejectedBiomarkerCreate": "{name} tried to add a biomarker and was refused", + "rejectedBiomarkerUpdate": "{name} tried to change a biomarker and was refused", + "rejectedReminderCreate": "{name} tried to add a measurement reminder and was refused", + "rejectedReminderUpdate": "{name} tried to change a measurement reminder and was refused", + "rejectedMoodCreate": "{name} tried to add a mood entry and was refused", + "rejectedMoodUpdate": "{name} tried to change a mood entry and was refused", + "rejectedDoseCreate": "{name} tried to record a dose and was refused", + "rejectedDoseUpdate": "{name} tried to change a dose and was refused", + "rejectedDoseImport": "{name} tried to import doses and was refused", + "rejectedTrackedValue": "{name} tried to log a tracked value and was refused", + "deniedDoseImport": "A dose import {name} started could not be queued", + "anamnesisFactCreate": "{name} added a health background fact", + "customMetricCreate": "{name} added a tracked value type", + "customMetricUpdate": "{name} changed a tracked value type", + "customMetricDelete": "{name} removed a tracked value type", + "documentStore": "{name} added a document", + "documentUpdate": "{name} changed a document", + "documentDiscard": "{name} discarded a document", + "medicationIntakeImportFinished": "The dose import {name} started has finished", + "medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded" + }, + "section": { + "measurements": { + "label": "Readings", + "detail": "Weight, blood pressure, pulse, glucose, sleep, workouts and the values you track." + }, + "medications": { + "label": "Medications", + "detail": "The medication list, its schedules, the doses taken, side effects and stock." + }, + "labs": { + "label": "Lab results", + "detail": "Lab results and the list of analytes behind them." + }, + "profile": { + "label": "Health background", + "detail": "Allergies, family history and the facts in your health profile." + }, + "illness": { + "label": "Illness", + "detail": "Illness episodes and the day logs kept under them." + }, + "mind": { + "label": "Mood and mind", + "detail": "Mood entries and the history of the mental-health questionnaires." + }, + "cycle": { + "label": "Cycle", + "detail": "Cycle tracking." + }, + "documents": { + "label": "Documents", + "detail": "Everything stored in the document vault." + } + }, + "actionError": { + "retry": "Try again", + "offline": "That did not reach the server. Nothing has changed — check your connection and try again.", + "rateLimited": "Too many attempts in a row. Wait a minute and try again.", + "acceptExpired": "That invitation has lapsed. Ask for a new one.", + "acceptWithdrawn": "That invitation was withdrawn.", + "acceptNotPending": "That invitation has already been accepted.", + "acceptNotFound": "That invitation is no longer there.", + "acceptFailed": "The invitation could not be accepted.", + "lastGuardian": "This is the only person looking after that profile, so this access cannot end yet. Add another guardian first, or delete the profile.", + "alreadyEnded": "That access has already ended.", + "notFound": "That access is no longer there.", + "revokeFailed": "The access could not be ended.", + "renounceFailed": "The access could not be handed back.", + "profileNotFound": "That profile is not there any more — it may already have been deleted, or your access to it may have ended.", + "deleteProfileFailed": "The profile could not be deleted.", + "removeGuardianFailed": "That access could not be ended." + }, + "anamnesis": { + "relativeNote": "A family history entry describes this person's relatives. Add it only when they would want it recorded here." } } } diff --git a/messages/es.json b/messages/es.json index 9160aa09f..a800a3727 100644 --- a/messages/es.json +++ b/messages/es.json @@ -341,6 +341,7 @@ }, "cycle": "Ciclo", "labs": "Análisis", + "profile": "Perfil", "vorsorge": "Revisiones", "illness": "Enfermedad", "documents": "Documentos", @@ -4433,6 +4434,121 @@ "system": "Sistema" } }, + "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Perfil", + "displayName": "Nombre visible", + "height": "Altura (cm)", + "dateOfBirth": "Fecha de nacimiento", + "gender": "Género", + "notSpecified": "No especificado", + "male": "Hombre", + "female": "Mujer", + "other": "Otro", + "locale": "Idioma", + "systemDefault": "Predeterminado del sistema", + "timezone": "Zona horaria", + "units": "Unidades", + "metric": "Métrico", + "imperial": "Imperial", + "timeFormat": "Formato de hora", + "automatic": "Automático", + "hour12": "12 horas", + "hour24": "24 horas", + "dateFormat": "Formato de fecha", + "dateDmy": "Día / mes / año", + "dateMdy": "Mes / día / año", + "dateYmd": "Año / mes / día" + }, + "modules": { + "title": "Módulos" + }, + "notifications": { + "title": "Notificaciones", + "moodReminder": "Activar recordatorio de ánimo", + "reminderHour": "Hora del recordatorio de ánimo", + "lowStockRunwayDays": "Días hasta existencias bajas", + "reorderLeadDays": "Días de margen para reponer" + }, + "thresholds": { + "title": "Objetivos", + "metric": "Métrica", + "minimum": "Mínimo", + "maximum": "Máximo" + }, + "coach": { + "title": "Asistente", + "disable": "Desactivar asistente", + "tone": "Tono", + "warm": "Cálido", + "neutral": "Neutro", + "concise": "Conciso", + "verbosity": "Nivel de detalle", + "brief": "Breve", + "default": "Predeterminado", + "detailed": "Detallado", + "defaultWindow": "Periodo predeterminado", + "last7days": "Últimos 7 días", + "last30days": "Últimos 30 días", + "last90days": "Últimos 90 días", + "allTime": "Todo el tiempo", + "showEvidence": "Mostrar evidencia de forma predeterminada", + "excludedMetrics": "Métricas excluidas", + "dataClusters": "Grupos de datos", + "customClusters": "Usar una selección personalizada de grupos", + "metrics": { + "bp": "Presión arterial", + "weight": "Peso", + "pulse": "Pulso", + "mood": "Ánimo", + "compliance": "Adherencia a la medicación", + "hrv": "Variabilidad de la frecuencia cardíaca", + "sleep": "Sueño", + "restingHr": "Frecuencia cardíaca en reposo", + "steps": "Pasos", + "medications": "Medicamentos", + "anthropometrics": "Medidas corporales" + }, + "clusters": { + "cardio": "Salud cardiovascular", + "body": "Composición corporal", + "activity": "Actividad", + "workouts": "Entrenamientos", + "sleep": "Sueño", + "mood": "Ánimo", + "glucose": "Glucosa", + "medication": "Medicación", + "mobility": "Movilidad", + "environment": "Entorno" + } + }, + "insights": { + "title": "Análisis", + "sections": "Secciones del resumen", + "tiles": "Mosaicos de navegación", + "order": "Orden", + "unknownItem": "Análisis no disponible", + "sectionLabels": { + "wellnessScores": "Puntuaciones de bienestar", + "dailyBriefing": "Resumen diario", + "vitals": "Signos vitales", + "trends": "Tendencias", + "periodReview": "Revisión del período", + "cycleSummary": "Resumen del ciclo", + "signals": "Señales", + "rhythmEvents": "Eventos de ritmo", + "healthStatus": "Estado de salud", + "breathing": "Respiración", + "labsChanges": "Cambios de laboratorio", + "ecg": "ECG" + } + } + }, + "unavailableTitle": "No disponible en este registro compartido", + "unavailableDescription": "Esta sección de Ajustes no está disponible intencionadamente mientras consultas el registro de otra persona.", + "integrationStatusDescription": "Estado de conexión de este registro gestionado. Los controles de conexión no están disponibles." + }, "sections": { "account": { "title": "Cuenta", @@ -9406,7 +9522,7 @@ }, "unavailable": { "title": "No forma parte del acceso compartido", - "description": "Se comparte el historial de salud en sí. Los ajustes, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.", + "description": "Se comparte el historial de salud en sí, incluidos los antecedentes que hay en Anamnesis. Los ajustes de la cuenta, los servicios conectados, las notificaciones y las funciones de IA siguen siendo de la persona propietaria de la cuenta.", "leave": "Volver a mi historial" }, "state": { @@ -9418,6 +9534,10 @@ "row": { "readOnly": "Puede leer el historial", "canWrite": "Puede leer y añadir entradas", + "canManage": "Puede leer el historial, añadir entradas y cambiar o eliminar lo que hay en él", + "entireRecord": "Cubre todo el historial", + "sections": "Cubre: {sections}", + "noSections": "No cubre nada: este acceso no abre ninguna parte del historial", "invited": "Invitado", "accepted": "Aceptado", "expires": "Caduca", @@ -9439,6 +9559,19 @@ "accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.", "accessWriteLabel": "Puede ver y añadir", "accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.", + "accessManageLabel": "Puede gestionar", + "accessManageCapability": "Puede añadir, cambiar y eliminar entradas de tu ficha, incluidas las que has escrito tú, y puede leer los análisis generados a partir de ella. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.", + "manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.", + "scopeLegend": "Qué puede abrir", + "scopeAllLabel": "Todo el historial", + "scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.", + "scopeSomeLabel": "Solo las secciones que elijas", + "scopeSomeCapability": "Todo lo que no elijas le queda cerrado.", + "scopeManageNote": "El acceso de gestión siempre cubre todo el historial.", + "scopeLeakNote": "Una sección puede mencionar el resto de tu salud en sus notas y en sus nombres. Compartir una sección comparte todo lo que hay escrito en ella.", + "scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.", + "scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.", + "scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.", "expiresLabel": "El acceso termina el (opcional)", "expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.", "submit": "Enviar invitación", @@ -9449,7 +9582,11 @@ "errorSelf": "Esa es tu propia cuenta.", "errorInvalid": "Introduce un usuario o una dirección de correo.", "errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.", - "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo." + "errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.", + "errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.", + "errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.", + "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", + "errorOffline": "Esto no llegó al servidor. Tu invitación sigue aquí: comprueba tu conexión e inténtalo de nuevo." }, "given": { "title": "Quién puede abrir mi historial", @@ -9474,6 +9611,7 @@ "accept": "Aceptar", "accepting": "Aceptando…", "writeConsent": "Si aceptas, lo que añadas pasa a formar parte del historial de {name}. Después no podrás cambiar ni eliminar entradas; solo {name} puede.", + "manageConsent": "Si aceptas, podrás añadir cosas al historial de {name} y también cambiar o eliminar lo que ya hay, incluidas las entradas del propio {name}. Todo lo que hagas queda registrado con tu nombre en su vista de actividad, y {name} puede terminar tu acceso cuando quiera.", "open": "Abrir", "renounce": "Devolver", "renounceTitle": "¿Devolver el historial de {name}?", @@ -9489,7 +9627,8 @@ "loadError": "No se pudo cargar la actividad.", "opened": "{name} abrió tu historial {count}×", "acted": "{name} hizo un cambio en tu historial", - "deletedAccount": "Una cuenta eliminada" + "deletedAccount": "Una cuenta eliminada", + "truncated": "Esta lista muestra la actividad más reciente, no toda." }, "delegate": { "addOnlyNote": "Puedes añadir cosas a este historial. Modificar o borrar entradas solo lo puede hacer {name}." @@ -9499,8 +9638,65 @@ "description": "Historiales que otras personas han compartido contigo.", "levelRead": "Puede ver", "levelWrite": "Puede ver y añadir", + "kindShared": "Registro compartido", + "kindManaged": "Perfil administrado", "open": "Abrir el historial de {name}" }, + "managed": { + "title": "Perfiles de los que te ocupas", + "description": "Historiales de salud sin inicio de sesión propio.", + "explainer": "Un perfil administrado es un historial de salud para alguien que no inicia sesión — una criatura, o una persona a la que cuidas. No tiene inicio de sesión ni dirección de correo, y tú pasas a ser su primera persona responsable. Más adelante puedes añadir otras, y cualquiera de vosotras puede entrar en el historial para mantenerlo al día.", + "nameLabel": "Nombre", + "namePlaceholder": "El nombre con el que reconocerás este historial", + "nameHint": "Hasta 80 caracteres. Se muestra allá donde aparezca este historial.", + "nameRequired": "Dale un nombre al historial.", + "nameTooLong": "Ese nombre tiene más de 80 caracteres.", + "dobLabel": "Fecha de nacimiento (opcional)", + "dobHint": "Déjala vacía si prefieres no registrarla. No se deduce nada a partir de un año.", + "localeLabel": "Idioma de este historial", + "localeHint": "El idioma de los textos que se escriben en este historial. Tus propias pantallas siguen en tu idioma.", + "timezoneHint": "La zona en la que se miden los días de este historial.", + "timezoneInvalid": "Este navegador no conoce esa zona horaria.", + "create": "Crear perfil", + "creating": "Creando…", + "created": "{name} ya está listo. Puedes entrar en ese historial desde el menú de la cuenta.", + "errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo.", + "errorOffline": "Esto no llegó al servidor. No ha cambiado nada — revisa tu conexión y vuelve a intentarlo.", + "errorInvalid": "El servidor rechazó esos datos. Revisa el nombre, la fecha y la zona horaria.", + "errorFailed": "No se pudo crear el perfil.", + "delete": "Eliminar perfil", + "deleteTitle": "¿Eliminar {name}?", + "deleteBody": "Todo lo registrado para {name} desaparece con el perfil — mediciones, medicamentos, documentos y notas. Esto no se puede deshacer.", + "deleteGuardiansOne": "{count} persona se ocupa hoy de este historial y lo perderá.", + "deleteGuardiansFew": "{count} personas se ocupan hoy de este historial y lo perderán.", + "deleteGuardiansOther": "{count} personas se ocupan hoy de este historial y lo perderán.", + "deleteFloorNote": "También es la salida cuando eres la última persona responsable: puedes terminar el historial, pero no dejarlo sin nadie que se ocupe de él.", + "deleteConfirm": "Eliminar perfil", + "guardiansTitle": "Quién se ocupa de {name}", + "guardianYou": "(tú)", + "guardianSince": "Se ocupa de este historial desde el {date}", + "guardianPending": "Invitada el {date}, aún sin aceptar", + "guardianRemove": "Terminar acceso", + "guardianRemoveTitle": "¿Terminar el acceso de {name}?", + "guardianRemoveBody": "{name} dejará de poder abrir {profile}. Lo que haya registrado se queda en el historial.", + "guardianWithdrawBody": "Se retira la invitación a {name}. Nunca abrió {profile}, así que en el historial no cambia nada.", + "guardianRemoveConfirm": "Terminar acceso", + "soleGuardian": "Eres la única persona que se ocupa de {name}, así que aquí no hay ningún acceso que puedas terminar. Invita a alguien y espera a que acepte, devuelve tu propio acceso en «Historiales que puedo abrir», o elimina el perfil.", + "guardianInviteLabel": "Invitar a otra persona responsable", + "guardianInvite": "Enviar invitación", + "guardianInviting": "Enviando…", + "guardianInvited": "Invitación enviada a {name}.", + "guardianExpiresHint": "Opcional. El acceso termina al final de ese día en tu zona horaria.", + "guardianPendingNote": "Una persona invitada solo cuenta cuando acepta. Hasta entonces sigues siendo la última, y devolver tu propio acceso se sigue rechazando.", + "guardianErrorDuplicate": "Esa cuenta ya tiene acceso a este historial.", + "guardianErrorManagedInvitee": "Un perfil administrado no puede ocuparse de otro historial. Invita a una cuenta que inicie sesión.", + "guardianErrorNoAccount": "En esta instancia no hay ninguna cuenta con ese nombre o correo.", + "guardianErrorRateLimit": "Demasiadas invitaciones en la última hora. Espera un poco y vuelve a intentarlo.", + "guardianErrorFailed": "No se pudo enviar la invitación.", + "errorMfaRequired": "Esto necesita un segundo factor y tu cuenta aún no tiene ninguno. Configura uno en Ajustes → Seguridad y vuelve a intentarlo.", + "rosterLoadError": "No se pudo cargar quién se ocupa de este historial.", + "errorRateLimit": "Demasiados perfiles creados en la última hora. Espera un poco y vuelve a intentarlo." + }, "toast": { "savedTo": "Guardado en el historial de {name}." }, @@ -9515,7 +9711,143 @@ "medicationSideEffectCreate": "{name} anotó un efecto secundario", "medicationCreate": "{name} añadió un medicamento", "medicationIntake": "{name} registró una dosis", - "medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado" + "medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado", + "measurementUpdate": "{name} cambió una medición", + "measurementDelete": "{name} eliminó una medición", + "measurementDeleteBulk": "{name} eliminó varias mediciones a la vez", + "measurementRestore": "{name} restauró una medición", + "measurementReminderCreate": "{name} añadió un recordatorio de medición", + "measurementReminderUpdate": "{name} cambió un recordatorio de medición", + "measurementReminderDelete": "{name} eliminó un recordatorio de medición", + "measurementReminderComplete": "{name} completó un recordatorio de medición", + "measurementReminderSatisfy": "{name} cerró un recordatorio de medición", + "labResultUpdate": "{name} cambió un resultado de laboratorio", + "labResultDelete": "{name} eliminó un resultado de laboratorio", + "labResultRestore": "{name} restauró un resultado de laboratorio", + "biomarkerUpdate": "{name} cambió un biomarcador", + "biomarkerDelete": "{name} eliminó un biomarcador", + "allergyUpdate": "{name} cambió una alergia", + "allergyDelete": "{name} eliminó una alergia", + "familyHistoryUpdate": "{name} cambió un antecedente familiar", + "familyHistoryDelete": "{name} eliminó un antecedente familiar", + "illnessEpisodeUpdate": "{name} cambió una enfermedad", + "illnessEpisodeDelete": "{name} eliminó una enfermedad", + "illnessEpisodeResolve": "{name} marcó una enfermedad como terminada", + "illnessEpisodeRestore": "{name} restauró una enfermedad", + "illnessDayLogUpsert": "{name} registró un día de enfermedad", + "medicationUpdate": "{name} cambió un medicamento", + "medicationDelete": "{name} eliminó un medicamento", + "medicationGlp1Update": "{name} cambió un tratamiento con GLP-1", + "medicationScheduleCreate": "{name} añadió una pauta de dosificación", + "medicationScheduleUpdate": "{name} cambió una pauta de dosificación", + "medicationScheduleDelete": "{name} eliminó una pauta de dosificación", + "medicationInventoryCreate": "{name} añadió un envase de medicamento", + "medicationInventoryUpdate": "{name} cambió un envase de medicamento", + "medicationInventoryDelete": "{name} eliminó un envase de medicamento", + "medicationSideEffectDelete": "{name} eliminó un efecto secundario", + "medicationIntakeUpdate": "{name} cambió una dosis", + "medicationIntakeDelete": "{name} eliminó una dosis", + "medicationIntakeBulkDelete": "{name} eliminó varias dosis a la vez", + "medicationIntakePurge": "{name} borró todo el historial de dosis de un medicamento", + "medicationIntakeImport": "{name} inició una importación de dosis de un medicamento", + "medicationIntakeHistoryImport": "{name} inició una importación del historial de dosis", + "moodEntryCreate": "{name} añadió una entrada de ánimo", + "moodEntryUpdate": "{name} cambió una entrada de ánimo", + "moodEntryDelete": "{name} eliminó una entrada de ánimo", + "moodDeleteBulk": "{name} eliminó varias entradas de ánimo a la vez", + "moodRestore": "{name} restauró una entrada de ánimo", + "mentalHealthCreate": "{name} registró el resultado de un cuestionario", + "cycleDelete": "{name} eliminó un ciclo", + "cycleDayLogUpsert": "{name} registró un día del ciclo", + "cycleDayLogUpdate": "{name} cambió un día del ciclo", + "cycleDayLogDelete": "{name} eliminó un día del ciclo", + "cyclePeriodBoundary": "{name} cambió el inicio o el final de una regla", + "cycleSymptomCreate": "{name} añadió un síntoma del ciclo", + "nutrientWaterWrite": "{name} registró la ingesta de agua", + "healthRecordExport": "{name} exportó el historial", + "rejectedMeasurementQuery": "{name} envió una búsqueda de mediciones que este historial no pudo leer", + "rejectedDoseQuery": "{name} envió una búsqueda de dosis que este historial no pudo leer", + "rejectedMoodQuery": "{name} envió una búsqueda de ánimo que este historial no pudo leer", + "rejectedMeasurementCreate": "{name} intentó añadir una medición y fue rechazado", + "rejectedMeasurementBatch": "{name} intentó añadir varias mediciones y fue rechazado", + "rejectedMeasurementUpdate": "{name} intentó cambiar una medición y fue rechazado", + "rejectedLabCreate": "{name} intentó añadir un resultado de laboratorio y fue rechazado", + "rejectedLabUpdate": "{name} intentó cambiar un resultado de laboratorio y fue rechazado", + "rejectedBiomarkerCreate": "{name} intentó añadir un biomarcador y fue rechazado", + "rejectedBiomarkerUpdate": "{name} intentó cambiar un biomarcador y fue rechazado", + "rejectedReminderCreate": "{name} intentó añadir un recordatorio de medición y fue rechazado", + "rejectedReminderUpdate": "{name} intentó cambiar un recordatorio de medición y fue rechazado", + "rejectedMoodCreate": "{name} intentó añadir una entrada de ánimo y fue rechazado", + "rejectedMoodUpdate": "{name} intentó cambiar una entrada de ánimo y fue rechazado", + "rejectedDoseCreate": "{name} intentó registrar una dosis y fue rechazado", + "rejectedDoseUpdate": "{name} intentó cambiar una dosis y fue rechazado", + "rejectedDoseImport": "{name} intentó importar dosis y fue rechazado", + "rejectedTrackedValue": "{name} intentó registrar un valor propio y fue rechazado", + "deniedDoseImport": "Una importación de dosis iniciada por {name} no se pudo poner en cola", + "anamnesisFactCreate": "{name} añadió un dato de antecedentes de salud", + "customMetricCreate": "{name} añadió un tipo de valor propio", + "customMetricUpdate": "{name} cambió un tipo de valor propio", + "customMetricDelete": "{name} eliminó un tipo de valor propio", + "documentStore": "{name} añadió un documento", + "documentUpdate": "{name} cambió un documento", + "documentDiscard": "{name} descartó un documento", + "medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado", + "medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}" + }, + "section": { + "measurements": { + "label": "Mediciones", + "detail": "Peso, tensión arterial, pulso, glucosa, sueño, entrenamientos y los valores que sigues." + }, + "medications": { + "label": "Medicamentos", + "detail": "La lista de medicamentos, sus pautas, las dosis tomadas, los efectos secundarios y las existencias." + }, + "labs": { + "label": "Resultados de laboratorio", + "detail": "Los resultados de laboratorio y la lista de analitos que hay detrás." + }, + "profile": { + "label": "Antecedentes de salud", + "detail": "Alergias, antecedentes familiares y los datos de tu perfil de salud." + }, + "illness": { + "label": "Enfermedades", + "detail": "Los episodios de enfermedad y los registros diarios asociados." + }, + "mind": { + "label": "Ánimo y bienestar mental", + "detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental." + }, + "cycle": { + "label": "Ciclo", + "detail": "El seguimiento del ciclo." + }, + "documents": { + "label": "Documentos", + "detail": "Todo lo que está guardado en el archivo de documentos." + } + }, + "actionError": { + "retry": "Inténtalo de nuevo", + "offline": "Esto no llegó al servidor. No ha cambiado nada: comprueba tu conexión e inténtalo de nuevo.", + "rateLimited": "Demasiados intentos seguidos. Espera un minuto e inténtalo de nuevo.", + "acceptExpired": "Esa invitación ha caducado. Pide una nueva.", + "acceptWithdrawn": "Esa invitación fue retirada.", + "acceptNotPending": "Esa invitación ya se aceptó.", + "acceptNotFound": "Esa invitación ya no existe.", + "acceptFailed": "No se pudo aceptar la invitación.", + "lastGuardian": "Esta es la única persona que cuida de ese perfil, así que este acceso todavía no puede terminar. Añade otra persona responsable primero, o elimina el perfil.", + "alreadyEnded": "Ese acceso ya ha terminado.", + "notFound": "Ese acceso ya no existe.", + "revokeFailed": "No se pudo terminar el acceso.", + "renounceFailed": "No se pudo devolver el acceso.", + "profileNotFound": "Ese perfil ya no está — puede que se haya eliminado, o que tu acceso haya terminado.", + "deleteProfileFailed": "No se pudo eliminar el perfil.", + "removeGuardianFailed": "No se pudo terminar ese acceso." + }, + "anamnesis": { + "relativeNote": "Una entrada de antecedentes familiares describe a los familiares de esta persona. Añádela solo si querría que quedara registrada aquí." } } } diff --git a/messages/fr.json b/messages/fr.json index 6f7c15361..b14279d03 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -341,6 +341,7 @@ }, "cycle": "Cycle", "labs": "Analyses", + "profile": "Profil", "vorsorge": "Examens", "illness": "Maladie", "documents": "Documents", @@ -4433,6 +4434,121 @@ "system": "Système" } }, + "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profil", + "displayName": "Nom affiché", + "height": "Taille (cm)", + "dateOfBirth": "Date de naissance", + "gender": "Genre", + "notSpecified": "Non précisé", + "male": "Homme", + "female": "Femme", + "other": "Autre", + "locale": "Langue", + "systemDefault": "Valeur système", + "timezone": "Fuseau horaire", + "units": "Unités", + "metric": "Métrique", + "imperial": "Impérial", + "timeFormat": "Format de l’heure", + "automatic": "Automatique", + "hour12": "12 heures", + "hour24": "24 heures", + "dateFormat": "Format de date", + "dateDmy": "Jour / mois / année", + "dateMdy": "Mois / jour / année", + "dateYmd": "Année / mois / jour" + }, + "modules": { + "title": "Modules" + }, + "notifications": { + "title": "Notifications", + "moodReminder": "Activer le rappel d’humeur", + "reminderHour": "Heure du rappel d’humeur", + "lowStockRunwayDays": "Jours avant stock faible", + "reorderLeadDays": "Délai de réapprovisionnement" + }, + "thresholds": { + "title": "Objectifs", + "metric": "Mesure", + "minimum": "Minimum", + "maximum": "Maximum" + }, + "coach": { + "title": "Coach", + "disable": "Désactiver le Coach", + "tone": "Ton", + "warm": "Chaleureux", + "neutral": "Neutre", + "concise": "Concis", + "verbosity": "Niveau de détail", + "brief": "Bref", + "default": "Par défaut", + "detailed": "Détaillé", + "defaultWindow": "Période par défaut", + "last7days": "7 derniers jours", + "last30days": "30 derniers jours", + "last90days": "90 derniers jours", + "allTime": "Depuis toujours", + "showEvidence": "Afficher les sources par défaut", + "excludedMetrics": "Mesures exclues", + "dataClusters": "Groupes de données", + "customClusters": "Utiliser une sélection personnalisée de groupes", + "metrics": { + "bp": "Tension artérielle", + "weight": "Poids", + "pulse": "Pouls", + "mood": "Humeur", + "compliance": "Adhérence aux médicaments", + "hrv": "Variabilité de la fréquence cardiaque", + "sleep": "Sommeil", + "restingHr": "Fréquence cardiaque au repos", + "steps": "Pas", + "medications": "Médicaments", + "anthropometrics": "Mesures corporelles" + }, + "clusters": { + "cardio": "Santé cardiovasculaire", + "body": "Composition corporelle", + "activity": "Activité", + "workouts": "Entraînements", + "sleep": "Sommeil", + "mood": "Humeur", + "glucose": "Glucose", + "medication": "Médicaments", + "mobility": "Mobilité", + "environment": "Environnement" + } + }, + "insights": { + "title": "Analyses", + "sections": "Sections d’aperçu", + "tiles": "Tuiles de navigation", + "order": "Ordre", + "unknownItem": "Analyse indisponible", + "sectionLabels": { + "wellnessScores": "Scores de bien-être", + "dailyBriefing": "Brief quotidien", + "vitals": "Signes vitaux", + "trends": "Tendances", + "periodReview": "Bilan de période", + "cycleSummary": "Résumé du cycle", + "signals": "Signaux", + "rhythmEvents": "Événements de rythme", + "healthStatus": "État de santé", + "breathing": "Respiration", + "labsChanges": "Évolutions de laboratoire", + "ecg": "ECG" + } + } + }, + "unavailableTitle": "Indisponible dans ce dossier partagé", + "unavailableDescription": "Cette section des réglages est volontairement indisponible lorsque vous consultez le dossier d’une autre personne.", + "integrationStatusDescription": "État de connexion pour ce dossier géré. Les commandes de connexion ne sont pas disponibles." + }, "sections": { "account": { "title": "Compte", @@ -9406,7 +9522,7 @@ }, "unavailable": { "title": "Ne fait pas partie de l'accès partagé", - "description": "Le partage porte sur le dossier de santé lui-même. Les réglages, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.", + "description": "Le partage porte sur le dossier de santé lui-même, y compris les antécédents dans Anamnèse. Les réglages du compte, les services connectés, les notifications et les fonctions d'IA restent à la personne propriétaire du compte.", "leave": "Revenir à mon dossier" }, "state": { @@ -9418,6 +9534,10 @@ "row": { "readOnly": "Peut lire le dossier", "canWrite": "Peut lire et ajouter des entrées", + "canManage": "Peut lire le dossier, y ajouter des entrées et modifier ou supprimer ce qui s'y trouve", + "entireRecord": "Couvre l'intégralité du dossier", + "sections": "Couvre : {sections}", + "noSections": "Ne couvre rien : cet accès n'ouvre aucune partie du dossier", "invited": "Invité le", "accepted": "Accepté le", "expires": "Expire le", @@ -9439,6 +9559,19 @@ "accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.", "accessWriteLabel": "Peut consulter et ajouter", "accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.", + "accessManageLabel": "Peut gérer", + "accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées de votre dossier, y compris celles que vous avez saisies vous-même, et lire les analyses qui en sont tirées. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.", + "manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.", + "scopeLegend": "Ce que cette personne peut ouvrir", + "scopeAllLabel": "Dossier complet", + "scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.", + "scopeSomeLabel": "Seulement les rubriques que vous choisissez", + "scopeSomeCapability": "Tout ce que vous ne choisissez pas lui reste fermé.", + "scopeManageNote": "Un accès de gestion couvre toujours l'intégralité du dossier.", + "scopeLeakNote": "Une rubrique peut évoquer le reste de votre santé dans ses notes et ses intitulés. Partager une rubrique, c'est partager tout ce qui y est écrit.", + "scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.", + "scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.", + "scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.", "expiresLabel": "L'accès prend fin le (facultatif)", "expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.", "submit": "Envoyer l'invitation", @@ -9449,7 +9582,11 @@ "errorSelf": "C'est votre propre compte.", "errorInvalid": "Saisissez un nom d'utilisateur ou une adresse e-mail.", "errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.", - "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer." + "errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.", + "errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.", + "errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.", + "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", + "errorOffline": "Cela n'a pas atteint le serveur. Votre invitation est toujours là — vérifiez votre connexion et réessayez." }, "given": { "title": "Qui peut ouvrir mon dossier", @@ -9474,6 +9611,7 @@ "accept": "Accepter", "accepting": "Acceptation…", "writeConsent": "Si vous acceptez, ce que vous ajoutez fait partie du dossier de {name}. Vous ne pourrez plus modifier ni supprimer vos entrées ensuite ; {name} est la seule personne à pouvoir le faire.", + "manageConsent": "Si vous acceptez, vous pourrez compléter le dossier de {name} mais aussi modifier ou supprimer ce qui s'y trouve déjà, y compris les entrées de {name}. Tout ce que vous faites apparaît à votre nom dans son journal d'activité, et {name} peut mettre fin à votre accès à tout moment.", "open": "Ouvrir", "renounce": "Rendre", "renounceTitle": "Rendre le dossier de {name} ?", @@ -9489,7 +9627,8 @@ "loadError": "L'activité n'a pas pu être chargée.", "opened": "{name} a ouvert votre dossier {count}×", "acted": "{name} a fait une modification dans votre dossier", - "deletedAccount": "Un compte supprimé" + "deletedAccount": "Un compte supprimé", + "truncated": "Cette liste montre l'activité la plus récente, pas la totalité." }, "delegate": { "addOnlyNote": "Vous pouvez ajouter des éléments à ce dossier. Seul {name} peut modifier ou supprimer des entrées." @@ -9499,8 +9638,65 @@ "description": "Les dossiers que d'autres personnes partagent avec vous.", "levelRead": "Peut consulter", "levelWrite": "Peut consulter et ajouter", + "kindShared": "Dossier partagé", + "kindManaged": "Profil géré", "open": "Ouvrir le dossier de {name}" }, + "managed": { + "title": "Profils dont vous vous occupez", + "description": "Dossiers de santé sans identifiants propres.", + "explainer": "Un profil géré est un dossier de santé pour quelqu'un qui ne se connecte pas lui-même — un enfant, ou une personne dont vous vous occupez. Il n'a ni identifiants ni adresse e-mail, et vous en devenez la première personne responsable. Vous pourrez en ajouter d'autres plus tard, et chacune pourra entrer dans le dossier pour le tenir à jour.", + "nameLabel": "Nom", + "namePlaceholder": "Le nom sous lequel vous reconnaîtrez ce dossier", + "nameHint": "Jusqu'à 80 caractères. Affiché partout où ce dossier apparaît.", + "nameRequired": "Donnez un nom au dossier.", + "nameTooLong": "Ce nom dépasse 80 caractères.", + "dobLabel": "Date de naissance (facultatif)", + "dobHint": "Laissez le champ vide si vous préférez ne pas en enregistrer. Rien n'est déduit d'une année.", + "localeLabel": "Langue de ce dossier", + "localeHint": "La langue des textes écrits dans ce dossier. Vos propres écrans restent dans votre langue.", + "timezoneHint": "Le fuseau dans lequel les journées de ce dossier sont comptées.", + "timezoneInvalid": "Ce navigateur ne connaît pas ce fuseau horaire.", + "create": "Créer le profil", + "creating": "Création…", + "created": "{name} est en place. Vous pouvez entrer dans ce dossier depuis le menu du compte.", + "errorStepUp": "Confirmez votre deuxième facteur, puis réessayez.", + "errorOffline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.", + "errorInvalid": "Le serveur a refusé ces informations. Vérifiez le nom, la date et le fuseau horaire.", + "errorFailed": "Le profil n'a pas pu être créé.", + "delete": "Supprimer le profil", + "deleteTitle": "Supprimer {name} ?", + "deleteBody": "Tout ce qui a été enregistré pour {name} disparaît avec le profil — mesures, médicaments, documents et notes. Cette action est irréversible.", + "deleteGuardiansOne": "{count} personne s'occupe aujourd'hui de ce dossier et le perdra.", + "deleteGuardiansFew": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.", + "deleteGuardiansOther": "{count} personnes s'occupent aujourd'hui de ce dossier et le perdront.", + "deleteFloorNote": "C'est aussi l'issue lorsque vous êtes la dernière personne responsable : vous pouvez mettre fin au dossier, mais pas le laisser sans personne.", + "deleteConfirm": "Supprimer le profil", + "guardiansTitle": "Qui s'occupe de {name}", + "guardianYou": "(vous)", + "guardianSince": "S'occupe de ce dossier depuis le {date}", + "guardianPending": "Invitée le {date}, pas encore acceptée", + "guardianRemove": "Mettre fin à l'accès", + "guardianRemoveTitle": "Mettre fin à l'accès de {name} ?", + "guardianRemoveBody": "{name} ne pourra plus ouvrir {profile}. Ce qu'elle a saisi reste dans le dossier.", + "guardianWithdrawBody": "L'invitation adressée à {name} est retirée. Cette personne n'a jamais ouvert {profile}, rien ne change dans le dossier.", + "guardianRemoveConfirm": "Mettre fin à l'accès", + "soleGuardian": "Vous êtes la seule personne à vous occuper de {name} : il n'y a donc ici aucun accès auquel mettre fin. Invitez quelqu'un et attendez son acceptation, rendez votre propre accès sous « Dossiers que je peux ouvrir », ou supprimez le profil.", + "guardianInviteLabel": "Inviter une autre personne responsable", + "guardianInvite": "Envoyer l'invitation", + "guardianInviting": "Envoi…", + "guardianInvited": "Invitation envoyée à {name}.", + "guardianExpiresHint": "Facultatif. L'accès prend fin à la fin de cette journée dans votre fuseau horaire.", + "guardianPendingNote": "Une personne invitée ne compte qu'une fois qu'elle a accepté. D'ici là vous êtes toujours la dernière, et rendre votre propre accès reste refusé.", + "guardianErrorDuplicate": "Ce compte a déjà accès à ce dossier.", + "guardianErrorManagedInvitee": "Un profil géré ne peut pas s'occuper d'un autre dossier. Invitez un compte qui se connecte.", + "guardianErrorNoAccount": "Aucun compte de cette instance ne porte ce nom ou cette adresse e-mail.", + "guardianErrorRateLimit": "Trop d'invitations dans la dernière heure. Patientez un peu et réessayez.", + "guardianErrorFailed": "L'invitation n'a pas pu être envoyée.", + "errorMfaRequired": "Cela demande un deuxième facteur, et votre compte n'en a pas encore. Configurez-en un dans Réglages → Sécurité, puis réessayez.", + "rosterLoadError": "Impossible de charger qui s'occupe de ce dossier.", + "errorRateLimit": "Trop de profils créés dans la dernière heure. Patientez un peu et réessayez." + }, "toast": { "savedTo": "Enregistré dans le dossier de {name}." }, @@ -9515,7 +9711,143 @@ "medicationSideEffectCreate": "{name} a noté un effet indésirable", "medicationCreate": "{name} a ajouté un médicament", "medicationIntake": "{name} a enregistré une prise", - "medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé" + "medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé", + "measurementUpdate": "{name} a modifié une mesure", + "measurementDelete": "{name} a supprimé une mesure", + "measurementDeleteBulk": "{name} a supprimé plusieurs mesures d'un coup", + "measurementRestore": "{name} a restauré une mesure", + "measurementReminderCreate": "{name} a ajouté un rappel de mesure", + "measurementReminderUpdate": "{name} a modifié un rappel de mesure", + "measurementReminderDelete": "{name} a supprimé un rappel de mesure", + "measurementReminderComplete": "{name} a terminé un rappel de mesure", + "measurementReminderSatisfy": "{name} a clôturé un rappel de mesure", + "labResultUpdate": "{name} a modifié un résultat de laboratoire", + "labResultDelete": "{name} a supprimé un résultat de laboratoire", + "labResultRestore": "{name} a restauré un résultat de laboratoire", + "biomarkerUpdate": "{name} a modifié un biomarqueur", + "biomarkerDelete": "{name} a supprimé un biomarqueur", + "allergyUpdate": "{name} a modifié une allergie", + "allergyDelete": "{name} a supprimé une allergie", + "familyHistoryUpdate": "{name} a modifié un antécédent familial", + "familyHistoryDelete": "{name} a supprimé un antécédent familial", + "illnessEpisodeUpdate": "{name} a modifié une maladie", + "illnessEpisodeDelete": "{name} a supprimé une maladie", + "illnessEpisodeResolve": "{name} a marqué une maladie comme terminée", + "illnessEpisodeRestore": "{name} a restauré une maladie", + "illnessDayLogUpsert": "{name} a enregistré une journée de maladie", + "medicationUpdate": "{name} a modifié un médicament", + "medicationDelete": "{name} a supprimé un médicament", + "medicationGlp1Update": "{name} a modifié un traitement GLP-1", + "medicationScheduleCreate": "{name} a ajouté un schéma de prise", + "medicationScheduleUpdate": "{name} a modifié un schéma de prise", + "medicationScheduleDelete": "{name} a supprimé un schéma de prise", + "medicationInventoryCreate": "{name} a ajouté une boîte de médicament", + "medicationInventoryUpdate": "{name} a modifié une boîte de médicament", + "medicationInventoryDelete": "{name} a supprimé une boîte de médicament", + "medicationSideEffectDelete": "{name} a supprimé un effet indésirable", + "medicationIntakeUpdate": "{name} a modifié une prise", + "medicationIntakeDelete": "{name} a supprimé une prise", + "medicationIntakeBulkDelete": "{name} a supprimé plusieurs prises d'un coup", + "medicationIntakePurge": "{name} a effacé tout l'historique des prises d'un médicament", + "medicationIntakeImport": "{name} a lancé un import des prises d'un médicament", + "medicationIntakeHistoryImport": "{name} a lancé un import de l'historique des prises", + "moodEntryCreate": "{name} a ajouté une entrée d'humeur", + "moodEntryUpdate": "{name} a modifié une entrée d'humeur", + "moodEntryDelete": "{name} a supprimé une entrée d'humeur", + "moodDeleteBulk": "{name} a supprimé plusieurs entrées d'humeur d'un coup", + "moodRestore": "{name} a restauré une entrée d'humeur", + "mentalHealthCreate": "{name} a enregistré le résultat d'un questionnaire", + "cycleDelete": "{name} a supprimé un cycle", + "cycleDayLogUpsert": "{name} a enregistré un jour du cycle", + "cycleDayLogUpdate": "{name} a modifié un jour du cycle", + "cycleDayLogDelete": "{name} a supprimé un jour du cycle", + "cyclePeriodBoundary": "{name} a modifié le début ou la fin de règles", + "cycleSymptomCreate": "{name} a ajouté un symptôme de cycle", + "nutrientWaterWrite": "{name} a enregistré la consommation d'eau", + "healthRecordExport": "{name} a exporté le dossier", + "rejectedMeasurementQuery": "{name} a envoyé une recherche de mesures que ce dossier n'a pas pu lire", + "rejectedDoseQuery": "{name} a envoyé une recherche de prises que ce dossier n'a pas pu lire", + "rejectedMoodQuery": "{name} a envoyé une recherche d'humeur que ce dossier n'a pas pu lire", + "rejectedMeasurementCreate": "{name} a tenté d'ajouter une mesure et a été refusé", + "rejectedMeasurementBatch": "{name} a tenté d'ajouter plusieurs mesures et a été refusé", + "rejectedMeasurementUpdate": "{name} a tenté de modifier une mesure et a été refusé", + "rejectedLabCreate": "{name} a tenté d'ajouter un résultat de laboratoire et a été refusé", + "rejectedLabUpdate": "{name} a tenté de modifier un résultat de laboratoire et a été refusé", + "rejectedBiomarkerCreate": "{name} a tenté d'ajouter un biomarqueur et a été refusé", + "rejectedBiomarkerUpdate": "{name} a tenté de modifier un biomarqueur et a été refusé", + "rejectedReminderCreate": "{name} a tenté d'ajouter un rappel de mesure et a été refusé", + "rejectedReminderUpdate": "{name} a tenté de modifier un rappel de mesure et a été refusé", + "rejectedMoodCreate": "{name} a tenté d'ajouter une entrée d'humeur et a été refusé", + "rejectedMoodUpdate": "{name} a tenté de modifier une entrée d'humeur et a été refusé", + "rejectedDoseCreate": "{name} a tenté d'enregistrer une prise et a été refusé", + "rejectedDoseUpdate": "{name} a tenté de modifier une prise et a été refusé", + "rejectedDoseImport": "{name} a tenté d'importer des prises et a été refusé", + "rejectedTrackedValue": "{name} a tenté d'enregistrer une valeur personnalisée et a été refusé", + "deniedDoseImport": "Un import de prises lancé par {name} n'a pas pu être mis en file", + "anamnesisFactCreate": "{name} a ajouté un élément d'antécédents de santé", + "customMetricCreate": "{name} a ajouté un type de valeur personnalisée", + "customMetricUpdate": "{name} a modifié un type de valeur personnalisée", + "customMetricDelete": "{name} a supprimé un type de valeur personnalisée", + "documentStore": "{name} a ajouté un document", + "documentUpdate": "{name} a modifié un document", + "documentDiscard": "{name} a écarté un document", + "medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé", + "medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}" + }, + "section": { + "measurements": { + "label": "Mesures", + "detail": "Poids, tension artérielle, pouls, glycémie, sommeil, séances et les valeurs que vous suivez." + }, + "medications": { + "label": "Médicaments", + "detail": "La liste des médicaments, leurs horaires, les prises, les effets indésirables et les stocks." + }, + "labs": { + "label": "Résultats de laboratoire", + "detail": "Les résultats de laboratoire et la liste des analytes correspondants." + }, + "profile": { + "label": "Antécédents de santé", + "detail": "Allergies, antécédents familiaux et les informations de votre profil de santé." + }, + "illness": { + "label": "Maladies", + "detail": "Les épisodes de maladie et les journaux quotidiens qui s'y rattachent." + }, + "mind": { + "label": "Humeur et santé mentale", + "detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale." + }, + "cycle": { + "label": "Cycle", + "detail": "Le suivi du cycle." + }, + "documents": { + "label": "Documents", + "detail": "Tout ce qui est conservé dans le coffre à documents." + } + }, + "actionError": { + "retry": "Réessayer", + "offline": "Cela n'a pas atteint le serveur. Rien n'a changé — vérifiez votre connexion et réessayez.", + "rateLimited": "Trop de tentatives d'affilée. Attendez une minute et réessayez.", + "acceptExpired": "Cette invitation a expiré. Demandez-en une nouvelle.", + "acceptWithdrawn": "Cette invitation a été retirée.", + "acceptNotPending": "Cette invitation a déjà été acceptée.", + "acceptNotFound": "Cette invitation n'existe plus.", + "acceptFailed": "L'invitation n'a pas pu être acceptée.", + "lastGuardian": "Cette personne est la seule à s'occuper de ce profil, donc cet accès ne peut pas encore prendre fin. Ajoutez d'abord une autre personne responsable, ou supprimez le profil.", + "alreadyEnded": "Cet accès a déjà pris fin.", + "notFound": "Cet accès n'existe plus.", + "revokeFailed": "L'accès n'a pas pu être terminé.", + "renounceFailed": "L'accès n'a pas pu être rendu.", + "profileNotFound": "Ce profil n'existe plus — il a peut-être été supprimé, ou votre accès a pris fin.", + "deleteProfileFailed": "Le profil n'a pas pu être supprimé.", + "removeGuardianFailed": "Cet accès n'a pas pu prendre fin." + }, + "anamnesis": { + "relativeNote": "Un antécédent familial décrit les proches de cette personne. Ne l'ajoutez que si elle voudrait le voir consigné ici." } } } diff --git a/messages/it.json b/messages/it.json index 1a11a9ab5..72a8faeb1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -341,6 +341,7 @@ }, "cycle": "Ciclo", "labs": "Esami", + "profile": "Profilo", "vorsorge": "Controlli", "illness": "Malattia", "documents": "Documenti", @@ -4433,6 +4434,121 @@ "system": "Sistema" } }, + "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profilo", + "displayName": "Nome visualizzato", + "height": "Altezza (cm)", + "dateOfBirth": "Data di nascita", + "gender": "Genere", + "notSpecified": "Non specificato", + "male": "Uomo", + "female": "Donna", + "other": "Altro", + "locale": "Lingua", + "systemDefault": "Predefinito di sistema", + "timezone": "Fuso orario", + "units": "Unità", + "metric": "Metrico", + "imperial": "Imperiale", + "timeFormat": "Formato ora", + "automatic": "Automatico", + "hour12": "12 ore", + "hour24": "24 ore", + "dateFormat": "Formato data", + "dateDmy": "Giorno / mese / anno", + "dateMdy": "Mese / giorno / anno", + "dateYmd": "Anno / mese / giorno" + }, + "modules": { + "title": "Moduli" + }, + "notifications": { + "title": "Notifiche", + "moodReminder": "Attiva promemoria dell’umore", + "reminderHour": "Ora del promemoria dell’umore", + "lowStockRunwayDays": "Giorni prima delle scorte basse", + "reorderLeadDays": "Margine per il riordino" + }, + "thresholds": { + "title": "Obiettivi", + "metric": "Metrica", + "minimum": "Minimo", + "maximum": "Massimo" + }, + "coach": { + "title": "Coach", + "disable": "Disattiva Coach", + "tone": "Tono", + "warm": "Caldo", + "neutral": "Neutro", + "concise": "Conciso", + "verbosity": "Livello di dettaglio", + "brief": "Breve", + "default": "Predefinito", + "detailed": "Dettagliato", + "defaultWindow": "Periodo predefinito", + "last7days": "Ultimi 7 giorni", + "last30days": "Ultimi 30 giorni", + "last90days": "Ultimi 90 giorni", + "allTime": "Tutto il periodo", + "showEvidence": "Mostra le fonti per impostazione predefinita", + "excludedMetrics": "Metriche escluse", + "dataClusters": "Gruppi di dati", + "customClusters": "Usa una selezione personalizzata di gruppi", + "metrics": { + "bp": "Pressione arteriosa", + "weight": "Peso", + "pulse": "Polso", + "mood": "Umore", + "compliance": "Aderenza ai farmaci", + "hrv": "Variabilità della frequenza cardiaca", + "sleep": "Sonno", + "restingHr": "Frequenza cardiaca a riposo", + "steps": "Passi", + "medications": "Farmaci", + "anthropometrics": "Misure corporee" + }, + "clusters": { + "cardio": "Salute cardiovascolare", + "body": "Composizione corporea", + "activity": "Attività", + "workouts": "Allenamenti", + "sleep": "Sonno", + "mood": "Umore", + "glucose": "Glucosio", + "medication": "Farmaci", + "mobility": "Mobilità", + "environment": "Ambiente" + } + }, + "insights": { + "title": "Analisi", + "sections": "Sezioni della panoramica", + "tiles": "Riquadri di navigazione", + "order": "Ordine", + "unknownItem": "Analisi non disponibile", + "sectionLabels": { + "wellnessScores": "Punteggi di benessere", + "dailyBriefing": "Riepilogo giornaliero", + "vitals": "Parametri vitali", + "trends": "Tendenze", + "periodReview": "Riepilogo del periodo", + "cycleSummary": "Riepilogo del ciclo", + "signals": "Segnali", + "rhythmEvents": "Eventi del ritmo", + "healthStatus": "Stato di salute", + "breathing": "Respirazione", + "labsChanges": "Variazioni di laboratorio", + "ecg": "ECG" + } + } + }, + "unavailableTitle": "Non disponibile in questo record condiviso", + "unavailableDescription": "Questa area delle Impostazioni non è disponibile intenzionalmente mentre visualizzi il record di un’altra persona.", + "integrationStatusDescription": "Stato di connessione per questo record gestito. I controlli di connessione non sono disponibili." + }, "sections": { "account": { "title": "Account", @@ -9406,7 +9522,7 @@ }, "unavailable": { "title": "Non fa parte dell'accesso condiviso", - "description": "La condivisione riguarda la cartella sanitaria in sé. Impostazioni, servizi collegati, notifiche e funzioni di IA restano alla persona proprietaria dell'account.", + "description": "La condivisione riguarda la cartella sanitaria in sé, compresa l'anamnesi. Le impostazioni dell'account, i servizi collegati, le notifiche e le funzioni di IA restano alla persona proprietaria dell'account.", "leave": "Torna alla mia cartella" }, "state": { @@ -9418,6 +9534,10 @@ "row": { "readOnly": "Può leggere la cartella", "canWrite": "Può leggere e aggiungere voci", + "canManage": "Può leggere la cartella, aggiungere voci e modificare o eliminare ciò che contiene", + "entireRecord": "Copre l'intera cartella", + "sections": "Copre: {sections}", + "noSections": "Non copre nulla: questo accesso non apre alcuna parte della cartella", "invited": "Invitato il", "accepted": "Accettato il", "expires": "Scade il", @@ -9439,6 +9559,19 @@ "accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.", "accessWriteLabel": "Può vedere e aggiungere", "accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.", + "accessManageLabel": "Può gestire", + "accessManageCapability": "Può aggiungere, modificare ed eliminare voci della tua cartella, comprese quelle inserite da te, e può leggere le analisi generate a partire da essa. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.", + "manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.", + "scopeLegend": "Che cosa può aprire", + "scopeAllLabel": "Intera cartella", + "scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.", + "scopeSomeLabel": "Solo le sezioni che scegli", + "scopeSomeCapability": "Tutto ciò che non scegli resta chiuso per lei.", + "scopeManageNote": "L'accesso di gestione copre sempre l'intera cartella.", + "scopeLeakNote": "Una sezione può menzionare il resto della tua salute nelle note e nei nomi. Condividere una sezione significa condividere tutto ciò che vi è scritto.", + "scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.", + "scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.", + "scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.", "expiresLabel": "L'accesso termina il (facoltativo)", "expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.", "submit": "Invia invito", @@ -9449,7 +9582,11 @@ "errorSelf": "Questo è il tuo account.", "errorInvalid": "Inserisci un nome utente o un indirizzo e-mail.", "errorRateLimited": "Troppi inviti. Riprova più tardi.", - "errorFailed": "Non è stato possibile inviare l'invito. Riprova." + "errorFailed": "Non è stato possibile inviare l'invito. Riprova.", + "errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.", + "errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.", + "errorStepUp": "Conferma il tuo secondo fattore e riprova.", + "errorOffline": "La richiesta non ha raggiunto il server. Il tuo invito è ancora qui: controlla la connessione e riprova." }, "given": { "title": "Chi può aprire la mia cartella", @@ -9474,6 +9611,7 @@ "accept": "Accetta", "accepting": "Accettazione…", "writeConsent": "Se accetti, ciò che aggiungi entra a far parte della cartella di {name}. Dopo non potrai più modificare o rimuovere le voci; può farlo solo {name}.", + "manageConsent": "Se accetti, potrai aggiungere voci alla cartella di {name} e anche modificare o eliminare quelle già presenti, comprese quelle inserite da {name}. Tutto ciò che fai viene registrato con il tuo nome nella sua vista attività, e {name} può terminare il tuo accesso in qualsiasi momento.", "open": "Apri", "renounce": "Restituisci", "renounceTitle": "Restituire la cartella di {name}?", @@ -9489,7 +9627,8 @@ "loadError": "Non è stato possibile caricare l'attività.", "opened": "{name} ha aperto la tua cartella {count}×", "acted": "{name} ha fatto una modifica nella tua cartella", - "deletedAccount": "Un account eliminato" + "deletedAccount": "Un account eliminato", + "truncated": "Questo elenco mostra l'attività più recente, non tutta." }, "delegate": { "addOnlyNote": "Puoi aggiungere voci a questa cartella. Solo {name} può modificarle o eliminarle." @@ -9499,8 +9638,65 @@ "description": "Cartelle che altre persone hanno condiviso con te.", "levelRead": "Può consultare", "levelWrite": "Può consultare e aggiungere", + "kindShared": "Cartella condivisa", + "kindManaged": "Profilo gestito", "open": "Apri la cartella di {name}" }, + "managed": { + "title": "Profili di cui ti occupi", + "description": "Cartelle sanitarie senza credenziali proprie.", + "explainer": "Un profilo gestito è una cartella sanitaria per qualcuno che non accede da sé — un bambino, o una persona di cui ti prendi cura. Non ha credenziali né indirizzo e-mail, e tu ne diventi la prima persona responsabile. Più avanti puoi aggiungerne altre, e ognuna può entrare nella cartella per tenerla aggiornata.", + "nameLabel": "Nome", + "namePlaceholder": "Il nome con cui riconoscerai questa cartella", + "nameHint": "Fino a 80 caratteri. Compare ovunque appaia questa cartella.", + "nameRequired": "Dai un nome alla cartella.", + "nameTooLong": "Questo nome supera gli 80 caratteri.", + "dobLabel": "Data di nascita (facoltativa)", + "dobHint": "Lascia il campo vuoto se preferisci non registrarla. Da un anno di nascita non si deduce nulla.", + "localeLabel": "Lingua di questa cartella", + "localeHint": "La lingua dei testi scritti in questa cartella. Le tue schermate restano nella tua lingua.", + "timezoneHint": "Il fuso in cui si contano le giornate di questa cartella.", + "timezoneInvalid": "Questo browser non conosce quel fuso orario.", + "create": "Crea profilo", + "creating": "Creazione in corso…", + "created": "{name} è pronto. Puoi entrare in quella cartella dal menu dell'account.", + "errorStepUp": "Conferma il tuo secondo fattore e riprova.", + "errorOffline": "Non è arrivato al server. Non è cambiato nulla — controlla la connessione e riprova.", + "errorInvalid": "Il server ha rifiutato questi dati. Controlla nome, data e fuso orario.", + "errorFailed": "Non è stato possibile creare il profilo.", + "delete": "Elimina profilo", + "deleteTitle": "Eliminare {name}?", + "deleteBody": "Tutto ciò che è stato registrato per {name} sparisce insieme al profilo — misurazioni, farmaci, documenti e note. Non si può annullare.", + "deleteGuardiansOne": "{count} persona si occupa oggi di questa cartella e la perderà.", + "deleteGuardiansFew": "{count} persone si occupano oggi di questa cartella e la perderanno.", + "deleteGuardiansOther": "{count} persone si occupano oggi di questa cartella e la perderanno.", + "deleteFloorNote": "È anche la via d'uscita quando sei l'ultima persona responsabile: puoi chiudere la cartella, ma non lasciarla senza nessuno.", + "deleteConfirm": "Elimina profilo", + "guardiansTitle": "Chi si occupa di {name}", + "guardianYou": "(tu)", + "guardianSince": "Si occupa di questa cartella dal {date}", + "guardianPending": "Invitata il {date}, non ancora accettata", + "guardianRemove": "Termina accesso", + "guardianRemoveTitle": "Terminare l'accesso di {name}?", + "guardianRemoveBody": "{name} non potrà più aprire {profile}. Quello che ha inserito resta nella cartella.", + "guardianWithdrawBody": "L'invito a {name} viene ritirato. Non ha mai aperto {profile}, quindi nella cartella non cambia nulla.", + "guardianRemoveConfirm": "Termina accesso", + "soleGuardian": "Sei l'unica persona che si occupa di {name}, quindi qui non c'è nessun accesso da terminare. Invita qualcuno e aspetta che accetti, restituisci il tuo accesso in «Cartelle che posso aprire», oppure elimina il profilo.", + "guardianInviteLabel": "Invita un'altra persona responsabile", + "guardianInvite": "Invia invito", + "guardianInviting": "Invio in corso…", + "guardianInvited": "Invito inviato a {name}.", + "guardianExpiresHint": "Facoltativo. L'accesso termina alla fine di quel giorno nel tuo fuso orario.", + "guardianPendingNote": "Una persona invitata conta solo quando accetta. Fino ad allora sei ancora l'ultima, e restituire il tuo accesso viene comunque rifiutato.", + "guardianErrorDuplicate": "Quell'account ha già accesso a questa cartella.", + "guardianErrorManagedInvitee": "Un profilo gestito non può occuparsi di un'altra cartella. Invita un account che accede da sé.", + "guardianErrorNoAccount": "Su questa istanza non esiste un account con quel nome o quella e-mail.", + "guardianErrorRateLimit": "Troppi inviti nell'ultima ora. Aspetta un po' e riprova.", + "guardianErrorFailed": "Non è stato possibile inviare l'invito.", + "errorMfaRequired": "Serve un secondo fattore e il tuo account non ne ha ancora uno. Impostane uno in Impostazioni → Sicurezza e riprova.", + "rosterLoadError": "Non è stato possibile caricare chi si occupa di questa cartella.", + "errorRateLimit": "Troppi profili creati nell'ultima ora. Aspetta un po' e riprova." + }, "toast": { "savedTo": "Salvato nella cartella di {name}." }, @@ -9515,7 +9711,143 @@ "medicationSideEffectCreate": "{name} ha annotato un effetto collaterale", "medicationCreate": "{name} ha aggiunto un farmaco", "medicationIntake": "{name} ha registrato una dose", - "medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato" + "medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato", + "measurementUpdate": "{name} ha modificato una misurazione", + "measurementDelete": "{name} ha eliminato una misurazione", + "measurementDeleteBulk": "{name} ha eliminato più misurazioni in una volta", + "measurementRestore": "{name} ha ripristinato una misurazione", + "measurementReminderCreate": "{name} ha aggiunto un promemoria di misurazione", + "measurementReminderUpdate": "{name} ha modificato un promemoria di misurazione", + "measurementReminderDelete": "{name} ha eliminato un promemoria di misurazione", + "measurementReminderComplete": "{name} ha completato un promemoria di misurazione", + "measurementReminderSatisfy": "{name} ha chiuso un promemoria di misurazione", + "labResultUpdate": "{name} ha modificato un risultato di laboratorio", + "labResultDelete": "{name} ha eliminato un risultato di laboratorio", + "labResultRestore": "{name} ha ripristinato un risultato di laboratorio", + "biomarkerUpdate": "{name} ha modificato un biomarcatore", + "biomarkerDelete": "{name} ha eliminato un biomarcatore", + "allergyUpdate": "{name} ha modificato un'allergia", + "allergyDelete": "{name} ha eliminato un'allergia", + "familyHistoryUpdate": "{name} ha modificato una voce di anamnesi familiare", + "familyHistoryDelete": "{name} ha eliminato una voce di anamnesi familiare", + "illnessEpisodeUpdate": "{name} ha modificato una malattia", + "illnessEpisodeDelete": "{name} ha eliminato una malattia", + "illnessEpisodeResolve": "{name} ha segnato una malattia come conclusa", + "illnessEpisodeRestore": "{name} ha ripristinato una malattia", + "illnessDayLogUpsert": "{name} ha registrato una giornata di malattia", + "medicationUpdate": "{name} ha modificato un farmaco", + "medicationDelete": "{name} ha eliminato un farmaco", + "medicationGlp1Update": "{name} ha modificato una terapia GLP-1", + "medicationScheduleCreate": "{name} ha aggiunto uno schema di assunzione", + "medicationScheduleUpdate": "{name} ha modificato uno schema di assunzione", + "medicationScheduleDelete": "{name} ha eliminato uno schema di assunzione", + "medicationInventoryCreate": "{name} ha aggiunto una confezione di farmaco", + "medicationInventoryUpdate": "{name} ha modificato una confezione di farmaco", + "medicationInventoryDelete": "{name} ha eliminato una confezione di farmaco", + "medicationSideEffectDelete": "{name} ha eliminato un effetto collaterale", + "medicationIntakeUpdate": "{name} ha modificato una dose", + "medicationIntakeDelete": "{name} ha eliminato una dose", + "medicationIntakeBulkDelete": "{name} ha eliminato più dosi in una volta", + "medicationIntakePurge": "{name} ha cancellato tutto lo storico delle dosi di un farmaco", + "medicationIntakeImport": "{name} ha avviato un'importazione delle dosi di un farmaco", + "medicationIntakeHistoryImport": "{name} ha avviato un'importazione dello storico delle dosi", + "moodEntryCreate": "{name} ha aggiunto una voce dell'umore", + "moodEntryUpdate": "{name} ha modificato una voce dell'umore", + "moodEntryDelete": "{name} ha eliminato una voce dell'umore", + "moodDeleteBulk": "{name} ha eliminato più voci dell'umore in una volta", + "moodRestore": "{name} ha ripristinato una voce dell'umore", + "mentalHealthCreate": "{name} ha registrato il risultato di un questionario", + "cycleDelete": "{name} ha eliminato un ciclo", + "cycleDayLogUpsert": "{name} ha registrato un giorno del ciclo", + "cycleDayLogUpdate": "{name} ha modificato un giorno del ciclo", + "cycleDayLogDelete": "{name} ha eliminato un giorno del ciclo", + "cyclePeriodBoundary": "{name} ha modificato l'inizio o la fine di una mestruazione", + "cycleSymptomCreate": "{name} ha aggiunto un sintomo del ciclo", + "nutrientWaterWrite": "{name} ha registrato l'assunzione di acqua", + "healthRecordExport": "{name} ha esportato la cartella", + "rejectedMeasurementQuery": "{name} ha inviato una ricerca di misurazioni che questa cartella non ha potuto leggere", + "rejectedDoseQuery": "{name} ha inviato una ricerca di dosi che questa cartella non ha potuto leggere", + "rejectedMoodQuery": "{name} ha inviato una ricerca dell'umore che questa cartella non ha potuto leggere", + "rejectedMeasurementCreate": "{name} ha provato ad aggiungere una misurazione ed è stato rifiutato", + "rejectedMeasurementBatch": "{name} ha provato ad aggiungere più misurazioni ed è stato rifiutato", + "rejectedMeasurementUpdate": "{name} ha provato a modificare una misurazione ed è stato rifiutato", + "rejectedLabCreate": "{name} ha provato ad aggiungere un risultato di laboratorio ed è stato rifiutato", + "rejectedLabUpdate": "{name} ha provato a modificare un risultato di laboratorio ed è stato rifiutato", + "rejectedBiomarkerCreate": "{name} ha provato ad aggiungere un biomarcatore ed è stato rifiutato", + "rejectedBiomarkerUpdate": "{name} ha provato a modificare un biomarcatore ed è stato rifiutato", + "rejectedReminderCreate": "{name} ha provato ad aggiungere un promemoria di misurazione ed è stato rifiutato", + "rejectedReminderUpdate": "{name} ha provato a modificare un promemoria di misurazione ed è stato rifiutato", + "rejectedMoodCreate": "{name} ha provato ad aggiungere una voce dell'umore ed è stato rifiutato", + "rejectedMoodUpdate": "{name} ha provato a modificare una voce dell'umore ed è stato rifiutato", + "rejectedDoseCreate": "{name} ha provato a registrare una dose ed è stato rifiutato", + "rejectedDoseUpdate": "{name} ha provato a modificare una dose ed è stato rifiutato", + "rejectedDoseImport": "{name} ha provato a importare delle dosi ed è stato rifiutato", + "rejectedTrackedValue": "{name} ha provato a registrare un valore personalizzato ed è stato rifiutato", + "deniedDoseImport": "Un'importazione di dosi avviata da {name} non è stata messa in coda", + "anamnesisFactCreate": "{name} ha aggiunto un dato di anamnesi", + "customMetricCreate": "{name} ha aggiunto un tipo di valore personalizzato", + "customMetricUpdate": "{name} ha modificato un tipo di valore personalizzato", + "customMetricDelete": "{name} ha eliminato un tipo di valore personalizzato", + "documentStore": "{name} ha aggiunto un documento", + "documentUpdate": "{name} ha modificato un documento", + "documentDiscard": "{name} ha scartato un documento", + "medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata", + "medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}" + }, + "section": { + "measurements": { + "label": "Misurazioni", + "detail": "Peso, pressione, polso, glicemia, sonno, allenamenti e i valori che monitori." + }, + "medications": { + "label": "Farmaci", + "detail": "L'elenco dei farmaci, gli orari, le dosi assunte, gli effetti collaterali e le scorte." + }, + "labs": { + "label": "Esami di laboratorio", + "detail": "I risultati di laboratorio e l'elenco degli analiti che li compongono." + }, + "profile": { + "label": "Anamnesi", + "detail": "Allergie, storia familiare e i dati del tuo profilo di salute." + }, + "illness": { + "label": "Malattie", + "detail": "Gli episodi di malattia e i diari giornalieri collegati." + }, + "mind": { + "label": "Umore e benessere mentale", + "detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale." + }, + "cycle": { + "label": "Ciclo", + "detail": "Il monitoraggio del ciclo." + }, + "documents": { + "label": "Documenti", + "detail": "Tutto ciò che è conservato nell'archivio documenti." + } + }, + "actionError": { + "retry": "Riprova", + "offline": "La richiesta non ha raggiunto il server. Non è cambiato nulla: controlla la connessione e riprova.", + "rateLimited": "Troppi tentativi di fila. Aspetta un minuto e riprova.", + "acceptExpired": "Questo invito è scaduto. Chiedine uno nuovo.", + "acceptWithdrawn": "Questo invito è stato ritirato.", + "acceptNotPending": "Questo invito è già stato accettato.", + "acceptNotFound": "Questo invito non esiste più.", + "acceptFailed": "Non è stato possibile accettare l'invito.", + "lastGuardian": "Questa è l'unica persona che si occupa di quel profilo, quindi questo accesso non può ancora finire. Aggiungi prima un'altra persona responsabile, oppure elimina il profilo.", + "alreadyEnded": "Questo accesso è già terminato.", + "notFound": "Questo accesso non esiste più.", + "revokeFailed": "Non è stato possibile terminare l'accesso.", + "renounceFailed": "Non è stato possibile restituire l'accesso.", + "profileNotFound": "Quel profilo non c'è più — potrebbe essere già stato eliminato, o il tuo accesso è terminato.", + "deleteProfileFailed": "Non è stato possibile eliminare il profilo.", + "removeGuardianFailed": "Non è stato possibile terminare quell'accesso." + }, + "anamnesis": { + "relativeNote": "Una voce di anamnesi familiare descrive i parenti di questa persona. Aggiungila solo se vorrebbe vederla registrata qui." } } } diff --git a/messages/pl.json b/messages/pl.json index 5f5bc482c..ddd1b58b4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -341,6 +341,7 @@ }, "cycle": "Cykl", "labs": "Wyniki badań", + "profile": "Profil", "vorsorge": "Badania", "illness": "Choroba", "documents": "Dokumenty", @@ -4433,6 +4434,121 @@ "system": "System" } }, + "sharedRecord": { + "managedSettings": { + "profile": { + "title": "Profil", + "displayName": "Wyświetlana nazwa", + "height": "Wzrost (cm)", + "dateOfBirth": "Data urodzenia", + "gender": "Płeć", + "notSpecified": "Nie podano", + "male": "Mężczyzna", + "female": "Kobieta", + "other": "Inna", + "locale": "Język", + "systemDefault": "Ustawienie systemowe", + "timezone": "Strefa czasowa", + "units": "Jednostki", + "metric": "Metryczne", + "imperial": "Imperialne", + "timeFormat": "Format czasu", + "automatic": "Automatyczny", + "hour12": "12-godzinny", + "hour24": "24-godzinny", + "dateFormat": "Format daty", + "dateDmy": "Dzień / miesiąc / rok", + "dateMdy": "Miesiąc / dzień / rok", + "dateYmd": "Rok / miesiąc / dzień" + }, + "modules": { + "title": "Moduły" + }, + "notifications": { + "title": "Powiadomienia", + "moodReminder": "Włącz przypomnienie nastroju", + "reminderHour": "Godzina przypomnienia nastroju", + "lowStockRunwayDays": "Dni do niskiego stanu zapasów", + "reorderLeadDays": "Wyprzedzenie ponownego zamówienia" + }, + "thresholds": { + "title": "Cele", + "metric": "Metryka", + "minimum": "Minimum", + "maximum": "Maksimum" + }, + "coach": { + "title": "Trener", + "disable": "Wyłącz trenera", + "tone": "Ton", + "warm": "Ciepły", + "neutral": "Neutralny", + "concise": "Zwięzły", + "verbosity": "Szczegółowość", + "brief": "Krótka", + "default": "Domyślna", + "detailed": "Szczegółowa", + "defaultWindow": "Domyślny okres", + "last7days": "Ostatnie 7 dni", + "last30days": "Ostatnie 30 dni", + "last90days": "Ostatnie 90 dni", + "allTime": "Cały okres", + "showEvidence": "Domyślnie pokazuj źródła", + "excludedMetrics": "Wykluczone metryki", + "dataClusters": "Grupy danych", + "customClusters": "Użyj własnego wyboru grup", + "metrics": { + "bp": "Ciśnienie krwi", + "weight": "Masa ciała", + "pulse": "Tętno", + "mood": "Nastrój", + "compliance": "Przestrzeganie przyjmowania leków", + "hrv": "Zmienność rytmu serca", + "sleep": "Sen", + "restingHr": "Tętno spoczynkowe", + "steps": "Kroki", + "medications": "Leki", + "anthropometrics": "Wymiary ciała" + }, + "clusters": { + "cardio": "Zdrowie sercowo-naczyniowe", + "body": "Skład ciała", + "activity": "Aktywność", + "workouts": "Treningi", + "sleep": "Sen", + "mood": "Nastrój", + "glucose": "Glukoza", + "medication": "Leki", + "mobility": "Mobilność", + "environment": "Środowisko" + } + }, + "insights": { + "title": "Wnioski", + "sections": "Sekcje przeglądu", + "tiles": "Kafelki nawigacji", + "order": "Kolejność", + "unknownItem": "Niedostępna analiza", + "sectionLabels": { + "wellnessScores": "Wyniki dobrostanu", + "dailyBriefing": "Codzienny przegląd", + "vitals": "Parametry życiowe", + "trends": "Trendy", + "periodReview": "Przegląd okresu", + "cycleSummary": "Podsumowanie cyklu", + "signals": "Sygnały", + "rhythmEvents": "Zdarzenia rytmu", + "healthStatus": "Stan zdrowia", + "breathing": "Oddychanie", + "labsChanges": "Zmiany wyników laboratoryjnych", + "ecg": "EKG" + } + } + }, + "unavailableTitle": "Niedostępne w tym udostępnionym rekordzie", + "unavailableDescription": "Ten obszar Ustawień jest celowo niedostępny podczas przeglądania rekordu innej osoby.", + "integrationStatusDescription": "Stan połączenia dla tego zarządzanego rekordu. Elementy sterujące połączeniem są niedostępne." + }, "sections": { "account": { "title": "Konto", @@ -9406,7 +9522,7 @@ }, "unavailable": { "title": "Nie należy do udostępnionego dostępu", - "description": "Udostępniana jest sama karta zdrowia. Ustawienia, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.", + "description": "Udostępniana jest sama karta zdrowia, w tym wywiad zdrowotny w sekcji Anamneza. Ustawienia konta, połączone usługi, powiadomienia i funkcje SI pozostają przy osobie, do której należy konto.", "leave": "Wróć do mojej karty" }, "state": { @@ -9418,6 +9534,10 @@ "row": { "readOnly": "Może czytać kartę", "canWrite": "Może czytać i dodawać wpisy", + "canManage": "Może czytać kartotekę, dodawać wpisy oraz zmieniać i usuwać to, co w niej jest", + "entireRecord": "Obejmuje całą kartotekę", + "sections": "Obejmuje: {sections}", + "noSections": "Nie obejmuje niczego: ten dostęp nie otwiera żadnej części kartoteki", "invited": "Zaproszono", "accepted": "Zaakceptowano", "expires": "Wygasa", @@ -9439,6 +9559,19 @@ "accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.", "accessWriteLabel": "Może przeglądać i dodawać", "accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.", + "accessManageLabel": "Może zarządzać", + "accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy w Twojej kartotece, także te, które wprowadziłeś samodzielnie, oraz czytać wygenerowane na jej podstawie analizy. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.", + "manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.", + "scopeLegend": "Co ta osoba może otworzyć", + "scopeAllLabel": "Cała kartoteka", + "scopeAllCapability": "Każda jej część, tak jak udostępnianie działało do tej pory.", + "scopeSomeLabel": "Tylko wybrane przez Ciebie sekcje", + "scopeSomeCapability": "Wszystko, czego nie wybierzesz, pozostaje dla niej zamknięte.", + "scopeManageNote": "Dostęp zarządzający zawsze obejmuje całą kartotekę.", + "scopeLeakNote": "Sekcja może wspominać o reszcie Twojego zdrowia w notatkach i nazwach. Udostępnienie sekcji oznacza udostępnienie wszystkiego, co w niej zapisano.", + "scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.", + "scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.", + "scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.", "expiresLabel": "Dostęp kończy się (opcjonalnie)", "expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.", "submit": "Wyślij zaproszenie", @@ -9449,7 +9582,11 @@ "errorSelf": "To twoje własne konto.", "errorInvalid": "Podaj nazwę użytkownika lub adres e-mail.", "errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.", - "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie." + "errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.", + "errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.", + "errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.", + "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", + "errorOffline": "To nie dotarło do serwera. Twoje zaproszenie nadal tu jest — sprawdź połączenie i spróbuj ponownie." }, "given": { "title": "Kto może otworzyć moją kartę", @@ -9474,6 +9611,7 @@ "accept": "Akceptuj", "accepting": "Akceptowanie…", "writeConsent": "Jeśli zaakceptujesz, to, co dodasz, stanie się częścią karty użytkownika {name}. Później nie będziesz mieć możliwości zmiany ani usunięcia wpisów; może to zrobić tylko {name}.", + "manageConsent": "Jeśli zaakceptujesz, będziesz mieć możliwość uzupełniania kartoteki użytkownika {name}, a także zmieniania i usuwania tego, co już w niej jest, łącznie z wpisami samego {name}. Wszystko, co zrobisz, zostanie zapisane pod Twoim imieniem w widoku aktywności, a {name} może zakończyć Twój dostęp w dowolnej chwili.", "open": "Otwórz", "renounce": "Zwróć", "renounceTitle": "Zwrócić kartę użytkownika {name}?", @@ -9489,7 +9627,8 @@ "loadError": "Nie udało się wczytać aktywności.", "opened": "{name} otworzył twoją kartę {count}×", "acted": "{name} wprowadził zmianę w twojej karcie", - "deletedAccount": "Usunięte konto" + "deletedAccount": "Usunięte konto", + "truncated": "Ta lista pokazuje najnowszą aktywność, a nie całą." }, "delegate": { "addOnlyNote": "Możesz dodawać wpisy do tej karty. Zmieniać je i usuwać może tylko {name}." @@ -9499,8 +9638,65 @@ "description": "Karty, które udostępniły ci inne osoby.", "levelRead": "Może przeglądać", "levelWrite": "Może przeglądać i dodawać", + "kindShared": "Udostępniona karta", + "kindManaged": "Zarządzany profil", "open": "Otwórz kartę użytkownika {name}" }, + "managed": { + "title": "Profile, którymi się opiekujesz", + "description": "Karty zdrowia bez własnego logowania.", + "explainer": "Zarządzany profil to karta zdrowia osoby, która sama się nie loguje — dziecka albo kogoś, kim się opiekujesz. Nie ma logowania ani adresu e-mail, a Ty stajesz się pierwszą osobą opiekującą się profilem. Później możesz dodać kolejne, a każda z was może wejść do karty i ją uzupełniać.", + "nameLabel": "Nazwa", + "namePlaceholder": "Nazwa, po której rozpoznasz tę kartę", + "nameHint": "Do 80 znaków. Wyświetla się wszędzie tam, gdzie pojawia się ta karta.", + "nameRequired": "Nadaj karcie nazwę.", + "nameTooLong": "Ta nazwa ma więcej niż 80 znaków.", + "dobLabel": "Data urodzenia (opcjonalnie)", + "dobHint": "Zostaw puste, jeśli wolisz jej nie zapisywać. Z samego rocznika nic nie jest wyliczane.", + "localeLabel": "Język tej karty", + "localeHint": "Język tekstów zapisywanych w tej karcie. Twoje własne widoki pozostają w Twoim języku.", + "timezoneHint": "Strefa, w której liczone są dni tej karty.", + "timezoneInvalid": "Ta przeglądarka nie zna takiej strefy czasowej.", + "create": "Utwórz profil", + "creating": "Tworzenie…", + "created": "{name} jest gotowy. Do tej karty przejdziesz z menu konta.", + "errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie.", + "errorOffline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.", + "errorInvalid": "Serwer odrzucił te dane. Sprawdź nazwę, datę i strefę czasową.", + "errorFailed": "Nie udało się utworzyć profilu.", + "delete": "Usuń profil", + "deleteTitle": "Usunąć {name}?", + "deleteBody": "Wszystko, co zapisano dla {name}, znika razem z profilem — pomiary, leki, dokumenty i notatki. Tego nie da się cofnąć.", + "deleteGuardiansOne": "{count} osoba opiekuje się dziś tą kartą i ją straci.", + "deleteGuardiansFew": "{count} osoby opiekują się dziś tą kartą i ją stracą.", + "deleteGuardiansOther": "{count} osób opiekuje się dziś tą kartą i ją straci.", + "deleteFloorNote": "To także wyjście, gdy jesteś ostatnią osobą opiekującą się profilem: możesz zamknąć kartę, ale nie zostawić jej bez opieki.", + "deleteConfirm": "Usuń profil", + "guardiansTitle": "Kto opiekuje się kartą {name}", + "guardianYou": "(Ty)", + "guardianSince": "Opiekuje się tą kartą od {date}", + "guardianPending": "Zaproszenie z {date}, jeszcze nieprzyjęte", + "guardianRemove": "Zakończ dostęp", + "guardianRemoveTitle": "Zakończyć dostęp osoby {name}?", + "guardianRemoveBody": "{name} nie będzie już mogła otworzyć karty {profile}. To, co zapisała, zostaje w karcie.", + "guardianWithdrawBody": "Zaproszenie dla osoby {name} zostaje wycofane. Nigdy nie otworzyła karty {profile}, więc w karcie nic się nie zmienia.", + "guardianRemoveConfirm": "Zakończ dostęp", + "soleGuardian": "Jesteś jedyną osobą, która opiekuje się kartą {name}, więc nie ma tu dostępu, który mógłbyś zakończyć. Zaproś kogoś i poczekaj na przyjęcie, oddaj własny dostęp w sekcji „Karty, które mogę otworzyć” albo usuń profil.", + "guardianInviteLabel": "Zaproś kolejną osobę opiekującą się profilem", + "guardianInvite": "Wyślij zaproszenie", + "guardianInviting": "Wysyłanie…", + "guardianInvited": "Zaproszenie wysłane do osoby {name}.", + "guardianExpiresHint": "Opcjonalnie. Dostęp kończy się z końcem tego dnia w Twojej strefie czasowej.", + "guardianPendingNote": "Zaproszona osoba liczy się dopiero po przyjęciu zaproszenia. Do tego czasu wciąż jesteś ostatnia, a oddanie własnego dostępu nadal będzie odrzucane.", + "guardianErrorDuplicate": "To konto ma już dostęp do tej karty.", + "guardianErrorManagedInvitee": "Zarządzany profil nie może opiekować się inną kartą. Zaproś konto, które samo się loguje.", + "guardianErrorNoAccount": "W tej instancji nie ma konta o takiej nazwie ani takim adresie e-mail.", + "guardianErrorRateLimit": "Zbyt wiele zaproszeń w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie.", + "guardianErrorFailed": "Nie udało się wysłać zaproszenia.", + "errorMfaRequired": "To wymaga drugiego składnika logowania, a Twoje konto jeszcze go nie ma. Skonfiguruj go w Ustawieniach → Bezpieczeństwo i spróbuj ponownie.", + "rosterLoadError": "Nie udało się wczytać, kto opiekuje się tą kartą.", + "errorRateLimit": "Zbyt wiele profili utworzonych w ostatniej godzinie. Odczekaj chwilę i spróbuj ponownie." + }, "toast": { "savedTo": "Zapisano w karcie użytkownika {name}." }, @@ -9515,7 +9711,143 @@ "medicationSideEffectCreate": "{name} zanotował działanie niepożądane", "medicationCreate": "{name} dodał lek", "medicationIntake": "{name} zapisał przyjęcie dawki", - "medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)" + "medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)", + "measurementUpdate": "{name} zmienił pomiar", + "measurementDelete": "{name} usunął pomiar", + "measurementDeleteBulk": "{name} usunął kilka pomiarów naraz", + "measurementRestore": "{name} przywrócił pomiar", + "measurementReminderCreate": "{name} dodał przypomnienie o pomiarze", + "measurementReminderUpdate": "{name} zmienił przypomnienie o pomiarze", + "measurementReminderDelete": "{name} usunął przypomnienie o pomiarze", + "measurementReminderComplete": "{name} zrealizował przypomnienie o pomiarze", + "measurementReminderSatisfy": "{name} zamknął przypomnienie o pomiarze", + "labResultUpdate": "{name} zmienił wynik badania", + "labResultDelete": "{name} usunął wynik badania", + "labResultRestore": "{name} przywrócił wynik badania", + "biomarkerUpdate": "{name} zmienił biomarker", + "biomarkerDelete": "{name} usunął biomarker", + "allergyUpdate": "{name} zmienił alergię", + "allergyDelete": "{name} usunął alergię", + "familyHistoryUpdate": "{name} zmienił wpis w wywiadzie rodzinnym", + "familyHistoryDelete": "{name} usunął wpis w wywiadzie rodzinnym", + "illnessEpisodeUpdate": "{name} zmienił wpis o chorobie", + "illnessEpisodeDelete": "{name} usunął wpis o chorobie", + "illnessEpisodeResolve": "{name} oznaczył chorobę jako zakończoną", + "illnessEpisodeRestore": "{name} przywrócił wpis o chorobie", + "illnessDayLogUpsert": "{name} zapisał dzień choroby", + "medicationUpdate": "{name} zmienił lek", + "medicationDelete": "{name} usunął lek", + "medicationGlp1Update": "{name} zmienił terapię GLP-1", + "medicationScheduleCreate": "{name} dodał schemat dawkowania", + "medicationScheduleUpdate": "{name} zmienił schemat dawkowania", + "medicationScheduleDelete": "{name} usunął schemat dawkowania", + "medicationInventoryCreate": "{name} dodał opakowanie leku", + "medicationInventoryUpdate": "{name} zmienił opakowanie leku", + "medicationInventoryDelete": "{name} usunął opakowanie leku", + "medicationSideEffectDelete": "{name} usunął działanie niepożądane", + "medicationIntakeUpdate": "{name} zmienił przyjęcie dawki", + "medicationIntakeDelete": "{name} usunął przyjęcie dawki", + "medicationIntakeBulkDelete": "{name} usunął kilka przyjęć dawek naraz", + "medicationIntakePurge": "{name} skasował całą historię przyjmowania leku", + "medicationIntakeImport": "{name} rozpoczął import przyjęć dla jednego leku", + "medicationIntakeHistoryImport": "{name} rozpoczął import historii przyjmowania", + "moodEntryCreate": "{name} dodał wpis nastroju", + "moodEntryUpdate": "{name} zmienił wpis nastroju", + "moodEntryDelete": "{name} usunął wpis nastroju", + "moodDeleteBulk": "{name} usunął kilka wpisów nastroju naraz", + "moodRestore": "{name} przywrócił wpis nastroju", + "mentalHealthCreate": "{name} zapisał wynik kwestionariusza", + "cycleDelete": "{name} usunął cykl", + "cycleDayLogUpsert": "{name} zapisał dzień cyklu", + "cycleDayLogUpdate": "{name} zmienił dzień cyklu", + "cycleDayLogDelete": "{name} usunął dzień cyklu", + "cyclePeriodBoundary": "{name} zmienił początek lub koniec miesiączki", + "cycleSymptomCreate": "{name} dodał objaw cyklu", + "nutrientWaterWrite": "{name} zapisał ilość wypitej wody", + "healthRecordExport": "{name} wyeksportował kartę", + "rejectedMeasurementQuery": "{name} wysłał zapytanie o pomiary, którego ta karta nie mogła odczytać", + "rejectedDoseQuery": "{name} wysłał zapytanie o dawki, którego ta karta nie mogła odczytać", + "rejectedMoodQuery": "{name} wysłał zapytanie o nastrój, którego ta karta nie mogła odczytać", + "rejectedMeasurementCreate": "{name} próbował dodać pomiar i został odrzucony", + "rejectedMeasurementBatch": "{name} próbował dodać kilka pomiarów i został odrzucony", + "rejectedMeasurementUpdate": "{name} próbował zmienić pomiar i został odrzucony", + "rejectedLabCreate": "{name} próbował dodać wynik badania i został odrzucony", + "rejectedLabUpdate": "{name} próbował zmienić wynik badania i został odrzucony", + "rejectedBiomarkerCreate": "{name} próbował dodać biomarker i został odrzucony", + "rejectedBiomarkerUpdate": "{name} próbował zmienić biomarker i został odrzucony", + "rejectedReminderCreate": "{name} próbował dodać przypomnienie o pomiarze i został odrzucony", + "rejectedReminderUpdate": "{name} próbował zmienić przypomnienie o pomiarze i został odrzucony", + "rejectedMoodCreate": "{name} próbował dodać wpis nastroju i został odrzucony", + "rejectedMoodUpdate": "{name} próbował zmienić wpis nastroju i został odrzucony", + "rejectedDoseCreate": "{name} próbował zapisać przyjęcie dawki i został odrzucony", + "rejectedDoseUpdate": "{name} próbował zmienić przyjęcie dawki i został odrzucony", + "rejectedDoseImport": "{name} próbował zaimportować przyjęcia dawek i został odrzucony", + "rejectedTrackedValue": "{name} próbował zapisać własny pomiar i został odrzucony", + "deniedDoseImport": "Import przyjęć rozpoczęty przez {name} nie mógł zostać zakolejkowany", + "anamnesisFactCreate": "{name} dodał fakt z wywiadu zdrowotnego", + "customMetricCreate": "{name} dodał własny typ pomiaru", + "customMetricUpdate": "{name} zmienił własny typ pomiaru", + "customMetricDelete": "{name} usunął własny typ pomiaru", + "documentStore": "{name} dodał dokument", + "documentUpdate": "{name} zmienił dokument", + "documentDiscard": "{name} odrzucił dokument", + "medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się", + "medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}" + }, + "section": { + "measurements": { + "label": "Pomiary", + "detail": "Waga, ciśnienie, tętno, glukoza, sen, treningi i śledzone przez Ciebie wartości." + }, + "medications": { + "label": "Leki", + "detail": "Lista leków, harmonogramy, przyjęte dawki, działania niepożądane i stan zapasów." + }, + "labs": { + "label": "Wyniki badań", + "detail": "Wyniki laboratoryjne i lista analitów, które za nimi stoją." + }, + "profile": { + "label": "Historia zdrowia", + "detail": "Alergie, wywiad rodzinny i dane z Twojego profilu zdrowia." + }, + "illness": { + "label": "Choroby", + "detail": "Epizody chorobowe i prowadzone przy nich wpisy dzienne." + }, + "mind": { + "label": "Nastrój i zdrowie psychiczne", + "detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego." + }, + "cycle": { + "label": "Cykl", + "detail": "Śledzenie cyklu." + }, + "documents": { + "label": "Dokumenty", + "detail": "Wszystko, co jest przechowywane w sejfie dokumentów." + } + }, + "actionError": { + "retry": "Spróbuj ponownie", + "offline": "To nie dotarło do serwera. Nic się nie zmieniło — sprawdź połączenie i spróbuj ponownie.", + "rateLimited": "Zbyt wiele prób pod rząd. Odczekaj minutę i spróbuj ponownie.", + "acceptExpired": "To zaproszenie wygasło. Poproś o nowe.", + "acceptWithdrawn": "To zaproszenie zostało wycofane.", + "acceptNotPending": "To zaproszenie zostało już przyjęte.", + "acceptNotFound": "Tego zaproszenia już nie ma.", + "acceptFailed": "Nie udało się przyjąć zaproszenia.", + "lastGuardian": "Ta osoba jako jedyna opiekuje się tym profilem, więc ten dostęp nie może się jeszcze zakończyć. Dodaj najpierw kolejną osobę opiekującą się profilem albo usuń profil.", + "alreadyEnded": "Ten dostęp już się zakończył.", + "notFound": "Tego dostępu już nie ma.", + "revokeFailed": "Nie udało się zakończyć dostępu.", + "renounceFailed": "Nie udało się zwrócić dostępu.", + "profileNotFound": "Tego profilu już nie ma — mógł zostać usunięty albo Twój dostęp do niego się zakończył.", + "deleteProfileFailed": "Nie udało się usunąć profilu.", + "removeGuardianFailed": "Nie udało się zakończyć tego dostępu." + }, + "anamnesis": { + "relativeNote": "Wpis w wywiadzie rodzinnym opisuje bliskich tej osoby. Dodaj go tylko wtedy, gdy chciałaby, żeby został tu zapisany." } } } diff --git a/package.json b/package.json index d534aee92..4aa16d7cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.36.1", + "version": "1.37.0", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/playwright.config.ts b/playwright.config.ts index 3a79e9e88..069501a15 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -75,14 +75,70 @@ export default defineConfig({ projects: [ { name: "chromium-desktop", + // The disk-layer spec needs a live service worker, which the shared + // `use` block blocks for every other spec. It runs in its own project. + testIgnore: ["v137-record-session-fence-offline.spec.ts"], use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, colorScheme: "dark", }, }, + { + // v1.37.0 — the one project that lets the service worker run. + // + // Every other project blocks it, and the reason is written in the `use` + // block above: a worker-originated `fetch` is not subject to + // `page.route`, so the SW would bypass the per-spec route mocks. The + // record-session fence's disk-layer case is the exact inverse — its whole + // claim is about what the `healthlog-data-*` cache can and cannot serve + // across a switch, and with the worker blocked that cache is never + // populated, so its positive control could never pass. A check that + // cannot pass is worse than no check, so the spec gets a project rather + // than a caveat. + // + // It uses no route mocks for exactly this reason, and it is the only + // spec in this project. + name: "chromium-service-worker", + testMatch: ["v137-record-session-fence-offline.spec.ts"], + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 }, + colorScheme: "dark", + serviceWorkers: "allow", + }, + }, { name: "chromium-mobile", + // The scoped-sharing journeys each carry a persistent server-side + // session stamp. They use isolated desktop jars and are not responsive + // layout checks, so running a second project against the same stamps + // would test session interference rather than the record journeys. + testIgnore: [ + "v137-sharing-managed-profiles.spec.ts", + "v137-sharing-managed-profiles-a11y.spec.ts", + // v1.37.0 — every spec that MOVES a session's record selector runs in + // exactly one project. + // + // Each of these now has its own login and its own session row (see + // `FENCE_STORAGE_STATE_PATH` in `e2e/setup/global-setup.ts`), which is + // what lets them run beside each other at any worker count. A second + // PROJECT is the one thing a separate jar cannot fix: it would drive + // the very same row from two browsers at once, moving the epoch under + // itself. The scoped-sharing journeys above are excluded for the same + // reason, one release earlier. + "v137-record-session-fence.spec.ts", + "v137-sharing-cross-tab-session.spec.ts", + // The banner-geometry spec joined that list in v1.37.0, because it + // stopped faking the shared-record strip and started switching for + // real. It is the one entry here that measures a MOBILE property, so + // it does not simply lose it: it measures both widths inside the + // desktop project instead, where the shell below `md` renders from the + // same width-driven rules. Its file comment carries the reasoning. + "chrome-header-seam-banners.spec.ts", + // Runs only in the service-worker project. + "v137-record-session-fence-offline.spec.ts", + ], use: { // Pixel 5 — Chromium-based mobile profile so CI only needs // `playwright install chromium` instead of also pulling diff --git a/prisma/migrations/0296_account_grant_scope_and_manage/migration.sql b/prisma/migrations/0296_account_grant_scope_and_manage/migration.sql new file mode 100644 index 000000000..d6917254b --- /dev/null +++ b/prisma/migrations/0296_account_grant_scope_and_manage/migration.sql @@ -0,0 +1,40 @@ +-- v1.37.0 — a grant can open part of a record, and a grant can hand it over. +-- +-- Two additive changes, no backfill, no re-consent, and no existing row +-- changes meaning: +-- +-- * `scope_json` is NULL on every row that exists, and NULL means the entire +-- record. That is not a default standing in for a missing answer — it is +-- the answer every one of those rows was actually consented as, before +-- narrowing was a thing anybody could ask for. A non-NULL value is a +-- non-empty array of section keys from the closed vocabulary in +-- `src/lib/sharing/scope.ts`. +-- * `MANAGE` joins the level enum. No row becomes MANAGE here; the value +-- exists so a row can state the level instead of implying it, the same +-- reason `account_grant_access` was a type rather than a boolean from day +-- one (migration 0292). +-- +-- Json rather than a text[] or a join table. A join table would be the +-- normalised answer and it would be the wrong one: the set is read on every +-- delegated request as part of deciding an authorization, so it belongs on the +-- row the resolver already loads, and it is written exactly once, at +-- invitation, by a machine — never edited, never queried across grants. There +-- is no in-place widening anywhere in this feature, so the set has no update +-- path to keep consistent. A text[] would carry the same shape with a +-- Postgres-specific type Prisma models less directly, for no read this +-- product performs. +-- +-- No CHECK constraint asserting the array is non-empty, well-formed, or absent +-- on a MANAGE row. Those three conditions are enforced in +-- `src/lib/sharing/grants.ts`, at the one function that writes the column, and +-- the reason is the same one migrations 0290 and 0292 gave for declining their +-- own CHECKs: the delete-all-data guard seeds a row into every table by +-- walking `information_schema`, and a constraint it cannot satisfy breaks a +-- fixture whose whole value is that nobody hand-writes it. The READ side is +-- what actually protects the record anyway — `normaliseScope` resolves a +-- malformed blob to the empty set and refuses every section, so a bad value +-- that reached the column by any route grants nothing rather than everything. + +ALTER TYPE "account_grant_access" ADD VALUE 'MANAGE'; + +ALTER TABLE "account_grants" ADD COLUMN "scope_json" JSONB; diff --git a/prisma/migrations/0297_managed_profile/migration.sql b/prisma/migrations/0297_managed_profile/migration.sql new file mode 100644 index 000000000..83b0a252e --- /dev/null +++ b/prisma/migrations/0297_managed_profile/migration.sql @@ -0,0 +1,28 @@ +-- v1.37.0 — a record somebody else runs. +-- +-- A managed profile is an account for a person who cannot run their own +-- record: a child, a parent under care. It has a display name, no e-mail and +-- no password, never signs in, and is administered by whoever holds an active +-- MANAGE grant on it. +-- +-- One column, and the whole of the feature's identity fence hangs off it. An +-- invited adult who granted management of their OWN record must never reach +-- the settings, integrations, notification routing, grant management, AI +-- configuration, export or deletion of that record — those are the surfaces +-- that would let a manager outlive their own revocation. On a managed profile +-- there is no self to reserve them for, so the guardian holds them. The gate +-- is this column and not the grant level, which is what makes the two cases +-- structurally different rather than differently argued. +-- +-- A timestamp rather than a boolean, deliberately. It says since when, which +-- the activity trail needs to be readable across the transition, and clearing +-- it leaves a future-compatible NULL state. This migration deliberately adds +-- no transition route or credential attachment flow. If a later handover is +-- designed, `email` and `password_hash` are already nullable and the grants +-- can survive untouched while the product defines who holds the revoke button. +-- +-- No index. The column is read by primary key, on a row the request already +-- loaded, and the one enumeration that scans for marked rows (the reminder +-- fan-out) resolves them from the grant side. + +ALTER TABLE "users" ADD COLUMN "managed_profile_at" TIMESTAMPTZ(3); diff --git a/prisma/migrations/0298_notification_delivery_identity/migration.sql b/prisma/migrations/0298_notification_delivery_identity/migration.sql new file mode 100644 index 000000000..27691f2e4 --- /dev/null +++ b/prisma/migrations/0298_notification_delivery_identity/migration.sql @@ -0,0 +1,121 @@ +-- v1.37.0 — a notification names the health record it concerns and the +-- account whose channels receive it. The former remains the source of health +-- calculations and content; the latter owns delivery preferences and reach. +-- +-- `user_id` remains as a legacy recipient mirror so established self-delivery +-- writers and bounded historical readers stay valid while every row gains the +-- explicit pair. The trigger maps an omitted pair to self-delivery, rejects a +-- partial or inconsistent pair, and refuses a managed profile as recipient. + +ALTER TABLE "push_attempts" + ADD COLUMN "record_user_id" TEXT, + ADD COLUMN "recipient_user_id" TEXT; + +UPDATE "push_attempts" +SET + "record_user_id" = "user_id", + "recipient_user_id" = "user_id"; + +ALTER TABLE "push_attempts" + ALTER COLUMN "record_user_id" SET NOT NULL, + ALTER COLUMN "recipient_user_id" SET NOT NULL; + +ALTER TABLE "push_attempts" + ADD CONSTRAINT "push_attempts_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "push_attempts_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "push_attempts_recipient_user_id_created_at_desc_idx" + ON "push_attempts" ("recipient_user_id", "created_at" DESC); + +CREATE INDEX "push_attempts_record_user_id_created_at_desc_idx" + ON "push_attempts" ("record_user_id", "created_at" DESC); + +CREATE TABLE "notification_events" ( + "id" TEXT NOT NULL, + "record_user_id" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "dedup_key" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "notification_events_pkey" PRIMARY KEY ("id"), + CONSTRAINT "notification_events_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "notification_events_record_user_id_event_type_dedup_key_created_at_desc_idx" + ON "notification_events" ("record_user_id", "event_type", "dedup_key", "created_at" DESC); + +-- Tracking belongs to the account that received the Telegram message. The +-- prior subject-only key collapsed multiple delegated recipients into one row. +ALTER TABLE "telegram_reminder_messages" + ADD COLUMN "recipient_user_id" TEXT; + +UPDATE "telegram_reminder_messages" AS "message" +SET "recipient_user_id" = "medication"."user_id" +FROM "medications" AS "medication" +WHERE "medication"."id" = "message"."medication_id"; + +ALTER TABLE "telegram_reminder_messages" + ALTER COLUMN "recipient_user_id" SET NOT NULL, + ADD CONSTRAINT "telegram_reminder_messages_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +DROP INDEX "telegram_reminder_messages_med_sched_date_phase_tod_key"; + +CREATE UNIQUE INDEX "telegram_reminder_messages_recipient_slot_key" + ON "telegram_reminder_messages" ( + "recipient_user_id", + "medication_id", + "schedule_id", + "date", + "phase", + "time_of_day" + ); + +CREATE FUNCTION enforce_push_attempt_attribution() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.record_user_id IS NULL AND NEW.recipient_user_id IS NULL THEN + NEW.record_user_id := NEW.user_id; + NEW.recipient_user_id := NEW.user_id; + ELSIF NEW.record_user_id IS NULL OR NEW.recipient_user_id IS NULL THEN + RAISE EXCEPTION 'push attempt attribution requires both principals'; + ELSIF NEW.user_id <> NEW.recipient_user_id THEN + RAISE EXCEPTION 'push attempt recipient must match user_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "users" + WHERE "id" = NEW.recipient_user_id + AND "managed_profile_at" IS NOT NULL + ) THEN + RAISE EXCEPTION 'managed profile cannot receive notification delivery'; + END IF; + + IF NEW.record_user_id <> NEW.recipient_user_id + AND NOT EXISTS ( + SELECT 1 + FROM "users" + WHERE "id" = NEW.record_user_id + AND "managed_profile_at" IS NOT NULL + ) THEN + RAISE EXCEPTION 'cross-principal delivery requires a managed record'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER push_attempt_attribution_guard +BEFORE INSERT OR UPDATE ON "push_attempts" +FOR EACH ROW +EXECUTE FUNCTION enforce_push_attempt_attribution(); diff --git a/prisma/migrations/0299_medication_intake_import_actor_attribution/migration.sql b/prisma/migrations/0299_medication_intake_import_actor_attribution/migration.sql new file mode 100644 index 000000000..dcc245481 --- /dev/null +++ b/prisma/migrations/0299_medication_intake_import_actor_attribution/migration.sql @@ -0,0 +1,28 @@ +-- Keep the record subject and the actor distinct for durable intake-import +-- work. Historical rows predate delegated imports, so their actor is the +-- record subject: the only truthful attribution available for those jobs. +ALTER TABLE "medication_intake_import_jobs" + RENAME COLUMN "user_id" TO "record_user_id"; + +ALTER TABLE "medication_intake_import_jobs" + RENAME CONSTRAINT "medication_intake_import_jobs_user_id_fkey" + TO "medication_intake_import_jobs_record_user_id_fkey"; + +ALTER TABLE "medication_intake_import_jobs" + ADD COLUMN "actor_user_id" TEXT; + +UPDATE "medication_intake_import_jobs" + SET "actor_user_id" = "record_user_id" + WHERE "actor_user_id" IS NULL; + +ALTER TABLE "medication_intake_import_jobs" + ALTER COLUMN "actor_user_id" SET NOT NULL; + +-- No actor foreign key: a deleted delegate must remain identifiable in the +-- record's durable job and audit trail, just like audit_logs.actor_user_id. + +DROP INDEX "medication_intake_import_jobs_user_id_created_at_idx"; +CREATE INDEX "medication_intake_import_jobs_record_user_id_created_at_idx" + ON "medication_intake_import_jobs"("record_user_id", "created_at"); +CREATE INDEX "medication_intake_import_jobs_actor_user_id_created_at_idx" + ON "medication_intake_import_jobs"("actor_user_id", "created_at"); diff --git a/prisma/migrations/0300_notification_event_created_at_index/migration.sql b/prisma/migrations/0300_notification_event_created_at_index/migration.sql new file mode 100644 index 000000000..547ccd82b --- /dev/null +++ b/prisma/migrations/0300_notification_event_created_at_index/migration.sql @@ -0,0 +1,5 @@ +-- v1.37.0 — notification event retention deletes globally by created_at. +-- The dedup lookup index starts with record_user_id, so it cannot serve this +-- trailing-edge cleanup predicate efficiently. +CREATE INDEX "notification_events_created_at_idx" + ON "notification_events" ("created_at"); diff --git a/prisma/migrations/0301_notification_egress_authorization/migration.sql b/prisma/migrations/0301_notification_egress_authorization/migration.sql new file mode 100644 index 000000000..fb76cc9c5 --- /dev/null +++ b/prisma/migrations/0301_notification_egress_authorization/migration.sql @@ -0,0 +1,41 @@ +-- v1.37.0 — a managed Guardian notification receives a durable final +-- authorization claim under the profile lifecycle lock before provider I/O. +-- This records only delivery principals and outcome, never health content or +-- channel credentials. Retention follows the notification attempt cleanup. +CREATE TABLE "notification_egress_authorizations" ( + "id" TEXT NOT NULL, + "record_user_id" TEXT NOT NULL, + "recipient_user_id" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "event_type" TEXT NOT NULL, + "authorized_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "outcome" TEXT, + + CONSTRAINT "notification_egress_authorizations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "notification_egress_authorizations_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "notification_egress_authorizations_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "notification_egress_authorizations_authorized_at_idx" + ON "notification_egress_authorizations" ("authorized_at"); + +CREATE INDEX "notification_egress_authorizations_record_user_id_recipient_user_id_authorized_at_idx" + ON "notification_egress_authorizations" ( + "record_user_id", + "recipient_user_id", + "authorized_at" DESC + ); + +-- A recipient can erase their account without owning the underlying record. +-- PostgreSQL needs a recipient-first access path for that FK cascade and for +-- the explicit Delete All Data predicate over recipient_user_id. +CREATE INDEX "notification_egress_auth_recipient_authorized_at_idx" + ON "notification_egress_authorizations" ( + "recipient_user_id", + "authorized_at" DESC + ); diff --git a/prisma/migrations/0302_session_record_epoch/migration.sql b/prisma/migrations/0302_session_record_epoch/migration.sql new file mode 100644 index 000000000..8ea0ffc99 --- /dev/null +++ b/prisma/migrations/0302_session_record_epoch/migration.sql @@ -0,0 +1,73 @@ +-- v1.37.0 — a monotonic counter for "how many times has this session's record +-- selector moved". +-- +-- The problem it solves is not authorization; `acting_as_user_id` plus the +-- grant join already decides that. It is agreement. A browser holds several +-- tabs on one session, and a request that left a suspended tab before a switch +-- landed arrives after it — carrying an intent formed under the old record and +-- addressed to a server that has since moved. The request is perfectly +-- authorized and answers the wrong question. So every request that resolves a +-- record now asserts which context it believes it is in, and the server refuses +-- when that belief no longer matches the row. +-- +-- The counter has to be the DATABASE's, not the application's, because the +-- application is not the only writer of the column it tracks. Three of the +-- five writers run no application code at all: +-- +-- * `sessions_acting_as_user_id_fkey ON DELETE SET NULL` — deleting an owner +-- account nulls every delegate session pointing at it, by referential +-- action, with nothing to hook; +-- * raw `UPDATE sessions SET acting_as_user_id = NULL` in the end-to-end +-- fixtures; +-- * an operator at `psql`, and whatever writes this column next year. +-- +-- An application-level increment would be a discipline those three break +-- silently, and a fence whose counter can silently fail to move is a fence that +-- passes stale requests. A row trigger cannot be forgotten. +-- +-- BEFORE, not AFTER, and that timing is load-bearing: on any statement that +-- moves the selector, the trigger overwrites whatever `record_epoch` the +-- incoming statement supplied, so a writer cannot steer the counter while +-- switching. +-- +-- What that does NOT cover, stated because the first draft of this comment +-- overstated it: a statement that names `record_epoch` and does NOT change +-- `acting_as_user_id` never fires this trigger at all, so it writes the column +-- freely. The trigger owns the counter on the path that matters — every +-- selector move — and what keeps application code away from the column +-- otherwise is the source guard in +-- `src/__tests__/record-session-fence-guard.test.ts`, which holds the set of +-- files naming it to one. +-- +-- `UPDATE OF "acting_as_user_id"` plus the `WHEN` clause keep the two writes +-- that touch a session on nearly every request off the trigger entirely — the +-- throttled `last_active_at` stamp and the sliding `expires_at` extension both +-- name other columns, and neither would satisfy the `IS DISTINCT FROM` test +-- even if it did. +-- +-- Precedent for a trigger on this line: `0298_notification_delivery_identity` +-- ships `push_attempt_attribution_guard` the same way. A trigger is invisible +-- to Prisma's datamodel comparison, which 0298 already proved tolerable. + +ALTER TABLE "sessions" ADD COLUMN "record_epoch" INTEGER NOT NULL DEFAULT 0; + +-- A session already sitting inside somebody else's record is not a session +-- that has never left its own, and the fence's exemption for pre-existing +-- browser tabs is defined as epoch zero. Normalise before the trigger exists, +-- so that from the first request after deploy `record_epoch = 0` means exactly +-- "never pointed at another record, and not pointed at one now". +UPDATE "sessions" SET "record_epoch" = 1 WHERE "acting_as_user_id" IS NOT NULL; + +CREATE OR REPLACE FUNCTION "sessions_bump_record_epoch"() RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW."record_epoch" := OLD."record_epoch" + 1; + RETURN NEW; +END; +$$; + +CREATE TRIGGER "sessions_record_epoch_bump" + BEFORE UPDATE OF "acting_as_user_id" ON "sessions" + FOR EACH ROW + WHEN (OLD."acting_as_user_id" IS DISTINCT FROM NEW."acting_as_user_id") + EXECUTE FUNCTION "sessions_bump_record_epoch"(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78c96f7d7..fa1aeaa28 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -52,6 +52,28 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + /// v1.37.0 — since when this account is a record somebody else runs. + /// + /// A managed profile is a person who cannot run their own record: it has a + /// display name and no credentials, never signs in, and is administered + /// end to end by whoever holds an active MANAGE grant on it. The marker is + /// what separates that case from an ordinary adult who granted management + /// of their own record — the identity and reach surfaces open for the + /// former and stay shut for the latter, and the gate is this column rather + /// than the grant, so no argument about a grant level can reach them. + /// + /// A timestamp rather than a boolean: it says since when, and preserves a + /// future-compatible null state without adding a transition surface here. + /// This release does not expose a way to clear the marker or attach + /// credentials; the grants and activity trail remain intact if a later, + /// separately designed handover ever needs that state. + /// + /// Consumers land in this release: the guardian resolver in + /// `src/lib/api-handler.ts` reads it on every guardian request, and the + /// creation flow is what writes it. A later notification plan may add + /// delivery fanout after it defines consent and preference semantics. + managedProfileAt DateTime? @map("managed_profile_at") + // Profile heightCm Float? @map("height_cm") dateOfBirth DateTime? @map("date_of_birth") @@ -794,7 +816,7 @@ model User { // pg-boss job state so the polling endpoint can read live per- // phase progress without round-tripping through pg-boss internals. importJobs ImportJob[] - medicationIntakeImportJobs MedicationIntakeImportJob[] + medicationIntakeImportJobs MedicationIntakeImportJob[] @relation("MedicationIntakeImportRecord") // v1.5.0 — persistent measurement rollup table. Carries the // count/min/max/mean/sd/slope/r2 stats per (type, granularity, // bucketStart) so the analytics + comprehensive read paths bypass @@ -838,7 +860,12 @@ model User { // 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin // retires rows older than 90 days (see `handlePushAttemptCleanup` in // `reminder-worker.ts`). - pushAttempts PushAttempt[] + pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") + pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") + notificationEvents NotificationEvent[] + notificationEgressAuthorizationsForRecord NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecordUser") + notificationEgressAuthorizationsReceived NotificationEgressAuthorization[] @relation("NotificationEgressAuthorizationRecipientUser") + telegramReminderMessages TelegramReminderMessage[] // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors // the 0–100 map to the user's own training load without re-integrating @@ -1152,6 +1179,24 @@ model Session { /// into its own account. actingAsUserId String? @map("acting_as_user_id") + /// v1.37.0 — how many times this session's record selector has moved. + /// + /// Monotonic, starts at 0, and NEVER written by application code: migration + /// 0302 installs a `BEFORE UPDATE OF acting_as_user_id` trigger that sets it, + /// so the increment also happens on the paths where no application code runs + /// at all — the `ON DELETE SET NULL` referential action above, a raw fixture + /// `UPDATE`, an operator at `psql`. A convention that every writer must + /// remember is a convention those three break silently. + /// + /// Consumer: the record-session fence + /// (`src/lib/sharing/record-session-fence.ts`), which refuses a cookie + /// request whose asserted epoch no longer matches the row. `0` additionally + /// means "this session has never been pointed at another record", which is + /// the fence's exemption for browser tabs that predate the feature; the + /// migration normalises existing rows so that invariant holds from the first + /// request after deploy. + recordEpoch Int @default(0) @map("record_epoch") + user User @relation("SessionOwner", fields: [userId], references: [id], onDelete: Cascade) actingAs User? @relation("SessionActingAs", fields: [actingAsUserId], references: [id], onDelete: SetNull) @@ -3340,24 +3385,26 @@ model ReminderPhaseConfig { } model TelegramReminderMessage { - id String @id @default(cuid()) - medicationId String @map("medication_id") - scheduleId String @map("schedule_id") - chatId String @map("chat_id") - messageId Int @map("message_id") - phase ReminderPhase - sentAt DateTime @default(now()) @map("sent_at") - date String // YYYY-MM-DD for day grouping + id String @id @default(cuid()) + recipientUserId String @map("recipient_user_id") + medicationId String @map("medication_id") + scheduleId String @map("schedule_id") + chatId String @map("chat_id") + messageId Int @map("message_id") + phase ReminderPhase + sentAt DateTime @default(now()) @map("sent_at") + date String // YYYY-MM-DD for day grouping /// v1.7.0 — time-of-day this reminder belongs to (HH:mm, user-local). /// Empty string = the single legacy window (pre-v1.7 rows + single-time /// schedules). Part of the dedup key so a multi-time schedule dispatches /// once per time-of-day per phase per day instead of once per day. - timeOfDay String @default("") @map("time_of_day") + timeOfDay String @default("") @map("time_of_day") - medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) - schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) + recipientUser User @relation(fields: [recipientUserId], references: [id], onDelete: Cascade) + medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + schedule MedicationSchedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade) - @@unique([medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_med_sched_date_phase_tod_key") + @@unique([recipientUserId, medicationId, scheduleId, date, phase, timeOfDay], map: "telegram_reminder_messages_recipient_slot_key") @@index([medicationId, date]) @@map("telegram_reminder_messages") } @@ -4693,7 +4740,8 @@ model ImportJob { // stays in Postgres so retries never depend on request-process memory. model MedicationIntakeImportJob { id String @id @default(cuid()) - userId String @map("user_id") + recordUserId String @map("record_user_id") + actorUserId String @map("actor_user_id") /// v1.33.0 (migration 0278) — nullable. The per-medication import route /// scopes its job to one medication and names it here. A dose history /// exported from another tracker covers a whole regimen in one file — the @@ -4714,10 +4762,11 @@ model MedicationIntakeImportJob { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + recordUser User @relation("MedicationIntakeImportRecord", fields: [recordUserId], references: [id], onDelete: Cascade) medication Medication? @relation(fields: [medicationId], references: [id], onDelete: Cascade) - @@index([userId, createdAt]) + @@index([recordUserId, createdAt]) + @@index([actorUserId, createdAt]) @@index([medicationId, createdAt]) @@index([status, heartbeatAt]) @@map("medication_intake_import_jobs") @@ -5779,6 +5828,50 @@ model ConsentReceipt { // `channel` is the dispatcher `ChannelType` string (`APNS`, // `WEB_PUSH`, `TELEGRAM`, `NTFY`). // +// Record-scoped notification events are durable event anchors rather than +// channel delivery records. They intentionally name no recipient, which keeps +// scheduler deduplication independent from future Guardian fan-out. +model NotificationEvent { + id String @id @default(cuid()) + recordUserId String @map("record_user_id") + eventType String @map("event_type") + dedupKey String @map("dedup_key") + createdAt DateTime @default(now()) @map("created_at") + + recordUser User @relation(fields: [recordUserId], references: [id], onDelete: Cascade) + + @@index([recordUserId, eventType, dedupKey, createdAt(sort: Desc)]) + @@index([createdAt], map: "notification_events_created_at_idx") + @@map("notification_events") +} + +// A managed Guardian channel send crosses a revocable authorization boundary. +// The dispatcher creates this claim while it holds the managed-profile +// lifecycle lock, then commits before starting provider I/O. That committed +// claim is the linearization point: a revocation that commits first prevents +// the send, while a revocation that commits later does not cancel the one +// already-authorized provider operation. No notification content, channel +// credentials, or event deduplication keys are retained here. Completion is +// observational only; authorization and retry decisions never read it. +model NotificationEgressAuthorization { + id String @id @default(cuid()) + recordUserId String @map("record_user_id") + recipientUserId String @map("recipient_user_id") + channel String + eventType String @map("event_type") + authorizedAt DateTime @default(now()) @map("authorized_at") + completedAt DateTime? @map("completed_at") + outcome String? + + recordUser User @relation("NotificationEgressAuthorizationRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade) + recipientUser User @relation("NotificationEgressAuthorizationRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade) + + @@index([authorizedAt], map: "notification_egress_authorizations_authorized_at_idx") + @@index([recordUserId, recipientUserId, authorizedAt(sort: Desc)]) + @@index([recipientUserId, authorizedAt(sort: Desc)], map: "notification_egress_auth_recipient_authorized_at_idx") + @@map("notification_egress_authorizations") +} + // `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped` // covers the "no recipient" / "config missing" soft cases. // @@ -5788,17 +5881,38 @@ model ConsentReceipt { // classified `web_push_*` reason; Telegram / NTFY likewise mirror // their sender-side `SendOutcome.reason`. model PushAttempt { - id String @id @default(cuid()) - userId String @map("user_id") - channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" - eventType String @map("event_type") // dispatcher event-type string - result String // "ok" | "error" | "skipped" - reason String? // sender-side reason on error/skip; null on ok - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(cuid()) + /// Legacy recipient mirror. Migration 0298 preserves established writers + /// while the database trigger requires it to equal `recipientUserId`. + userId String @map("user_id") + /// The health-record subject. This supplies calculation context and content. + /// + /// Optional HERE and `NOT NULL` in the database, deliberately, and the two do + /// not disagree: `push_attempt_attribution_guard` is a BEFORE INSERT trigger, + /// so a write that omits both principals has them filled from `userId` before + /// the constraint is checked. Declaring them required would force every + /// established self-delivery writer to name a pair it has no opinion about; + /// making the columns nullable in SQL would let a partial pair reach the + /// table, which the trigger exists to refuse. `prisma migrate diff` reports + /// the difference because it cannot see the trigger — that report is expected, + /// and `notification-attribution.test.ts` ("persistence maps an omitted pair + /// from the legacy recipient") is what proves the accommodation still works. + recordUserId String? @map("record_user_id") + /// The account whose channel, preferences, and locale receive the delivery. + /// Nullable for the same reason as `recordUserId` above. + recipientUserId String? @map("recipient_user_id") + channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" + eventType String @map("event_type") // dispatcher event-type string + result String // "ok" | "error" | "skipped" + reason String? // sender-side reason on error/skip; null on ok + createdAt DateTime @default(now()) @map("created_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + recordUser User? @relation("PushAttemptRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade) + recipientUser User? @relation("PushAttemptRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) + @@index([recordUserId, createdAt(sort: Desc)]) + @@index([recipientUserId, createdAt(sort: Desc)]) @@map("push_attempts") } @@ -5904,6 +6018,14 @@ model ClinicianShareLinkDocument { enum AccountGrantAccess { READ WRITE + /// v1.37.0 — full management of the record's data: everything WRITE reaches, + /// plus the edit, delete and restore verbs beside it, plus the record-wide + /// derived surfaces. Never the identity and reach surfaces — credentials, + /// channels, tokens, integrations, grant management, AI configuration — + /// which stay unreachable at every invited level, because a manager who + /// could mint a credential would survive their own revocation. MANAGE is + /// whole-record by construction: `scopeJson` must be NULL on a MANAGE row. + MANAGE @@map("account_grant_access") } @@ -5970,6 +6092,24 @@ model AccountGrant { access AccountGrantAccess @default(READ) + /// v1.37.0 — which sections of the record this grant opens. + /// + /// NULL means the entire record, which is exactly what every row written + /// before this column existed was consented as — so there is no backfill and + /// nobody is asked to consent again. A non-NULL value is a non-empty array + /// of `ShareDomain` keys (`src/lib/sharing/scope.ts`). + /// + /// Read fail-closed through `normaliseScope` in `src/lib/sharing/grants.ts` + /// and nowhere else: a malformed non-NULL blob resolves to the empty set and + /// refuses every section, the opposite direction from the module preference + /// normaliser, because this column answers an authorization question and + /// that one answers a presentation question. + /// + /// Always NULL on a MANAGE row. Management is whole-record: "they can do + /// anything, but only to part of you" promises a boundary that cannot be + /// kept once edits are in play. + scopeJson Json? @map("scope_json") + /// When the invitation was offered. A domain event, so re-offering an /// invitation on the same pending row moves it; `createdAt` below is row /// bookkeeping and never moves. They agree the day the row is written and @@ -5986,7 +6126,10 @@ model AccountGrant { revokedBy AccountGrantRevoker? @map("revoked_by") /// Optional lapse date. Checked live on every resolution, so an expired - /// grant stops conferring access without anything having to sweep it. + /// grant stops conferring access without anything having to sweep it. A + /// managed-profile invitation may carry an expiry while awaiting consent; + /// accepting its MANAGE grant clears this field because an active Guardian + /// ends only through an explicit lifecycle transition. expiresAt DateTime? @map("expires_at") /// Refreshed fire-and-forget when the grant is exercised, the same posture /// as `ApiToken.lastUsedAt`. The owner's panel reads it to answer "is this diff --git a/public/sw.js b/public/sw.js index 28e6f1ca5..d2632934b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.36.1"; + /* @sw-version-fallback */ "v1.37.0"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/scripts/env-manifest.json b/scripts/env-manifest.json index 447d71f49..4613b0c9d 100644 --- a/scripts/env-manifest.json +++ b/scripts/env-manifest.json @@ -241,6 +241,23 @@ "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." } ] + }, + { + "name": "Operational windows and cookie flags", + "description": "Values the product states back to a user or that decide whether a session cookie is accepted. Optional: each has a documented default. They are listed so `pnpm check-env` reports them, and so the compose whitelist and the example files can be held in step with this manifest.", + "required": false, + "variables": [ + { + "name": "AUDIT_LOG_RETENTION_DAYS", + "purpose": "How long audit rows survive, in days (365 by default). Published, not merely enforced: the shared-access activity feed and the dialog that ends somebody's access both state the number back to the user.", + "example": "365" + }, + { + "name": "SESSION_COOKIE_SECURE", + "purpose": "Forces the Secure flag on every cookie the app issues. Unset, it tracks NODE_ENV. Set false for a LAN / VPN-only self-host served over plain HTTP, where the default makes login look broken.", + "example": "false" + } + ] } ] } diff --git a/scripts/verify-v137-adversarial.ts b/scripts/verify-v137-adversarial.ts new file mode 100644 index 000000000..fab06f39c --- /dev/null +++ b/scripts/verify-v137-adversarial.ts @@ -0,0 +1,305 @@ +/** + * v1.37.0 integration verifier — the inventories, and whether any of them is empty. + * + * ## What this is for + * + * The release is proved by inventories: a migration allocation, a set of + * admitted mutating handlers, a notification delivery matrix, a set of security + * principals, five accessibility states and eight journeys. Every one of them is + * consumed by a test that iterates it, and every one of those tests passes + * trivially when the list it iterates is empty. That is the failure this script + * exists to catch, and it is not hypothetical — three separate checks in this + * repository have been found green because their matcher matched nothing. + * + * So the rule here is that a count is asserted before it is used, and asserted + * against a number written down rather than against "more than zero" alone. + * A list that lost half its entries is not caught by a non-empty check. + * + * ## What this is NOT + * + * It does not run the suites and it cannot tell whether the tests that consume + * these inventories assert anything worth asserting. It answers one question — + * "is there something in each of these, and is it the amount there is supposed + * to be" — and leaves the rest to the tests themselves. + * + * ## Usage + * + * pnpm dlx tsx scripts/verify-v137-adversarial.ts # everything + * pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-inventories # counts only + * pnpm dlx tsx scripts/verify-v137-adversarial.ts --check-migrations # allocation only + * + * Exits non-zero on the first failing assertion, and prints every check it ran + * so a run that checked nothing is visible as a run that printed nothing. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + ACCESSIBILITY_STATES, + SHARING_ACCESSIBILITY_STATES, + V137_E2E_JOURNEYS, +} from "../tests/fixtures/v137/e2e-journeys"; +import { NOTIFICATION_DELIVERY_MATRIX } from "../tests/fixtures/v137/notification-matrix"; +import { SECURITY_PRINCIPALS } from "../tests/fixtures/v137/security-principals"; +import { ADMITTED_MUTATING_HANDLERS } from "../tests/fixtures/v137/sharing-matrix"; + +const ROOT = process.cwd(); + +let failures = 0; +let checks = 0; + +function check(label: string, ok: boolean, detail: string): void { + checks += 1; + if (ok) { + console.log(` ok ${label} — ${detail}`); + return; + } + console.error(` FAIL ${label} — ${detail}`); + failures += 1; +} + +function expectCount(label: string, actual: number, expected: number): void { + check(label, actual === expected, `${actual} (expected ${expected})`); +} + +/* -------------------------------------------------------------------------- */ +/* Migration allocation */ +/* -------------------------------------------------------------------------- */ + +/** + * The release's allocation, corrected. The synthesis said 0296-0299 and called + * the range frozen; the release shipped seven. The list is here so a builder + * inventing an eighth number, or a merge quietly renumbering one of these, + * fails rather than being noticed at the release audit or not at all. + */ +const MIGRATION_ALLOCATION = [ + "0296_account_grant_scope_and_manage", + "0297_managed_profile", + "0298_notification_delivery_identity", + "0299_medication_intake_import_actor_attribution", + "0300_notification_event_created_at_index", + "0301_notification_egress_authorization", + "0302_session_record_epoch", +] as const; + +/** + * Triggers this release installs. Neither is modelled in the Prisma schema, so + * `migrate diff` has nothing to compare them against and cannot report one + * going missing — the drift check is structurally blind here. Their presence in + * the migration text is the cheapest honest signal; the behaviour is proved by + * the integration suites named beside each one. + */ +const MIGRATION_TRIGGERS: ReadonlyArray<{ + trigger: string; + migration: string; + provenBy: string; +}> = [ + { + trigger: "push_attempt_attribution_guard", + migration: "0298_notification_delivery_identity", + provenBy: "tests/integration/notification-attribution.test.ts", + }, + { + trigger: "sessions_record_epoch_bump", + migration: "0302_session_record_epoch", + provenBy: "tests/integration/record-session-epoch-trigger.test.ts", + }, +]; + +function checkMigrations(): void { + console.log("migration allocation"); + const dir = join(ROOT, "prisma/migrations"); + const present = readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + + check( + "the migrations directory really loaded", + present.length > 100, + `${present.length} migrations on disk`, + ); + + const release = present.filter((name) => /^(029[6-9]|030[0-2])_/.test(name)); + expectCount( + "the release allocates exactly seven", + release.length, + MIGRATION_ALLOCATION.length, + ); + + for (const expected of MIGRATION_ALLOCATION) { + check( + `allocated ${expected.slice(0, 4)}`, + present.includes(expected), + expected, + ); + } + + // Every number used once. A collision is the thing renumbering exists to + // prevent, and two directories sharing a prefix apply in an order nobody + // chose. + const releaseNumbers = release.map((name) => name.slice(0, 4)); + const duplicated = releaseNumbers.filter( + (n, i) => releaseNumbers.indexOf(n) !== i, + ); + check( + "no number is allocated twice in the release range", + duplicated.length === 0, + duplicated.length === 0 ? "none" : duplicated.join(", "), + ); + + for (const { trigger, migration, provenBy } of MIGRATION_TRIGGERS) { + const sql = readFileSync(join(dir, migration, "migration.sql"), "utf8"); + check( + `${migration} installs ${trigger}`, + sql.includes(trigger), + `behaviour proved by ${provenBy}`, + ); + } +} + +/* -------------------------------------------------------------------------- */ +/* Inventories */ +/* -------------------------------------------------------------------------- */ + +function checkInventories(): void { + console.log("inventories"); + + // Handlers. Every admitted mutation is meant to run level, owner, actor, + // effect and encrypted-field cases; an empty list runs none of them and the + // matrix test reports the same green either way. + check( + "the admitted mutating handlers are non-empty", + ADMITTED_MUTATING_HANDLERS.length > 0, + `${ADMITTED_MUTATING_HANDLERS.length} handlers`, + ); + // Keyed on route AND action, because one route legitimately appears more + // than once: an `[id]` route carries an update and a delete, and they are + // different contracts with different admitted levels. Keying on the route + // alone reported eleven false collisions on the first run of this script. + const handlerKeys = new Set( + ADMITTED_MUTATING_HANDLERS.map((h) => `${h.action} ${h.route}`), + ); + expectCount( + "no route and action pair is admitted twice", + handlerKeys.size, + ADMITTED_MUTATING_HANDLERS.length, + ); + check( + "every handler names a module that exists", + ADMITTED_MUTATING_HANDLERS.every((h) => { + try { + return ( + readFileSync(join(ROOT, "src", h.handlerModule), "utf8").length > 0 + ); + } catch { + return false; + } + }), + "each handlerModule resolves under src/", + ); + + // The notification matrix is two records by two Guardians by four channels. + // Written as a product, so a dropped channel shows up here as sixteen minus + // four rather than as a suite that simply ran fewer cases. + expectCount( + "the notification delivery matrix is 2 records x 2 Guardians x 4 channels", + NOTIFICATION_DELIVERY_MATRIX.length, + 16, + ); + check( + "no delivery in the matrix is interactive", + NOTIFICATION_DELIVERY_MATRIX.every((row) => row.interactive === false), + "a Guardian's notification offers no action buttons", + ); + + // Security principals. Each names an actor, a record and a recipient; a + // principal missing one of the three would let a test assert against + // undefined and pass. + const principals = Object.entries(SECURITY_PRINCIPALS); + check( + "the security principals are non-empty", + principals.length > 0, + `${principals.length} principals`, + ); + for (const [name, principal] of principals) { + const { actorUserId, recordUserId, recipientUserId } = principal.identities; + check( + `${name} names all three identities`, + Boolean(actorUserId && recordUserId && recipientUserId), + `${actorUserId} / ${recordUserId} / ${recipientUserId}`, + ); + } + + // Accessibility states and journeys. Both are enumerations the browser suite + // is read back against, and both are exact rather than non-empty: a ninth + // journey nobody counted and a fifth state quietly absorbed into a fourth are + // the two failures the enumeration exists to make visible. + expectCount( + "exactly five accessibility states", + ACCESSIBILITY_STATES.length, + 5, + ); + expectCount( + "every state carries a case", + SHARING_ACCESSIBILITY_STATES.length, + 5, + ); + expectCount("exactly eight journeys", V137_E2E_JOURNEYS.length, 8); + expectCount( + "every journey id is distinct", + new Set(V137_E2E_JOURNEYS.map((j) => j.id)).size, + 8, + ); + + // Each journey id must appear in a test title somewhere under e2e/ or tests/ + // or src/__tests__/. `sharing-accessibility-states.test.tsx` pins WHICH file + // carries each one; this leg is the cheaper cross-check that none of the + // eight has become a claim with no test anywhere at all. + const searched = [ + ...readdirSync(join(ROOT, "e2e")).map((f) => join("e2e", f)), + ...readdirSync(join(ROOT, "tests/integration")).map((f) => + join("tests/integration", f), + ), + ...readdirSync(join(ROOT, "src/__tests__")).map((f) => + join("src/__tests__", f), + ), + ].filter((f) => f.endsWith(".ts") || f.endsWith(".tsx")); + check( + "the test tree really loaded", + searched.length > 50, + `${searched.length} files scanned`, + ); + const corpus = searched + .map((f) => readFileSync(join(ROOT, f), "utf8")) + .join("\n"); + for (const journey of V137_E2E_JOURNEYS) { + check( + `${journey.id} is marked in a test title`, + corpus.includes(`[${journey.id}]`), + journey.claim.slice(0, 60) + "…", + ); + } +} + +/* -------------------------------------------------------------------------- */ + +const argv = process.argv.slice(2); +const only = argv.filter((a) => a.startsWith("--")); +const runMigrations = only.length === 0 || only.includes("--check-migrations"); +const runInventories = + only.length === 0 || only.includes("--check-inventories"); + +if (runMigrations) checkMigrations(); +if (runInventories) checkInventories(); + +// A run that asserted nothing is a run that proves nothing, and it would exit 0. +if (checks === 0) { + console.error("no checks ran — refusing to report success"); + process.exit(1); +} + +console.log( + `\n${checks} check(s), ${failures} failure(s)` + + (failures === 0 ? " — inventories are non-empty and exact" : ""), +); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index e4c2fda78..13d7f7e98 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -80,6 +80,19 @@ const NOT_A_CONSUMER = new Set([ join(SRC, "app", "api", "auth", "me", "route.ts"), ]); +const ACCOUNT_ENTRY_CONSUMER = join( + SRC, + "components", + "dashboard", + "looking-after-card.tsx", +); +const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts"); +const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [ + "level", + "sections", + "recordKind", +] as const; + /** * Fields with no client reader, each with the reason it is nonetheless * correct for the payload to carry it. An entry here is a claim; write one @@ -192,4 +205,48 @@ describe("account payload consumer guard", () => { expect(reason.length).toBeGreaterThan(20); } }); + + it("has a consumer that ADOPTS the record-session context, not only a type for it", () => { + // `recordSession` is the field the fence hands the browser, and the whole + // point of it is that a client adopts the server's value rather than + // deriving one. The generic sweep above is satisfied by any read anywhere; + // this leg names the two places that have to do the adopting, because a + // payload field read only by the transport type is the shape the file's + // opening paragraph is about. + expect(fields).toContain("recordSession"); + + const adopters = [ + join(SRC, "hooks", "use-account-switch.ts"), + join(SRC, "hooks", "use-record-capabilities.ts"), + ]; + let reads = 0; + for (const file of adopters) { + expect(stripComments(readFileSync(file, "utf8"))).toMatch( + /recordSession/, + ); + reads += 1; + } + expect(reads).toBe(adopters.length); + expect(reads).toBeGreaterThan(0); + }); + + it("has non-zero entry and active-record readers for additive access fields", () => { + const entrySource = stripComments( + readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"), + ); + const activeSource = stripComments( + readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"), + ); + + const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce( + (count, field) => { + expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`)); + expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`)); + return count + 2; + }, + 0, + ); + + expect(readerCount).toBeGreaterThan(0); + }); }); diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index bf83b9a2b..ad7666d33 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -94,6 +94,20 @@ describe("the acting-account carrier is read in one place", () => { // whole auth context for exactly this reason: had it taken the id, the // route would name the column too, and the list would grow per surface. "lib/sharing/account-access.ts", + // v1.37.0 — the record-session fence COMPARES it and never writes it. It + // reads the value off the `AuthContext` the resolver already holds rather + // than from the row, which is the property the fence rests on: the scope + // the handler serves under and the epoch the fence validated are one row + // read, so they cannot straddle a switch. A version of this file that + // resolved its own session would be a second answer to "whose record is + // this" and would also be wrong more often. + "lib/sharing/record-session-fence.ts", + // v1.37.0 — names the column in prose only. The matcher does not exempt + // comments and is left that way on purpose (see the note above), so the + // module that explains the fence has to be listed. It imports nothing at + // all, which the fence's own unit test asserts, so it can read no column + // even in principle. + "lib/sharing/record-session-fence-contract.ts", ].sort(); it("no other file reads or writes the carrier column", () => { @@ -125,7 +139,7 @@ describe("the acting-account carrier is read in one place", () => { * Who may ask "which record does this request claim" without an auth * context in hand. * - * `readClaimedActingAccount()` answers before any grant check has run, so a + * `readClaimedRecordContext()` answers before any grant check has run, so a * caller that treats its answer as permission has skipped the only thing * that grants any. Exactly one caller exists, beside the file that declares * it: the idempotency wrapper, which uses the claim to pick a cache cell and @@ -133,9 +147,17 @@ describe("the acting-account carrier is read in one place", () => { * acting account has an auth context by then and goes through * `requireRecordAuth`, which checks a live grant before returning. A second * caller here is a design question, not a patch. + * + * The matcher names the v1.36.0 spelling (`readClaimedActingAccount`) as well + * as today's. That function was removed in v1.37.0 when the wrapper started + * needing the session's record context on the same read; matching both means + * a reintroduced narrow copy trips this leg instead of quietly becoming a + * second answer. */ it("the unchecked claim has exactly two consumers", () => { - const consumers = filesMatching(/readClaimedActingAccount/); + const consumers = filesMatching( + /readClaimedRecordContext|readClaimedActingAccount/, + ); expect(consumers.length).toBeGreaterThan(0); expect(consumers).toEqual([ "lib/auth/acting-carrier.ts", @@ -168,16 +190,25 @@ describe("the cookie-only helpers cannot see the acting account", () => { expect(body).not.toContain("readActingCarrier"); }); - it("substitutes a data-scope user in exactly one function", () => { + it("substitutes a data-scope user in exactly two functions", () => { const src = read("lib/api-handler.ts"); const record = functionBody(src, "requireRecordAuth"); expect(record).toContain("readActingCarrier"); + // v1.37.0 — the guardian resolver is the second function that acts on the + // carrier, and it is a second declaration rather than a level on the + // first: it admits the surfaces of a record NOBODY runs themselves, gated + // on the managed-profile marker rather than on the grant. + const guardian = functionBody(src, "requireGuardianAuth"); + expect(guardian.length).toBeGreaterThan(0); + expect(guardian).toContain("readActingCarrier"); + expect(guardian).toContain("managedProfileAt"); + // `readActingCarrier` is what turns a request into "acting as somebody - // else". Two callers: the bare path, which refuses on it, and the record - // path, which acts on it. A third would be a mode nobody declared. + // else". Three callers: the bare path, which refuses on it, and the two + // record paths, which act on it. A fourth would be a mode nobody declared. const callers = (src.match(/await readActingCarrier\(/g) ?? []).length; - expect(callers).toBe(2); + expect(callers).toBe(3); }); }); diff --git a/src/__tests__/backup-plan-classification.test.ts b/src/__tests__/backup-plan-classification.test.ts index f02ba8651..00811393c 100644 --- a/src/__tests__/backup-plan-classification.test.ts +++ b/src/__tests__/backup-plan-classification.test.ts @@ -146,6 +146,15 @@ describe("every schema model has a backup verdict", () => { "a one-word reason is how a decision stops being reviewable", ).toEqual([]); }); + + it("excludes Guardian egress claims rather than exporting delivery metadata", () => { + expect(backupVerdict("NotificationEgressAuthorization")).toBe( + "NOT_IN_BACKUP", + ); + expect(NOT_IN_BACKUP_MODELS.NotificationEgressAuthorization).toMatch( + /no notification content, credentials, or deduplication key/i, + ); + }); }); /** diff --git a/src/__tests__/data-wipe-completeness.test.ts b/src/__tests__/data-wipe-completeness.test.ts index 55451e619..745992427 100644 --- a/src/__tests__/data-wipe-completeness.test.ts +++ b/src/__tests__/data-wipe-completeness.test.ts @@ -341,4 +341,10 @@ describe("data-wipe completeness", () => { }); expect(wipeWhere("Measurement", "u1")).toEqual({ userId: "u1" }); }); + + it("selects both record and recipient ownership of a Guardian egress claim", () => { + expect(wipeWhere("NotificationEgressAuthorization", "u1")).toEqual({ + OR: [{ recordUserId: "u1" }, { recipientUserId: "u1" }], + }); + }); }); diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts deleted file mode 100644 index 4a08f469e..000000000 --- a/src/__tests__/delegable-surface-guard.test.ts +++ /dev/null @@ -1,1182 +0,0 @@ -/** - * Structural guards on the delegable and actor-surface allowlists. - * - * `requireRecordAuth` is a declaration: this route may act on somebody else's - * health record. `requireActorAuth` is the opposite declaration: this route - * serves the caller's own account and keeps working while the caller is acting - * as someone else. Which routes may say either thing is the single most - * security-sensitive list in the product, and it is a judgement — argued in - * `.planning/2026-08-01-delegable-classification.md`, not derived from anything - * a machine can check. - * - * That record is an argument on paper. This file is what makes it hold: the set - * of route modules that actually reach each resolver has to equal a literal - * written here, so every admission arrives as a diff a human reviewed rather - * than as an import somebody added on a Friday. - * - * These are tripwires, not proofs. They cannot show a list is correct — only - * that it has not changed without someone editing this file. A reviewer who - * waves through a bad addition defeats every leg below, and no test substitutes - * for that review. - * - * Two properties this file is built around, both learned the hard way: - * - * 1. **Every leg asserts its own matcher found something.** A matcher that - * matches nothing reports success, and this repository has shipped several - * of those — a resolver hidden behind a newline, a regex a variable slipped - * past, an absence asserted in a fixture that never held the thing. Legs - * (c) and (d) still expect an EMPTY offender set, so for them "found - * nothing" and "the list is correct" look identical from the outside. - * Each leg therefore pins a separate, non-empty count: the number of - * route modules SCANNED, or the number of files naming a symbol the leg - * depends on. Those counts are the evidence; the empty result is only the - * verdict. The delegable list itself was empty when this file was written - * and is not any more, which removes the trap from legs (a) and (b) but - * not from the two below them. - * - * 2. **AST decides, text only skips.** Membership is decided by parsing the - * module and reading its identifiers, so a comment explaining why a route - * is NOT delegable cannot enrol it, and an aliased import cannot hide one. - * A raw-substring test runs first, but only to skip files that cannot - * possibly match — the source text is a strict superset of the identifier - * set. `describe("the matchers see what they look for")` proves both - * halves of that sentence against synthetic modules, so the day a matcher - * stops matching is the day this file goes red rather than quiet. - * - * `acting-account-boundary-guard.test.ts` is the other half of this boundary - * and stays as it is. It freezes who may touch the CARRIER — the session column - * and the selector header — with a text matcher that deliberately does not - * exempt comments, so a file merely naming the column in prose trips it. That - * is argued at its own call site and it is the right direction to be wrong in - * for an authorisation carrier. This file asks a different question of a - * different set of symbols and answers it from the AST, because a route - * explaining in a comment why it is NOT delegable is a thing worth writing. - */ -import { readFileSync, readdirSync } from "node:fs"; -import { join, sep } from "node:path"; - -import ts from "typescript"; -import { describe, expect, it } from "vitest"; - -const SRC = join(process.cwd(), "src"); - -/** The delegable declaration. Frozen by `DELEGABLE_ROUTES`. */ -const RECORD_RESOLVER = "requireRecordAuth"; -/** The actor-surface declaration. Frozen by `ACTOR_ROUTES`. */ -const ACTOR_RESOLVER = "requireActorAuth"; - -/** - * The helpers that answer a question a delegate must never be able to reach: - * "is this caller an admin", "has this caller just re-proven a second factor", - * "is this caller here over a cookie". All three resolve through `getSession()` - * and see the actor, never the owner — that is what - * `acting-account-boundary-guard.test.ts` pins inside `api-handler.ts`. - * - * This file pins the other end. A handler that resolves a RECORD and then runs - * one of these has put a role check and a substituted data scope in the same - * module, and the next person to edit it has no way to tell which `user` they - * are holding. `requireFreshMfaIfEnrolled` and `requireMfaManagementAuth` are - * not listed separately: both are thin wrappers that call `requireFreshMfa` or - * `requireCookieAuth`, so a module using either names one of these three too. - */ -const COOKIE_ONLY_HELPERS = [ - "requireAdmin", - "requireFreshMfa", - "requireCookieAuth", -] as const; - -/** - * Floors, not counts. The tree today holds 447 route modules (440 of them under - * `app/api/`) and 2,338 source files, of which 346 route modules resolve auth - * the ordinary way. These numbers exist so that a scan which collapses to a - * handful of files — a changed enumeration, a moved `src`, a `cwd` that is not - * the repo root — fails instead of agreeing with an empty allowlist. They sit - * far below today's figures so ordinary churn never touches them. - */ -const ROUTE_MODULE_FLOOR = 300; -const SOURCE_FILE_FLOOR = 1500; -const BARE_REQUIRE_AUTH_FLOOR = 250; - -/* -------------------------------------------------------------------------- */ -/* The file set */ -/* -------------------------------------------------------------------------- */ - -/** - * Every path under `src/`, as a `/`-joined relative string. - * - * A recursive `readdirSync` rather than a glob, and the difference is not - * stylistic. `fs.globSync` skips dot-prefixed directories, so `app/**` silently - * omits the three routes under `src/app/.well-known/` — three request handlers - * that a glob-based sweep reports as if they did not exist. Leg (a) pins one of - * them by name so this cannot quietly regress. Names only; nothing under - * `generated/` is ever read (CLAUDE.md). - */ -function allPaths(): string[] { - return readdirSync(SRC, { recursive: true }) - .map((p) => String(p).split(sep).join("/")) - .sort(); -} - -function nonTest(paths: string[]): string[] { - return paths - .filter((p) => !p.startsWith("generated/")) - .filter((p) => !p.includes("__tests__")) - .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); -} - -/** - * Every request-handling module in the tree. `app/**` rather than `app/api/**` - * on purpose: `app/mcp/route.ts` and the clinician share routes handle requests - * too, and a resolver reached from one of those is exactly as interesting as - * one reached from under `api/`. - */ -function routeModules(): string[] { - return nonTest( - allPaths().filter((p) => p.startsWith("app/") && p.endsWith("/route.ts")), - ); -} - -/** Every non-test source file under `src/`, generated client excluded. */ -function sourceFiles(): string[] { - return nonTest( - allPaths().filter((p) => p.endsWith(".ts") || p.endsWith(".tsx")), - ); -} - -const textCache = new Map(); - -function read(rel: string): string { - const cached = textCache.get(rel); - if (cached !== undefined) return cached; - const text = readFileSync(join(SRC, rel), "utf8"); - textCache.set(rel, text); - return text; -} - -/* -------------------------------------------------------------------------- */ -/* The matcher */ -/* -------------------------------------------------------------------------- */ - -/** - * Every identifier in a module's parsed source. - * - * Identifiers only, which means: imports (including the original name of an - * aliased one), calls, declarations, type references. Not comments, not string - * literals. A route file may say in prose why it is not delegable without being - * enrolled by saying so — the failure mode of a text matcher, and the reason - * the plan asked for AST here. - */ -function identifiersIn(source: string, fileName: string): Set { - const parsed = ts.createSourceFile( - fileName, - source, - ts.ScriptTarget.Latest, - true, - fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, - ); - const found = new Set(); - const walk = (node: ts.Node): void => { - if (ts.isIdentifier(node)) found.add(node.text); - node.forEachChild(walk); - }; - parsed.forEachChild(walk); - return found; -} - -const identifierCache = new Map>(); - -function identifiers(rel: string): Set { - const cached = identifierCache.get(rel); - if (cached !== undefined) return cached; - const found = identifiersIn(read(rel), rel); - identifierCache.set(rel, found); - return found; -} - -/** - * Does this module name `symbol` in code? - * - * The substring test runs first and decides nothing: it only skips files whose - * raw text cannot contain the identifier, because the text is a strict superset - * of the identifier set. Every candidate that survives it is decided by the - * AST. Both halves are proved below against synthetic modules. - */ -function namesSymbol(rel: string, symbol: string): boolean { - if (!read(rel).includes(symbol)) return false; - return identifiers(rel).has(symbol); -} - -function modulesNaming(files: readonly string[], symbol: string): string[] { - return files.filter((rel) => namesSymbol(rel, symbol)); -} - -/* -------------------------------------------------------------------------- */ -/* The call matcher: which symbol is called, and with what */ -/* -------------------------------------------------------------------------- */ - -/** - * One call site, with its callee resolved back to the ORIGINAL exported name - * and its first argument, when that argument is a plain string literal. - * - * The membership matcher above answers "does this module name the symbol", - * which is the right question for an allowlist and the wrong one for the write - * leg: `requireRecordAuth("read")` and `requireRecordAuth("write")` name the - * same identifier and mean opposite things. So this walks call expressions and - * resolves the callee through the module's own import table — an aliased - * import (`requireRecordAuth as resolveRecord`) and a namespace-qualified call - * (`auth.requireRecordAuth`) both come back as `requireRecordAuth`, which is - * the property that makes "the write set equals this literal" hold against a - * module that renames its import. - */ -interface CallSite { - /** The original exported name, after alias resolution. */ - name: string; - /** The first argument when it is a string literal; null otherwise. */ - firstArg: string | null; -} - -function callSitesIn(source: string, fileName: string): CallSite[] { - const parsed = ts.createSourceFile( - fileName, - source, - ts.ScriptTarget.Latest, - true, - fileName.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, - ); - - // local name -> original exported name, and the set of namespace imports. - const aliases = new Map(); - const namespaces = new Set(); - parsed.forEachChild((node) => { - if (!ts.isImportDeclaration(node)) return; - const bindings = node.importClause?.namedBindings; - if (!bindings) return; - if (ts.isNamespaceImport(bindings)) { - namespaces.add(bindings.name.text); - return; - } - for (const spec of bindings.elements) { - aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text); - } - }); - - const calls: CallSite[] = []; - const walk = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - const callee = node.expression; - let name: string | null = null; - if (ts.isIdentifier(callee)) { - name = aliases.get(callee.text) ?? callee.text; - } else if ( - ts.isPropertyAccessExpression(callee) && - ts.isIdentifier(callee.expression) && - namespaces.has(callee.expression.text) - ) { - name = callee.name.text; - } - if (name !== null) { - const first = node.arguments[0]; - calls.push({ - name, - firstArg: first && ts.isStringLiteralLike(first) ? first.text : null, - }); - } - } - node.forEachChild(walk); - }; - parsed.forEachChild(walk); - return calls; -} - -const callCache = new Map(); - -function callSites(rel: string): CallSite[] { - const cached = callCache.get(rel); - if (cached !== undefined) return cached; - const found = callSitesIn(read(rel), rel); - callCache.set(rel, found); - return found; -} - -/** - * Every call in this module that reaches the audit table through the Prisma - * client instead of through `auditLog()`. - * - * Matched structurally: a call whose callee is `.auditLog.`, - * read off the property-access chain rather than off the file's text, so a - * comment or a string discussing the old shape cannot produce a finding and a - * call broken across lines cannot hide one. The delegated actor column is - * stamped in exactly one place, so any client-level write against this table - * is a row filed with no actor. - */ -const AUDIT_WRITE_VERBS = new Set([ - "create", - "createMany", - "createManyAndReturn", - "upsert", - "update", - "updateMany", -]); - -function directAuditWritesIn(source: string, fileName: string): string[] { - const parsed = ts.createSourceFile( - fileName, - source, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const found: string[] = []; - const walk = (node: ts.Node): void => { - if (ts.isCallExpression(node)) { - const callee = node.expression; - if ( - ts.isPropertyAccessExpression(callee) && - AUDIT_WRITE_VERBS.has(callee.name.text) && - ts.isPropertyAccessExpression(callee.expression) && - callee.expression.name.text === "auditLog" - ) { - found.push( - `${callee.expression.expression.getText()}.auditLog.${callee.name.text}`, - ); - } - } - node.forEachChild(walk); - }; - parsed.forEachChild(walk); - return found; -} - -/** Does this module call `symbol` anywhere, under any import name? */ -function callsSymbol(rel: string, symbol: string): boolean { - if (!read(rel).includes(symbol)) return false; - return callSites(rel).some((c) => c.name === symbol); -} - -/** The `GrantNeed` literals this module passes to the record resolver. */ -function recordNeeds(rel: string): Set { - if (!read(rel).includes(RECORD_RESOLVER)) return new Set(); - return new Set( - callSites(rel) - .filter((c) => c.name === RECORD_RESOLVER && c.firstArg !== null) - .map((c) => c.firstArg as string), - ); -} - -/** Names of the top-level functions a module exports. */ -function exportedFunctionNames(rel: string): string[] { - const parsed = ts.createSourceFile( - rel, - read(rel), - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS, - ); - const names: string[] = []; - parsed.forEachChild((node) => { - if (!ts.isFunctionDeclaration(node) || !node.name) return; - const exported = node.modifiers?.some( - (m) => m.kind === ts.SyntaxKind.ExportKeyword, - ); - if (exported) names.push(node.name.text); - }); - return names; -} - -/* -------------------------------------------------------------------------- */ -/* The frozen lists */ -/* -------------------------------------------------------------------------- */ - -/** - * Route modules permitted to act on a record that may not be the caller's. - * - * Every member carries a line in the classification record that says why it - * may. A route absent from that record cannot enter this list. - * - * The value is the reason, in one line, in the maintainer's words. Not the - * route's description — the argument for why a delegate reading or writing - * through it cannot extend their own reach. - * - * Note what a member means: the MODULE reaches the resolver. Until v1.36.x - * that was the GET arm alone in every case; eleven of these files now also - * reach it from a POST arm, and those eleven are frozen a second time in - * {@link DELEGABLE_WRITE_ROUTES} with their own leg below. Membership here is - * therefore necessary and not sufficient for a delegable write: a file can sit - * in this list and still refuse every mutation, which is what the other - * forty-one do. Any write arm not named in the write literal is a diff two - * lists have to agree on. - */ -const DELEGABLE_ROUTES: Record = { - "app/api/measurements/route.ts": - "The record's readings. The GET returns values, units, timestamps and notes scoped `userId: user.id`; the POST arms are delegable writes (see the write literal) and the PUT / DELETE beside them are not.", - "app/api/measurements/[id]/route.ts": - "One reading of the record, fetched by id and guarded against the resolved user before it is serialised. The PUT and DELETE beside it keep `requireAuth()`.", - "app/api/measurements/series/route.ts": - "A chart series over the record's own readings. Every `where` and every raw-SQL predicate binds the resolved user id; the route holds no write arm at all.", - "app/api/measurements/series-batch/route.ts": - "The multi-type form of the same series read, delegating to `readSeriesBatch(user.id, …)`. Nothing but the resolved id reaches the query.", - "app/api/measurement-reminders/route.ts": - "The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.", - "app/api/measurement-reminders/[id]/route.ts": - "One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.", - "app/api/labs/route.ts": - "The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result is a delegable write; editing and deleting one are not.", - "app/api/labs/[id]/route.ts": - "One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.", - "app/api/biomarkers/route.ts": - "The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. Adding a marker is a delegable write; the PUT and DELETE on the sibling route are refused.", - "app/api/biomarkers/[id]/route.ts": - "One biomarker of the record, fetch-then-guard against the resolved user.", - "app/api/allergies/route.ts": - "The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows. The POST beside it is NOT delegable and is deliberately absent from the write literal below: the only surface that posts to it lives under `/settings`, which a switch closes, so admitting the write would freeze a permission ahead of any caller for it. The route comment carries the argument.", - "app/api/allergies/[id]/route.ts": - "One allergy of the record, fetch-then-guard against the resolved user.", - "app/api/family-history/route.ts": - "The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's. Its POST is not delegable, for the reason its allergy sibling gives plus one of its own — see the route comment.", - "app/api/family-history/[id]/route.ts": - "One family-history entry of the record, fetch-then-guard against the resolved user.", - "app/api/mental-health/assessments/route.ts": - "The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.", - "app/api/anamnesis/facts/route.ts": - "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.", - "app/api/mood-entries/route.ts": - "The record's mood entries. The nested include is the tag vocabulary — a key and a kind, no identifiers. Note that `GET /api/mood/tags` is refused and stays refused: it pulls the owner's tag LAYOUT, which is a presentation preference and belongs to the person, not the record.", - "app/api/mood-entries/[id]/route.ts": - "One mood entry of the record, fetch-then-guard against the resolved user.", - "app/api/custom-metrics/route.ts": - "The record's own metric definitions and their latest values. Every `where` carries the resolved user id.", - "app/api/custom-metrics/[id]/route.ts": - "One custom metric of the record, scoped by the resolved user id in the query itself.", - "app/api/custom-metrics/[id]/entries/route.ts": - "The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query. The READ arm only: the POST left the write literal below in v1.36.x for the reason its allergy and family-history siblings did, and the route comment carries the argument.", - "app/api/personal-records/route.ts": - "The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.", - "app/api/sleep/night/route.ts": - "The record's hypnogram. Read-only, module-gated on the record, no write of any kind on the path.", - "app/api/sleep/rhythm/route.ts": - "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.", - "app/api/nutrients/route.ts": - "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.", - "app/api/nutrients/daily/route.ts": - "One nutrient's daily series over the record, scoped by the resolved user id.", - "app/api/illness/episodes/route.ts": - "The record's illness episodes. Module-gated on the record on both arms, so a delegate gets the surface only where the owner switched it on; the create arm is a delegable write.", - "app/api/illness/episodes/[id]/route.ts": - "One episode of the record, fetch-then-guard against the resolved user.", - "app/api/illness/episodes/[id]/day-logs/route.ts": - "The day logs of one episode. The episode-scoped queries are safe because `loadOwnedEpisode(id, user.id)` runs first and now resolves against the record; the upsert arm keeps `requireAuth()`.", - "app/api/workouts/route.ts": - "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.", - "app/api/cycle/cycles/route.ts": - "The record's cycle history. Whether cycle tracking applies is a property of the record, and the gate gets there by re-loading the profile and gender for the resolved user id — note that the `gender` argument at the call site is dead (`requireCycleEnabled` names it `_gender`), so it is the id that carries the substitution, not that argument.", - "app/api/cycle/day-logs/route.ts": - 'One day of the record\'s cycle log. The `auditLog("cycle.day-log.upsert")` in this file belongs to the PUT, which keeps `requireAuth()`.', - "app/api/cycle/profile/route.ts": - "The record's cycle profile, supplied by the gate itself. This is the one cycle route that does read the auth-context gender directly — `isCycleEnabled(user.gender, gate.profile)` computes the `resolved` flag it returns — and post-substitution that is the owner's.", - "app/api/cycle/symptoms/custom/route.ts": - "The record's own custom symptom vocabulary. The create arm carries the rate limit and keeps `requireAuth()`.", - "app/api/medications/route.ts": - "The record's medication cabinet. The cached list projection keys on the same resolved id it scopes to, so the owner's cell holds the owner's data. Returns no ingest endpoint and no token count — the route that does is refused.", - "app/api/medications/[id]/route.ts": - "One medication of the record, fetch-then-guard against the resolved user before anything is serialised.", - "app/api/medications/[id]/cadence/route.ts": - "One medication's cadence, behind `assertMedicationOwnership(id, user.id)` — the shared guard, resolved against the record.", - "app/api/medications/[id]/schedule-revisions/route.ts": - "One medication's archived schedule eras, behind the same ownership guard. The create arm keeps `requireAuth()` and its compliance backfill with it.", - "app/api/medications/[id]/side-effects/route.ts": - "One medication's recorded side effects, behind the same ownership guard. The rate limit in this file belongs to the create arm, which is a delegable write and keys its bucket on the ACTOR for exactly that reason.", - "app/api/medications/[id]/inventory/route.ts": - "One medication's inventory, behind the same ownership guard. The rate limit in this file belongs to the create arm.", - "app/api/medications/[id]/phase-config/route.ts": - "One medication's reminder phase config, behind the same ownership guard. The upsert arm keeps `requireAuth()`.", - "app/api/medications/[id]/intake/route.ts": - "One medication's intake history, behind the same ownership guard. Marking a dose is a delegable write, and the one that notifies the owner rather than only filing an audit row.", - "app/api/medications/intake/route.ts": - "The account-wide intake list, scoped through the resolved user. Its cache cell keys on the same id it scopes to, and the canonical slot write on the POST arm is delegable.", - "app/api/medications/compliance/route.ts": - "Batched compliance across the record's cabinet. The one delegable read that spends a quota, so its rate-limit bucket keys on the ACTOR: a delegate must burn their own allowance rather than lock the owner out, and must not collect a fresh one by switching records.", - - // The front door. Eight reads admitted together because `/` is the first - // page a delegate lands on and every one of them was refusing there, which - // made the entry point of the feature the worst surface in it. Six are - // aggregates or record state; two are presentation blobs whose write arms - // stay bare, and those two say so in their own line below. - "app/api/dashboard/snapshot/route.ts": - "The record's tile strip, and the widest aggregate in the product. Admitted on the whole-record grant: every input is a module the delegate may read directly, the briefing prose is lifted read-only off the owner's row rather than generated, and no credential or integration endpoint is in the payload. First route to re-examine if per-module scope ever lands.", - "app/api/daily/digest/route.ts": - "The record's Today hero. Same family and same argument as the snapshot, assembled from already-cached values with no provider on the path; the `insights` module gate now resolves against the RECORD, so a delegate gets the hero only where the owner switched it on.", - "app/api/gamification/achievements/route.ts": - "The record's badges, every one derived from the record's own history. Read-only in fact as well as in declaration since v1.35.3 moved the unlock INSERT onto the sweep job. Spans every module carrying a badge category, so it joins the snapshot and the digest in the per-module-scope re-examination.", - "app/api/insights/coach/nudge-status/route.ts": - "Whether the RECORD's Coach thread holds something unopened — a timestamp, a boolean and a conversation id. Not in tension with the Coach chat staying refused: chat spends the owner's AI budget and writes into their conversation, and this reads neither.", - "app/api/coach/reminders/route.ts": - "The record's reminder ledger. A 'remind me about X' note is a statement about the owner's own health, stored and encrypted on their row — the same shape as a mood note. The POST beside it keeps `requireAuth()`: writing into somebody's Coach memory puts a delegate's words in the voice the Coach reads back to the owner.", - "app/api/settings/reminder-thresholds/route.ts": - "The record's low-stock runway and reorder lead, which decide whether the OWNER's medication cards read 'low stock'. Projects exactly two integers out of `notificationPrefs`; the channels and endpoints in that object are unreachable from here and the route that serves it whole stays refused. No write arm exists.", - "app/api/dashboard/widgets/route.ts": - "The record's dashboard layout, read only. Settled by a fact rather than a preference: the snapshot already carries this layout and the client seeds the same cache cell from it, so an actor answer would put two people's arrangements in one key. The PUT and DELETE stay bare — a delegate adds to a record, never redecorates it.", - "app/api/medications/layout/route.ts": - "The record's medication-list presentation, read only. The stored `order` is a list of the OWNER's medication ids and unknown ids are dropped at apply time, so the caller's own order resolves to nothing against the owner's cabinet. The PUT and DELETE stay bare, and this is the refusal a delegate can actually walk into, since `/medications` is a shared destination.", - - // The document vault. Five reads admitted together for the reason the front - // door's eight were: `/documents` carries `sharedRecord: true`, so the shell - // offers the destination and every read behind it refused — the same failure - // v1.36.1 describes as fixed on `/`. Uploading, retyping, deleting, linking, - // indexing and every AI verb stay bare; the split is per-arm, because the - // resolver escalates a non-safe method to `"write"` on its own. - "app/api/documents/inbound/route.ts": - "The record's document list. The GET never selects the encrypted blob column and scopes every predicate to the resolved user id; the POST upload beside it is not delegable and keeps `requireAuth()`.", - "app/api/documents/inbound/[id]/route.ts": - "One document of the record with its staged facts and condition links, fetched under the resolved user id before anything is serialised. The PATCH and DELETE are edits and stay with the owner.", - "app/api/documents/inbound/[id]/thumbnail/route.ts": - "One document's thumbnail — the tile that makes the admitted list browsable. Owner-scoped through the 1:1 relation on the resolved document; the read-quota bucket keys on the ACTOR because decrypting costs something.", - "app/api/documents/inbound/[id]/original/route.ts": - "The document itself, which is the read the rest of the vault exists to reach: a caregiver needs the discharge letter, not its filing card. Same owner-scoped lookup, and the same actor-keyed bucket as the thumbnail, tighter because it decrypts the whole file.", - "app/api/documents/inbound/usage/route.ts": - "The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.", -}; - -/** - * Route modules permitted to WRITE into a record that may not be the caller's. - * - * A strict subset of {@link DELEGABLE_ROUTES}, and the shorter list on purpose: - * a delegate adds to a record and never rewrites it. No delete, no edit, no - * restore, no import, no sync ingest sits here, and `PUT /api/medications/[id]` - * is deliberately absent — adding a medication is in, correcting one is not, - * including the one the delegate created ten seconds ago with a typo in the - * dose. Every member is a create. - * - * Membership is decided by the ARGUMENT, not by the identifier: a module joins - * this list when it passes `"write"` to `requireRecordAuth`. That is what the - * thirty-one read-only delegable modules do not do, and it is the difference - * the identifier matcher above cannot see. - * - * v1.36.x — `POST /api/allergies` and `POST /api/family-history` left this - * list, and the removal is worth reading before either is proposed again. The - * argument for admitting them was never wrong; what they lacked was a caller. - * The only surface in the product that posts to either lives in Settings → - * Anamnese, and a switch closes `/settings` — so a delegate could not reach - * the form at any grant level, and the two entries were a permission frozen - * ahead of the surface that would exercise it. Both delegable READ arms stay: - * a caregiver reading the allergy list is what the feature is for. Whoever - * builds a caregiver-reachable medical-history surface adds them back in the - * same diff, which is the two-ended change this list is meant to hold to. - * - * `POST /api/custom-metrics/[id]/entries` followed them out, one release late, - * and the delay is the lesson: the rule that removed the other two was applied - * to two of the three routes it fitted. The only surface that posts a tracked - * value is the entry form on `/custom-metrics/{id}`, which is not a - * shared-record destination — the shell renders the unavailable panel there - * before the form mounts, and `custom-metric-list.tsx` says so in its own - * comment while returning null. So the capability shipped named on the consent - * screen, in six languages, with nothing behind it. Its READ arm stays. - * - * Every member also has to call `auditLog`, asserted below. That is not a - * stylistic preference. The decision not to add a `writtenBy` column to eleven - * tables rests entirely on the audit trail carrying the actor, and `auditLog` - * is the only writer that stamps `actorUserId` — a member that filed its rows - * through a bare `prisma.auditLog.create`, or filed none at all, would make a - * delegate's contribution indistinguishable from the owner's own. Asserting it - * here is what turns that decision from a thing nobody has forgotten yet into - * a property of the list. - */ -const DELEGABLE_WRITE_ROUTES: Record = { - "app/api/measurements/route.ts": - "Entering a reading. Both POST arms — single and batch — land rows under the resolved record; the safety-floor check, the reminder satisfaction and the rollup recompute all address the same record and are correct under substitution.", - "app/api/labs/route.ts": - "Entering a lab result. The free-text path may mint a biomarker, and mints it into the RECORD's catalogue — a result added to somebody's record that left the marker on the helper's account would be worse than useless to the owner.", - "app/api/biomarkers/route.ts": - "Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.", - "app/api/illness/episodes/route.ts": - "Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.", - "app/api/medications/[id]/side-effects/route.ts": - "Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.", - "app/api/medications/route.ts": - "Adding a medication with its nested schedule. The schedule is the point — a medication nobody scheduled reminds nobody of anything. The edit and delete verbs on the sibling route stay refused.", - "app/api/medications/intake/route.ts": - "Marking a dose, canonical slot form. The cross-device sync, the web-push dose-clear and the badge recount stay addressed to the OWNER, which is correct: it is their dose and their devices. The owner also gets told who marked it.", - "app/api/medications/[id]/intake/route.ts": - "Marking a dose, per-medication form. Same family, same ownership guard against the record, same notification to the owner; this arm has no snooze, so the state is always one of the two markings.", -}; - -/** - * Route modules that deliberately resolve the ACTOR and keep working under a - * switch, because they serve the caller's own account and nothing of the - * owner's. - * - * The intended members are the surfaces a switched session needs in order to - * stay usable and to get back out: the account bootstrap payload, the switch - * endpoint, logout, the native refresh route, the locale setter. Four of them - * exist so far. The rest arrive as their own diffs here; naming them before - * they exist would freeze a guess. - * - * Note that "the caller's own thing" is the test, not "read-only": the locale - * setter WRITES, and writes to the caller's own row resolved from their own - * session. No grant is consulted because none is needed. - */ -const ACTOR_ROUTES: Record = { - "app/api/account/switch/route.ts": - "The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.", - "app/api/auth/me/route.ts": - "The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.", - "app/api/auth/me/locale/route.ts": - "The UI language belongs to the person reading the screen, never to the record on it. Under a record scope this would transplant a delegate's choice onto the owner's row and send the owner's cron mail in a language they may not read. It has to keep working rather than merely refuse safely: the switcher's mount-time backfill fires on every page load, including the shared dashboard.", - "app/api/feature-flags/route.ts": - "Reads the `AppSettings` singleton and no user row at all, so there is no record for a switch to substitute. It answers about the deployment, which for this purpose is the caller's side of the request; the shell gates the Coach launcher and the assistant surfaces on it, so a 403 here is a piece of chrome deciding it does not exist.", -}; - -/** - * Entries across all three lists. Pinned so that the reason-loop below cannot - * stay a formality by accident: every addition has to be counted here as well - * as listed above, which is one more place a careless admission has to pass. - */ -const FROZEN_ENTRY_COUNT = 69; - -/** - * The two surfaces that authenticate a Bearer token outside `requireAuth` — - * frozen as a set by `bearer-scope-enforcement-guard.test.ts`, repeated here - * for the one property that guard does not cover: neither may reach either new - * resolver. Both hand-roll their own token resolution, so neither would inherit - * the refusal arms that make the switch fail closed. - * - * The value is the identifier that proves the file is still that surface. If a - * file stops resolving a Bearer token, the "it never reaches the resolvers" - * assertion below becomes true for the wrong reason, so the anchor is checked - * first. - */ -const OUT_OF_BAND_BEARER: Record = { - // Hand-rolled `tokenHash` lookup; gates on the per-medication grant. - "app/api/ingest/medication/route.ts": "tokenHash", - // Resolves through `resolveMcpAuthContext`, the MCP edge of the resolver. - "app/mcp/route.ts": "resolveMcpAuthContext", -}; - -/* -------------------------------------------------------------------------- */ -/* The matcher's own proof */ -/* -------------------------------------------------------------------------- */ - -describe("the matchers see what they look for", () => { - /** - * These run against strings, not the tree, and they are the reason the empty - * allowlists above mean anything. Every leg below reports "no route calls - * these resolvers". That sentence is worth exactly as much as the evidence - * that the matcher could have said otherwise. - */ - const DELEGABLE_MODULE = ` - import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; - import { apiSuccess } from "@/lib/api-response"; - - export const GET = apiHandler(async () => { - const { user, actor } = await requireRecordAuth("read"); - return apiSuccess({ owner: user.id, actor: actor.id }); - }); - `; - - const ALIASED_MODULE = ` - import { requireRecordAuth as resolveRecord } from "@/lib/api-handler"; - - export const GET = apiHandler(async () => { - const { user } = await resolveRecord("read"); - return apiSuccess({ id: user.id }); - }); - `; - - const NAMESPACE_MODULE = ` - import * as auth from "@/lib/api-handler"; - - export const GET = auth.apiHandler(async () => { - const { user } = await auth.requireActorAuth(); - return apiSuccess({ id: user.id }); - }); - `; - - const PROSE_ONLY_MODULE = ` - /** - * Not delegable: requireRecordAuth would substitute the data scope, and - * this route reads the caller's own notification channels. requireAdmin is - * not used either. - */ - import { apiHandler, requireAuth } from "@/lib/api-handler"; - - const NOTE = "requireActorAuth"; - - export const GET = apiHandler(async () => { - const { user } = await requireAuth(); - return apiSuccess({ id: user.id, note: NOTE }); - }); - `; - - it("finds a direct call", () => { - expect(identifiersIn(DELEGABLE_MODULE, "d.ts").has(RECORD_RESOLVER)).toBe( - true, - ); - }); - - it("finds an aliased import, which a call-site matcher would miss", () => { - const ids = identifiersIn(ALIASED_MODULE, "a.ts"); - expect(ids.has(RECORD_RESOLVER)).toBe(true); - // The call itself reads `resolveRecord(`. Matching calls rather than - // identifiers would have found nothing here and reported the route clean. - expect(ids.has("resolveRecord")).toBe(true); - }); - - it("finds a namespace-qualified call", () => { - expect(identifiersIn(NAMESPACE_MODULE, "n.ts").has(ACTOR_RESOLVER)).toBe( - true, - ); - }); - - it("does not enrol a module that only mentions the resolvers", () => { - const ids = identifiersIn(PROSE_ONLY_MODULE, "p.ts"); - expect(ids.has(RECORD_RESOLVER)).toBe(false); - expect(ids.has(ACTOR_RESOLVER)).toBe(false); - expect(ids.has("requireAdmin")).toBe(false); - // And it is a real module, not an empty parse. - expect(ids.has("requireAuth")).toBe(true); - }); - - it("the substring pre-filter decides nothing", () => { - // The prose module contains all three names as raw text. If the pre-filter - // were doing the deciding, the assertions above would be inverted. - expect(PROSE_ONLY_MODULE.includes(RECORD_RESOLVER)).toBe(true); - expect(PROSE_ONLY_MODULE.includes(ACTOR_RESOLVER)).toBe(true); - expect(PROSE_ONLY_MODULE.includes("requireAdmin")).toBe(true); - }); - - it("reads the need argument through a direct, aliased and namespaced call", () => { - // The write leg turns on this and nothing else. `requireRecordAuth("read")` - // and `requireRecordAuth("write")` are the same identifier, so the - // identifier matcher above cannot tell a delegable read from a delegable - // write; if this matcher silently returned an empty set, the write leg - // would compare an empty set to an empty literal and agree with itself. - expect([...callSitesIn(DELEGABLE_MODULE, "d.ts")]).toContainEqual({ - name: RECORD_RESOLVER, - firstArg: "read", - }); - - const WRITE_MODULE = ` - import { requireRecordAuth as resolveRecord } from "@/lib/api-handler"; - - export const POST = apiHandler(async () => { - const { user } = await resolveRecord("write"); - await auditLog("thing.create", { userId: user.id }); - return apiSuccess({ id: user.id }); - }); - `; - const aliased = callSitesIn(WRITE_MODULE, "w.ts"); - // Resolved back through the import table: the call site itself reads - // `resolveRecord(`, and a matcher keyed on the written name would report - // this module as neither a read nor a write. - expect(aliased).toContainEqual({ - name: RECORD_RESOLVER, - firstArg: "write", - }); - expect(aliased.some((c) => c.name === "auditLog")).toBe(true); - - const NAMESPACED_WRITE = ` - import * as auth from "@/lib/api-handler"; - - export const POST = auth.apiHandler(async () => { - const { user } = await auth.requireRecordAuth("write"); - return apiSuccess({ id: user.id }); - }); - `; - expect(callSitesIn(NAMESPACED_WRITE, "nw.ts")).toContainEqual({ - name: RECORD_RESOLVER, - firstArg: "write", - }); - }); - - it("does not read a need out of prose or an unrelated string", () => { - // The word "write" appears twice as raw text here and neither occurrence - // is an argument to the resolver. A grep-shaped matcher enrols this file. - const NOT_A_WRITE = ` - /** This route is delegable for reads. It must never write. */ - import { requireRecordAuth } from "@/lib/api-handler"; - - const MODE = "write"; - - export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read"); - return apiSuccess({ id: user.id, mode: MODE }); - }); - `; - const sites = callSitesIn(NOT_A_WRITE, "nr.ts"); - // Non-zero: the parse produced call sites, so the negative below is a - // measurement rather than an empty walk. - expect(sites.length).toBeGreaterThan(0); - expect(sites).toContainEqual({ name: RECORD_RESOLVER, firstArg: "read" }); - expect(sites).not.toContainEqual({ - name: RECORD_RESOLVER, - firstArg: "write", - }); - expect(NOT_A_WRITE.includes('"write"')).toBe(true); - }); - - it("does not count an imported-but-uncalled auditLog", () => { - // Leg (e)'s audit assertion asks whether the module CALLS the helper. An - // import the module never reaches would satisfy an identifier matcher and - // stamp no actor on anything. - const IMPORTED_ONLY = ` - import { auditLog } from "@/lib/auth/audit"; - import { requireRecordAuth } from "@/lib/api-handler"; - - export const POST = apiHandler(async () => { - const { user } = await requireRecordAuth("write"); - return apiSuccess({ id: user.id, helper: auditLog.name }); - }); - `; - const sites = callSitesIn(IMPORTED_ONLY, "io.ts"); - expect(sites).toContainEqual({ name: RECORD_RESOLVER, firstArg: "write" }); - expect(sites.some((c) => c.name === "auditLog")).toBe(false); - // …while the identifier matcher, which is the one this would have fooled, - // says yes. - expect(identifiersIn(IMPORTED_ONLY, "io.ts").has("auditLog")).toBe(true); - }); - - it("sees a client-level audit write, and not a comment about one", () => { - const OFFENDER = ` - import { prisma } from "@/lib/db"; - import { requireRecordAuth } from "@/lib/api-handler"; - - export const POST = apiHandler(async () => { - const { user } = await requireRecordAuth("write"); - prisma.auditLog - .create({ data: { userId: user.id, action: "thing.failed" } }) - .catch(() => {}); - return apiSuccess({ id: user.id }); - }); - `; - // Split across lines exactly as the shipped call sites were. A matcher - // demanding a literal `prisma.auditLog.create(` on one line finds nothing - // here — the class of bug this repository has already shipped twice. - expect(directAuditWritesIn(OFFENDER, "off.ts")).toEqual([ - "prisma.auditLog.create", - ]); - - const PROSE = ` - import { auditLog } from "@/lib/auth/audit"; - - export const POST = apiHandler(async () => { - // Through auditLog() rather than a bare prisma.auditLog.create, - // because only the helper stamps the actor. - await auditLog("thing.created", {}); - return apiSuccess({}); - }); - `; - expect(directAuditWritesIn(PROSE, "prose.ts")).toEqual([]); - expect(PROSE.includes("prisma.auditLog.create")).toBe(true); - }); - - it("the co-occurrence rule fires on a module that does both", () => { - // Leg (c) finds nothing on today's tree because no delegable module runs a - // role check. This is the shape it exists to catch, proved against the - // same predicate the tree scan uses. - const OFFENDER = ` - import { requireRecordAuth, requireAdmin } from "@/lib/api-handler"; - - export const POST = apiHandler(async () => { - await requireAdmin(); - const { user } = await requireRecordAuth("write"); - return apiSuccess({ id: user.id }); - }); - `; - const ids = identifiersIn(OFFENDER, "o.ts"); - expect(ids.has(RECORD_RESOLVER)).toBe(true); - expect(COOKIE_ONLY_HELPERS.some((h) => ids.has(h))).toBe(true); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* The anchor: the symbols this file names are the symbols in production */ -/* -------------------------------------------------------------------------- */ - -describe("the resolvers exist and live in one place", () => { - it("both are exported from the auth layer", () => { - // If either symbol were renamed, every leg below would find nothing and - // agree with its empty allowlist. This is the assertion that stops that. - const exported = exportedFunctionNames("lib/api-handler.ts"); - expect(exported.length).toBeGreaterThan(0); - expect(exported).toContain(RECORD_RESOLVER); - expect(exported).toContain(ACTOR_RESOLVER); - }); - - it("nothing outside the frozen route lists reaches either resolver", () => { - // A shared helper calling `requireRecordAuth` would make every route that - // imports it delegable without any route file saying so. The route-level - // legs below cannot see that; this one can. - const files = sourceFiles(); - expect(files.length).toBeGreaterThan(SOURCE_FILE_FLOOR); - - const naming = files.filter( - (rel) => - namesSymbol(rel, RECORD_RESOLVER) || namesSymbol(rel, ACTOR_RESOLVER), - ); - - expect(naming.length).toBeGreaterThan(0); - expect(naming).toEqual( - [ - // Declares both. Not a caller. - "lib/api-handler.ts", - ...Object.keys(DELEGABLE_ROUTES), - ...Object.keys(ACTOR_ROUTES), - ].sort(), - ); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* (a) the delegable list */ -/* -------------------------------------------------------------------------- */ - -describe("(a) the delegable route set is frozen", () => { - it("equals the frozen list", () => { - const scanned = routeModules(); - - // The scan must find routes. This is the count that starts non-zero; the - // result set is the count that starts at zero, and they are not the same - // claim. Without this line an empty glob and an empty allowlist agree. - expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); - expect(scanned.every((rel) => rel.endsWith("/route.ts"))).toBe(true); - expect(scanned).toContain("app/api/measurements/route.ts"); - // The dot-directory pin. A glob-based enumeration drops this file and the - // two beside it without saying anything; the scan would still look healthy - // at 444 modules. Naming one of them makes that failure loud. - expect(scanned).toContain( - "app/.well-known/oauth-protected-resource/route.ts", - ); - - // Positive control for this leg's own matcher. It was written when the - // frozen list was empty and "no route names the resolver" and "the matcher - // names nothing" were the same sentence; the list is populated now, so the - // assertion below would catch a neutered matcher on its own. Kept anyway, - // because a future removal could empty the list again and the day that - // happens is not the day to rediscover this. `lib/api-handler.ts` is the - // guaranteed positive — it declares the symbol. - expect(modulesNaming(["lib/api-handler.ts"], RECORD_RESOLVER)).toEqual([ - "lib/api-handler.ts", - ]); - - expect(modulesNaming(scanned, RECORD_RESOLVER)).toEqual( - Object.keys(DELEGABLE_ROUTES).sort(), - ); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* (b) the actor-surface list */ -/* -------------------------------------------------------------------------- */ - -describe("(b) the actor-surface route set is frozen", () => { - it("equals the frozen list", () => { - const scanned = routeModules(); - - expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); - - // Positive control, same reason as leg (a). - expect(modulesNaming(["lib/api-handler.ts"], ACTOR_RESOLVER)).toEqual([ - "lib/api-handler.ts", - ]); - - expect(modulesNaming(scanned, ACTOR_RESOLVER)).toEqual( - Object.keys(ACTOR_ROUTES).sort(), - ); - }); -}); - -describe("every frozen entry carries a reason", () => { - it("names why the route may say what it says", () => { - const entries = [ - ...Object.entries(DELEGABLE_ROUTES), - ...Object.entries(DELEGABLE_WRITE_ROUTES), - ...Object.entries(ACTOR_ROUTES), - ]; - - for (const [rel, reason] of entries) { - expect(reason.trim().length, `${rel} has no reason`).toBeGreaterThan(0); - } - - // The loop above now runs, once. The count stays because it is what makes - // an addition visible twice — in the list and here — rather than once. - expect(entries.length).toBe(FROZEN_ENTRY_COUNT); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* (c) the record resolver never sits beside a role or step-up check */ -/* -------------------------------------------------------------------------- */ - -describe("(c) a record resolver never shares a module with a role or step-up check", () => { - it("no handler module names both", () => { - const scanned = routeModules(); - expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); - - // This leg's own non-zero proof, and the one that matters most here: the - // offender set is empty today whether or not the helper names are right, - // because no delegable module runs a role check. So each helper has to be - // findable in the very tree being scanned. Rename `requireAdmin` and this - // fails, rather than the guard quietly checking for a symbol that no - // longer exists. - for (const helper of COOKIE_ONLY_HELPERS) { - expect(modulesNaming(scanned, helper).length, helper).toBeGreaterThan(0); - } - - const offenders = scanned.filter((rel) => { - if (!namesSymbol(rel, RECORD_RESOLVER)) return false; - const ids = identifiers(rel); - return COOKIE_ONLY_HELPERS.some((helper) => ids.has(helper)); - }); - - expect(offenders).toEqual([]); - }); - - it("the declaration site is out of scope by construction", () => { - // `lib/api-handler.ts` declares the record resolver AND all three cookie- - // only helpers, so a scan of every source file would need it exempted, and - // an exemption is a hole. Scoping the leg to handler modules removes the - // need for one. This asserts the scoping still holds. - const src = exportedFunctionNames("lib/api-handler.ts"); - expect(src).toContain(RECORD_RESOLVER); - for (const helper of COOKIE_ONLY_HELPERS) expect(src).toContain(helper); - expect(routeModules()).not.toContain("lib/api-handler.ts"); - }); -}); - -/* -------------------------------------------------------------------------- */ -/* (d) the out-of-band Bearer surfaces */ -/* -------------------------------------------------------------------------- */ - -describe("(d) the out-of-band Bearer surfaces stay out of the switch", () => { - it("both are still route modules in the tree", () => { - const scanned = routeModules(); - for (const rel of Object.keys(OUT_OF_BAND_BEARER)) { - expect(scanned, rel).toContain(rel); - } - }); - - it.each(Object.entries(OUT_OF_BAND_BEARER))( - "%s resolves its own Bearer token and reaches neither resolver", - (rel, anchor) => { - const ids = identifiers(rel); - - // Non-zero proof, twice: the parse produced identifiers at all, and the - // file is still the Bearer surface this list claims it is. A file that - // stopped resolving a token would satisfy the two assertions below for a - // reason that has nothing to do with the switch. - expect(ids.size).toBeGreaterThan(0); - expect(ids.has(anchor), `${rel} no longer names ${anchor}`).toBe(true); - - expect(ids.has(RECORD_RESOLVER)).toBe(false); - expect(ids.has(ACTOR_RESOLVER)).toBe(false); - }, - ); -}); - -/* -------------------------------------------------------------------------- */ -/* (e) the delegable WRITE set */ -/* -------------------------------------------------------------------------- */ - -describe("(e) the delegable write set is frozen", () => { - it("equals the frozen write list", () => { - const scanned = routeModules(); - expect(scanned.length).toBeGreaterThan(ROUTE_MODULE_FLOOR); - - const writing = scanned.filter((rel) => recordNeeds(rel).has("write")); - expect(writing).toEqual(Object.keys(DELEGABLE_WRITE_ROUTES).sort()); - }); - - it("is a strict subset of the delegable set", () => { - // A module cannot write into a record it may not read. The read list is - // where the argument for each file lives, so a write entry with no read - // entry would be an admission with no recorded reason. - for (const rel of Object.keys(DELEGABLE_WRITE_ROUTES)) { - expect(DELEGABLE_ROUTES, rel).toHaveProperty([rel]); - } - expect(Object.keys(DELEGABLE_WRITE_ROUTES).length).toBeLessThan( - Object.keys(DELEGABLE_ROUTES).length, - ); - }); - - it("leaves the rest of the delegable set reading only", () => { - // The non-zero half of the leg above, and the one that makes an empty - // matcher fail: the read-only remainder must be found, must be large, and - // must be found by the SAME matcher that decided the write set. If - // `recordNeeds` stopped returning anything, this drops to zero and fails - // rather than agreeing that nothing writes. - const readOnly = Object.keys(DELEGABLE_ROUTES).filter( - (rel) => !(rel in DELEGABLE_WRITE_ROUTES), - ); - expect(readOnly.length).toBeGreaterThan(20); - for (const rel of readOnly) { - const needs = recordNeeds(rel); - expect(needs.has("read"), `${rel} does not resolve a record read`).toBe( - true, - ); - expect(needs.has("write"), `${rel} writes without being listed`).toBe( - false, - ); - } - }); - - it("every write module files its rows through auditLog", () => { - // The condition of admission, enforced. `auditLog()` is the only writer - // that stamps `actorUserId`; a delegated write recorded without it is - // indistinguishable from the owner having done it themselves, and the - // decision NOT to add a provenance column to eleven tables was taken on - // the strength of this trail existing. - const modules = Object.keys(DELEGABLE_WRITE_ROUTES); - expect(modules.length).toBeGreaterThan(0); - - for (const rel of modules) { - expect(callsSymbol(rel, "auditLog"), `${rel} never calls auditLog`).toBe( - true, - ); - } - }); - - it("no write module reaches the audit table without the helper", () => { - // The other half of the same property, and the failure this repository - // actually shipped: the helper is imported and used on the happy path - // while a validation-failure breadcrumb goes straight to the table, where - // it lands with `actorUserId` NULL — a delegate's malformed payload filed - // as if the owner had typed it. Eleven such calls existed across these - // files before the write migration. - // - // AST, not text, per this file's own doctrine: these modules explain in - // prose why they no longer call the client directly, and a comment saying - // so must not be the thing that trips the guard. - const modules = Object.keys(DELEGABLE_WRITE_ROUTES); - expect(modules.length).toBeGreaterThan(0); - - for (const rel of modules) { - expect( - directAuditWritesIn(read(rel), rel), - `${rel} writes an audit row without the helper`, - ).toEqual([]); - } - }); -}); - -/* -------------------------------------------------------------------------- */ -/* The counter-test: quiet on the innocent tree */ -/* -------------------------------------------------------------------------- */ - -describe("the guard is quiet on the tree it was written against", () => { - it("the bare-requireAuth population is scanned and produces no findings", () => { - const scanned = routeModules(); - - // The routes that resolve auth the ordinary way. They are the bulk of the - // tree and the thing this guard must not shout at. Iterating them is what - // makes "no findings" a measurement rather than a hope: 344 modules read, - // parsed where the substring appears, and none of them names a resolver. - const bare = modulesNaming(scanned, "requireAuth").filter( - (rel) => !(rel in DELEGABLE_ROUTES) && !(rel in ACTOR_ROUTES), - ); - expect(bare.length).toBeGreaterThan(BARE_REQUIRE_AUTH_FLOOR); - - for (const rel of bare) { - expect(namesSymbol(rel, RECORD_RESOLVER), rel).toBe(false); - expect(namesSymbol(rel, ACTOR_RESOLVER), rel).toBe(false); - } - }); -}); diff --git a/src/__tests__/guardian-notification-payloads.test.ts b/src/__tests__/guardian-notification-payloads.test.ts new file mode 100644 index 000000000..183daeb95 --- /dev/null +++ b/src/__tests__/guardian-notification-payloads.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; + +describe("managed Guardian notification payload safety", () => { + it("recognises only cross-account record delivery as managed Guardian delivery", () => { + expect( + isManagedGuardianDelivery({ + recordUserId: "record-1", + recipientUserId: "guardian-1", + }), + ).toBe(true); + + expect( + isManagedGuardianDelivery({ + recordUserId: "user-1", + recipientUserId: "user-1", + }), + ).toBe(false); + }); +}); diff --git a/src/__tests__/guardian-notification-policy.test.ts b/src/__tests__/guardian-notification-policy.test.ts new file mode 100644 index 000000000..086ad5de0 --- /dev/null +++ b/src/__tests__/guardian-notification-policy.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { findUnique, findMany } = vi.hoisted(() => ({ + findUnique: vi.fn(), + findMany: vi.fn(), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique }, + accountGrant: { findMany }, + }, +})); + +import { + isManagedGuardianFanoutAllowed, + resolveManagedGuardianRecipientIds, +} from "@/lib/notifications/delivery-identity"; + +const recordUserId = "managed-record"; + +function payload(over: Record = {}) { + return { + eventType: "MEDICATION_REMINDER" as const, + userId: recordUserId, + title: "Record content", + message: "Record schedule", + ...over, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + findUnique.mockResolvedValue({ managedProfileAt: new Date() }); + findMany.mockResolvedValue([ + { granteeId: "guardian-one", grantee: { managedProfileAt: null } }, + ]); +}); + +describe("guardian notification policy — allowlist and eligibility", () => { + it("allows exactly medication, measurement, low-stock, and explicitly tagged safety-floor alerts", () => { + expect(isManagedGuardianFanoutAllowed(payload())).toBe(true); + expect( + isManagedGuardianFanoutAllowed( + payload({ eventType: "MEASUREMENT_REMINDER" }), + ), + ).toBe(true); + expect( + isManagedGuardianFanoutAllowed( + payload({ eventType: "MEDICATION_LOW_STOCK" }), + ), + ).toBe(true); + expect( + isManagedGuardianFanoutAllowed( + payload({ + eventType: "SYSTEM_ALERT", + managedFanoutEvent: "SAFETY_FLOOR_ALERT", + }), + ), + ).toBe(true); + + expect( + isManagedGuardianFanoutAllowed(payload({ eventType: "SYSTEM_ALERT" })), + ).toBe(false); + expect( + isManagedGuardianFanoutAllowed( + payload({ eventType: "MEASUREMENT_ANOMALY" }), + ), + ).toBe(false); + expect( + isManagedGuardianFanoutAllowed( + payload({ + eventType: "SYSTEM_ALERT", + managedFanoutEvent: "ILLNESS_RED_FLAG", + }), + ), + ).toBe(false); + }); + + it("enumerates only active Guardians of the marked record", async () => { + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toEqual(["guardian-one"]); + + expect(findMany).toHaveBeenCalledWith({ + where: expect.objectContaining({ grantorId: recordUserId }), + select: { + granteeId: true, + grantee: { select: { managedProfileAt: true } }, + }, + }); + }); + + it("does not fan out unmarked records, disallowed events, self delivery, or invalid Guardians", async () => { + findUnique.mockResolvedValueOnce({ managedProfileAt: null }); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toBeNull(); + + await expect( + resolveManagedGuardianRecipientIds( + payload({ eventType: "MEASUREMENT_ANOMALY" }), + ), + ).resolves.toBeNull(); + + findMany.mockResolvedValueOnce([ + { granteeId: recordUserId, grantee: { managedProfileAt: null } }, + { + granteeId: "managed-guardian", + grantee: { managedProfileAt: new Date() }, + }, + { granteeId: "guardian-two", grantee: { managedProfileAt: null } }, + ]); + await expect( + resolveManagedGuardianRecipientIds(payload()), + ).resolves.toEqual(["guardian-two"]); + }); +}); diff --git a/src/__tests__/manage-activity-inventory.test.ts b/src/__tests__/manage-activity-inventory.test.ts new file mode 100644 index 000000000..6f0b765ec --- /dev/null +++ b/src/__tests__/manage-activity-inventory.test.ts @@ -0,0 +1,690 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +import { revokeBody } from "@/components/settings/access/grants-given-card"; +import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb"; + +/** + * Every MANAGE-admitted action names itself, in all six locales. + * + * ## What this asks that the sibling guard does not + * + * `src/lib/record-activity/__tests__/verb-map-covers-routes.test.ts` asks the + * same question of the WRITE surface: a delegable write whose audit action has + * no sentence renders as "made a change". MANAGE is where that answer stops + * being merely vague and starts being wrong. A WRITE delegate can only add, + * and "somebody added something" is at least the right shape of fact. A MANAGE + * delegate can change and remove entries the owner made themselves, so the + * generic line would tell an owner that something happened in their record + * while withholding the only part they need — whether a reading was corrected + * or deleted, whether a medication was stopped, whether the dose history was + * purged. The level ships under "reconstructable or refused", and a feed that + * cannot say which verb ran does not reconstruct anything. + * + * So the inventory here is the MANAGE half of the same sweep, and it goes two + * steps further than its sibling: + * + * 1. it resolves the sentence by CALLING the map rather than by matching + * `case "…"` in its source, because a `case` inside a comment matches and + * an action whose arm falls through to the fallback does not; + * 2. it follows the resolved key into all six bundles, because a key that + * exists only in English renders as its own dotted path to everyone else. + * + * ## PHI + * + * Two legs, and both are about the same failure. The activity feed reads out + * of the audit table, the audit table is not encrypted, and the temptation + * whenever a sentence feels thin is to put the value in it — "{name} changed + * your weight from 81 to 78". That is a health figure in a second store with + * its own retention and no rotation story. So: every verb line interpolates + * `name` and nothing else, in every locale, and the feed's own DTO carries no + * `details` field for a future caller to reach for. + * + * ## Why the counts are of MATCHES + * + * The verdict is an empty list of unmapped actions, and an empty match set + * says exactly the same thing. Every leg therefore pins the size of what it + * matched — modules walked, MANAGE modules found, one of them named, actions + * read, sentences resolved — so a matcher that quietly stopped matching fails + * instead of passing. `describe("the matcher sees what it looks for")` proves + * both halves against synthetic modules, including the one shape that silenced + * the sibling guard: a resolver call prettier broke across lines. + */ +const SRC = join(process.cwd(), "src"); +const API = join(SRC, "app/api"); +const MESSAGES = join(process.cwd(), "messages"); +const ACTIVITY_ROUTE = join( + process.cwd(), + "src/app/api/account/activity/route.ts", +); +const GRANTS_GIVEN_CARD = join( + process.cwd(), + "src/components/settings/access/grants-given-card.tsx", +); + +/** The declaration a record-delegated route makes, at any level. */ +const RECORD_RESOLVER = "requireRecordAuth"; + +/** + * Rows the feed cannot show, and the reason each one cannot. + * + * `src/lib/api-handler.ts` is a direct import of every route, so the walk + * below reaches the three actions the auth kit files itself. None of them can + * appear in a record owner's activity view, and the reason is the same for all + * three: they are filed under `auth.user.id` — the CALLER — at a point where + * no record has been resolved. The feed selects rows where `userId` is the + * owner and `actorUserId` is somebody else, so a caller-filed row is excluded + * twice over. + * + * Frozen at three. A fourth entry is a claim about a row nobody looked at, and + * the leg below fails rather than letting one join quietly. + */ +const NOT_IN_THE_FEED = [ + // The bearer path never resolves a record at all. + "auth.bearer.failure", + // Step-up is cookie-only and filed under the account being elevated. + "auth.stepup.elevation.rejected", + // `auditRefusal` files under the refused caller, before a record exists. + "sharing.access.denied", +] as const; +/** What an action with no sentence of its own renders as. */ +const FALLBACK_KEY = "recordSharing.activity.acted"; +/** The six bundles the product ships. */ +const LOCALES = ["de", "en", "es", "fr", "it", "pl"] as const; + +/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's + * `*` skips a leading dot and this tree has dot-directories. */ +function routeFiles(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) out.push(...routeFiles(full)); + else if (entry.name === "route.ts") out.push(full); + } + return out; +} + +interface ModuleFacts { + /** Does the module reach the record resolver at ANY level. */ + delegatesRecord: boolean; + /** + * Every level the module passes to `requireRecordAuth`, in source order. + * + * Recorded so the inclusion rule's LEVEL axis has something that can + * falsify it. `delegatesRecord` used to be set only for `"manage"`, and the + * legs below could not tell the corrected rule from the old one: every + * assertion about coverage passed either way, because a manage-only sweep + * still finds every manage action. The leg that separates them asks whether + * the walked set STRICTLY EXCEEDS the manage-only one, and it needs the + * levels to ask. + */ + recordLevels: string[]; + /** + * Every action the module hands to `auditLog("…")`, awaited or not. + * + * v1.37.0 — this used to keep only the AWAITED calls, on the reasoning that + * a fire-and-forget breadcrumb is not worth narrating. The feed disagrees, + * and the feed is what the owner reads: `/api/account/activity` selects on + * `actorUserId IS NOT NULL` with no action filter and no await in sight, so + * a `void auditLog(…)` breadcrumb lands in the view exactly like an awaited + * row. Ten `*.validation-failed` actions were invisible to this guard for + * that reason and rendered to owners as "made a change in your record" — + * including one a READ delegate can trigger. A guard whose inclusion rule + * differs from its subject's proves a property about the wrong set. + */ + auditActions: string[]; + /** First-party modules this one imports, resolved to files. */ + imports: string[]; +} + +/** + * Read both facts off one parse. + * + * AST rather than substrings, for the reason the sibling guard learned by + * going green while matching nothing: prettier moves a call's argument onto + * its own line the moment the call grows a second one, and + * `requireRecordAuth("manage"` stops appearing in the source text without + * anything about the route changing. The import table resolves a local name + * back to its exported one, so an alias and a namespace-qualified call both + * count, and a comment naming either symbol counts for nothing. + */ +export function moduleFacts(source: string, fileName: string): ModuleFacts { + const parsed = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + const aliases = new Map(); + const namespaces = new Set(); + const importPaths: string[] = []; + parsed.forEachChild((node) => { + if (!ts.isImportDeclaration(node)) return; + if (ts.isStringLiteral(node.moduleSpecifier)) { + const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName); + if (resolved !== null && !resolved.includes("__tests__")) { + importPaths.push(resolved); + } + } + const bindings = node.importClause?.namedBindings; + if (!bindings) return; + if (ts.isNamespaceImport(bindings)) { + namespaces.add(bindings.name.text); + return; + } + for (const spec of bindings.elements) { + aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text); + } + }); + + /** The original exported name behind this callee, or null. */ + const calleeName = (callee: ts.Expression): string | null => { + if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text; + if ( + ts.isPropertyAccessExpression(callee) && + ts.isIdentifier(callee.expression) && + namespaces.has(callee.expression.text) + ) { + return callee.name.text; + } + return null; + }; + + /** The first argument when it is a plain string literal. */ + const firstStringArg = (node: ts.CallExpression): string | null => { + const first = node.arguments[0]; + return first && ts.isStringLiteralLike(first) ? first.text : null; + }; + + let delegatesRecord = false; + const recordLevels: string[] = []; + const auditActions: string[] = []; + + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = calleeName(node.expression); + if (name === RECORD_RESOLVER) { + delegatesRecord = true; + const level = firstStringArg(node); + if (level !== null) recordLevels.push(level); + } + if (name === "auditLog") { + const action = firstStringArg(node); + if (action !== null) auditActions.push(action); + } + } + node.forEachChild(visit); + }; + parsed.forEachChild(visit); + + return { delegatesRecord, recordLevels, auditActions, imports: importPaths }; +} + +/** + * A module specifier resolved to a first-party file, or null. + * + * `@/…` and relative paths only. A package import is somebody else's tree and + * cannot be walked usefully. + */ +export function resolveFirstParty( + specifier: string, + fromFile: string, +): string | null { + let base: string; + if (specifier.startsWith("@/")) base = join(SRC, specifier.slice(2)); + else if (specifier.startsWith(".")) base = join(dirname(fromFile), specifier); + else return null; + for (const candidate of [ + `${base}.ts`, + `${base}.tsx`, + join(base, "index.ts"), + ]) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** + * Every action one route module can file, including through what it imports. + * + * One level deep, deliberately. The transitive closure pulls in the whole auth + * and grant machinery through `api-handler`, which reports actions no request + * to this route can file; one level is where a route's own collaborators live + * — the import-admission module and the medication lifecycle module are both + * direct imports of the routes that use them, and both were invisible to a + * walk that read only `route.ts`. + */ +function actionsReachableFrom( + file: string, + read: (path: string) => ModuleFacts, +): string[] { + const own = read(file); + const out = [...own.auditActions]; + for (const imported of own.imports) { + out.push(...read(imported).auditActions); + } + return out; +} + +/** The message key one action resolves to, or the fallback. */ +function verbKeyFor(action: string): { key: string; params: string[] } { + let key = ""; + let params: string[] = []; + recordActivityVerbLine( + (resolved, given) => { + key = resolved; + params = Object.keys(given ?? {}); + return resolved; + }, + action, + "Alex", + ); + return { key, params }; +} + +function bundle(locale: string): Record { + return JSON.parse( + readFileSync(join(MESSAGES, `${locale}.json`), "utf8"), + ) as Record; +} + +/** The leaf at a dotted path, or undefined. */ +function leaf(root: Record, key: string): unknown { + let node: unknown = root; + for (const segment of key.split(".")) { + if (typeof node !== "object" || node === null) return undefined; + node = (node as Record)[segment]; + } + return node; +} + +/** The `{placeholders}` a translated sentence interpolates. */ +function placeholders(sentence: string): string[] { + return [...sentence.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort(); +} + +describe("the matcher sees what it looks for", () => { + const REFORMATTED = ` + import { requireRecordAuth } from "@/lib/api-handler"; + import { auditLog } from "@/lib/auth/audit"; + + export const DELETE = apiHandler(async () => { + const { user } = await requireRecordAuth( + "manage", + "labs", + ); + await auditLog("thing.delete", { userId: user.id }); + return apiSuccess({ id: user.id }); + }); + `; + + it("matches a resolver call broken across lines", () => { + // The shape that silenced the sibling guard: the argument moves onto its + // own line and a substring test goes quiet with nothing else changed. + expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false); + const facts = moduleFacts(REFORMATTED, "r.ts"); + expect(facts.delegatesRecord).toBe(true); + expect(facts.auditActions).toEqual(["thing.delete"]); + }); + + it("matches an aliased and a namespace-qualified resolver call", () => { + const ALIASED = ` + import { requireRecordAuth as resolveRecord } from "@/lib/api-handler"; + import { auditLog as record } from "@/lib/auth/audit"; + + export const DELETE = apiHandler(async () => { + const { user } = await resolveRecord("manage", "labs"); + await record("thing.aliased"); + return apiSuccess({ id: user.id }); + }); + `; + const facts = moduleFacts(ALIASED, "a.ts"); + expect(facts.delegatesRecord).toBe(true); + expect(facts.auditActions).toEqual(["thing.aliased"]); + + const NAMESPACED = ` + import * as auth from "@/lib/api-handler"; + + export const DELETE = auth.apiHandler(async () => { + const { user } = await auth.requireRecordAuth("manage", "labs"); + return apiSuccess({ id: user.id }); + }); + `; + expect(moduleFacts(NAMESPACED, "n.ts").delegatesRecord).toBe(true); + }); + + it("does not enrol a route that only talks about delegating", () => { + const ACTOR_ONLY = ` + /** An actor surface. It must never take requireRecordAuth("manage"). */ + import { requireActorAuth } from "@/lib/api-handler"; + + export const POST = apiHandler(async () => { + const { user } = await requireActorAuth(); + return apiSuccess({ id: user.id }); + }); + `; + // The raw text carries the exact substring a naive membership test uses. + expect(ACTOR_ONLY.includes('requireRecordAuth("manage")')).toBe(true); + expect(moduleFacts(ACTOR_ONLY, "ao.ts").delegatesRecord).toBe(false); + }); + + it("reads the fire-and-forget audit calls too", () => { + // The defect this file shipped with. `void auditLog(…)` was invisible + // here and perfectly visible in the owner's feed, so ten refusal + // breadcrumbs rendered as "made a change in your record". + const MIXED = ` + import { requireRecordAuth } from "@/lib/api-handler"; + import { auditLog } from "@/lib/auth/audit"; + + export const DELETE = apiHandler(async () => { + const { user } = await requireRecordAuth("manage", "labs"); + void auditLog("thing.breadcrumb", {}); + await auditLog("thing.delete", { userId: user.id }); + return apiSuccess({ id: user.id }); + }); + `; + expect(moduleFacts(MIXED, "m.ts").auditActions).toEqual([ + "thing.breadcrumb", + "thing.delete", + ]); + }); + + it("resolves a first-party import and skips a package one", () => { + // The other half of the same defect: two of the twelve unmapped actions + // were filed by library modules the routes import, which a walk over + // `route.ts` alone could not see. + const admission = resolveFirstParty( + "@/lib/medications/intake-import-admission", + join(API, "medications/route.ts"), + ); + expect(admission).not.toBeNull(); + expect(readFileSync(admission!, "utf8")).toContain( + "medication.intake.import.kickoff.denied", + ); + expect(resolveFirstParty("next/server", "x.ts")).toBeNull(); + expect(resolveFirstParty("@/lib/nothing-here", "x.ts")).toBeNull(); + }); + + it("still reports the fallback for an action nobody mapped", () => { + // The positive control for the recording translator below. Without it, a + // `verbKeyFor` that always returned the same string would report every + // action as specific and this file would prove nothing. + expect(verbKeyFor("nothing.anybody.mapped").key).toBe(FALLBACK_KEY); + }); +}); + +describe("every delegated action has a sentence of its own", () => { + const routes = routeFiles(API); + const cache = new Map(); + const read = (file: string): ModuleFacts => { + let facts = cache.get(file); + if (!facts) { + facts = moduleFacts(readFileSync(file, "utf8"), file); + cache.set(file, facts); + } + return facts; + }; + + const delegatedModules = routes.filter((file) => read(file).delegatesRecord); + const excluded = new Set(NOT_IN_THE_FEED); + const reachable = [ + ...new Set( + delegatedModules.flatMap((file) => actionsReachableFrom(file, read)), + ), + ].sort(); + const actions = reachable.filter((action) => !excluded.has(action)); + + it("matches the delegated modules, and not zero of them", () => { + // A walk that finds nothing agrees with an inventory that covers nothing. + expect(routes.length).toBeGreaterThan(300); + expect(delegatedModules.length).toBeGreaterThan(0); + // Named anchors, so a matcher that collapsed to one stray file fails + // rather than clearing the floor. Deleting a reading and purging a dose + // history are the two acts MANAGE exists for; the mood list is the READ + // route whose refusal breadcrumb reaches the feed, and it is here because + // the level is no longer part of the inclusion rule. + const found = delegatedModules.map((file) => relative(process.cwd(), file)); + expect(found).toContain("src/app/api/measurements/[id]/route.ts"); + expect(found).toContain( + "src/app/api/medications/[id]/intake/purge/route.ts", + ); + expect(found).toContain("src/app/api/mood-entries/route.ts"); + // And the actions themselves were read, not merely looked for. + expect(actions.length).toBeGreaterThan(60); + }); + + it("walks strictly more modules than a manage-only sweep would", () => { + // The level axis of the inclusion rule, with something that can falsify + // it. Every other leg in this file passes under BOTH rules — a manage-only + // sweep still finds every manage action, so coverage cannot tell the two + // apart. This one can: the corrected rule is "any module that reaches + // `requireRecordAuth` at any level", so the walked set must strictly + // exceed the subset that names `manage` anywhere. + const manageOnly = delegatedModules.filter((file) => + read(file).recordLevels.includes("manage"), + ); + expect(manageOnly.length).toBeGreaterThan(0); + expect(delegatedModules.length).toBeGreaterThan(manageOnly.length); + + // A named anchor, so the claim does not rest on a count that could drift + // to equality without anybody noticing. The custom-metrics list resolves + // `requireRecordAuth("read", …)` and nothing else, so a manage-only filter + // drops it — along with the refusal breadcrumbs it and its neighbours file. + const anchor = join(API, "custom-metrics/route.ts"); + expect(read(anchor).recordLevels).toEqual(["read"]); + expect(read(anchor).recordLevels).not.toContain("manage"); + expect(delegatedModules).toContain(anchor); + expect(manageOnly).not.toContain(anchor); + }); + + it("reaches the actions a route files through what it imports", () => { + // Both halves of the fix, pinned against the two that were invisible. + expect(actions).toContain("medication.intake.import.kickoff.denied"); + expect(actions).toContain("medication.oneShot.reconciled"); + // And the fire-and-forget half, on the READ route that started this. + expect(actions).toContain("mood-entries.list.validation-failed"); + }); + + it("excludes exactly the three rows the feed cannot show", () => { + // The exclusion list is the one place a real action could be hidden, so + // its size is pinned and every member is asserted to have been REACHED — + // an entry naming an action nothing files would be a claim about nothing. + expect(NOT_IN_THE_FEED).toHaveLength(3); + for (const action of NOT_IN_THE_FEED) { + expect(reachable, action).toContain(action); + } + }); + + it("gives every one of them a specific verb rather than the fallback", () => { + const generic = actions.filter( + (action) => verbKeyFor(action).key === FALLBACK_KEY, + ); + + expect( + generic, + "a delegated action with no verb line renders to the owner as the " + + "generic 'made a change'. Add a case to activity-verb.ts and a " + + "sentence to all six bundles — or, if the row genuinely should not " + + "reach the feed, stop stamping the actor on it.", + ).toEqual([]); + }); + + it("never reports a refused attempt as something that happened", () => { + // The half of the HIGH finding that a coverage count cannot express. Every + // one of these rows is an attempt that changed NOTHING, and the generic + // line told the owner their record had been edited. A future arm that maps + // `…validation-failed` onto an ordinary verb would be the same defect with + // coverage, so the shape of the key is asserted rather than its presence. + const attempts = actions.filter( + (action) => + action.endsWith(".validation-failed") || action.endsWith(".denied"), + ); + expect(attempts.length).toBeGreaterThan(10); + + const dishonest = attempts.filter( + (action) => !/\.(rejected|denied)[A-Z]/.test(verbKeyFor(action).key), + ); + expect( + dishonest, + "an attempt that was refused must not render as an act that happened", + ).toEqual([]); + + // And the sentences themselves say so, in the source language. A key named + // `rejected…` whose EN text read "added a reading" would pass the check + // above and be exactly the lie it exists to stop. + const en = bundle("en"); + for (const action of attempts) { + const sentence = leaf(en, verbKeyFor(action).key); + expect(typeof sentence, action).toBe("string"); + expect(String(sentence), action).toMatch( + /tried to|could not|was refused|search this record/, + ); + } + }); + + it("resolves every verb key in all six bundles", () => { + const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const)); + const missing: string[] = []; + let resolved = 0; + + for (const action of actions) { + const { key } = verbKeyFor(action); + for (const locale of LOCALES) { + const value = leaf(bundles.get(locale)!, key); + if (typeof value === "string" && value.length > 0) resolved += 1; + else missing.push(`${locale}: ${key}`); + } + } + + expect(missing.sort()).toEqual([]); + // Six locales for every action, and the actions were counted above. + expect(resolved).toBe(actions.length * LOCALES.length); + }); + + it("interpolates the actor's name and nothing else, in every locale", () => { + const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const)); + const leaky: string[] = []; + let checked = 0; + + for (const action of actions) { + const { key, params } = verbKeyFor(action); + // The call site's own parameters first: a sentence cannot leak a value + // the map never hands it. + if (params.join(",") !== "name") leaky.push(`${key} passes ${params}`); + for (const locale of LOCALES) { + const value = leaf(bundles.get(locale)!, key); + if (typeof value !== "string") continue; + checked += 1; + const found = placeholders(value); + if (found.length !== 1 || found[0] !== "name") { + leaky.push(`${locale}: ${key} interpolates ${found.join(", ")}`); + } + } + } + + expect(leaky.sort()).toEqual([]); + expect(checked).toBe(actions.length * LOCALES.length); + }); +}); + +describe("the retention disclosure does not depend on a race", () => { + /** + * The sentence that bounds attribution — "who entered what is answerable for + * the next N days" — used to appear only if a SECOND query had already + * answered. The revoke dialog reads the grant list; the number came off the + * activity feed. Two independent requests, and whichever lost decided + * whether the owner was told. An owner who clicked quickly, or whose + * activity read failed, ended a manage grant having read one sentence fewer + * than an owner who clicked slowly, with nothing on screen to indicate that + * a sentence was missing. + * + * The number now rides the same payload as the row: if there is a grant to + * revoke, `retentionDays` is already in hand. Deterministic means there is + * no branch left, which is what both legs below check from opposite sides — + * one that the sentence is always composed, one that the second query is + * gone. + */ + const t = (key: string, params?: Record) => + params ? `${key}(${JSON.stringify(params)})` : key; + + it("states the window on every grant that could have written something", () => { + for (const access of ["WRITE", "MANAGE"] as const) { + const body = revokeBody({ t, access, name: "Alex", retentionDays: 90 }); + expect(body).toContain("recordSharing.given.revokeEntriesStay"); + expect(body).toContain( + 'recordSharing.given.revokeAttribution({"days":90})', + ); + } + // READ keeps neither: a read grant never put anything in the record, so + // saying what happens to what they entered would describe an act that + // cannot have occurred. + const read = revokeBody({ + t, + access: "READ", + name: "Alex", + retentionDays: 90, + }); + expect(read).not.toContain("recordSharing.given.revokeAttribution"); + }); + + it("takes the number from the read that produced the row", () => { + const source = readFileSync(GRANTS_GIVEN_CARD, "utf8"); + // Positive control: the matcher can see a hook name in this file at all. + expect(source.match(/useAccountGrants/g)?.length ?? 0).toBeGreaterThan(0); + // And the second query, whose arrival used to decide the copy, is gone. + expect(source).not.toContain("useRecordActivity"); + // The branch it fed is gone with it. A required number cannot be absent, + // so there is nothing left for a caller to omit — which is the difference + // between "the sentence is usually there" and "there is no path without + // it". Both halves are named: the optional marker and the guard. + expect(source).not.toContain("retentionDays?:"); + expect(source).not.toContain("retentionDays !== undefined"); + expect(source.match(/retentionDays: number/g)?.length ?? 0).toBe(1); + }); +}); + +describe("the activity feed carries no audit detail", () => { + /** + * The DTO the owner's feed publishes, read off the route. + * + * A field-level assertion rather than a sentence in a comment, because the + * pressure to widen it is real and constant: every thin sentence in the feed + * looks like it wants the value that would make it specific, and the audit + * row usually has one. `details` is a free-form JSON column written by sixty + * handlers; publishing it would put whatever any of them decided to file in + * front of the record owner, and some of them file a label. + */ + it("publishes five fields, and `details` is not one of them", () => { + const source = readFileSync(ACTIVITY_ROUTE, "utf8"); + const parsed = ts.createSourceFile( + ACTIVITY_ROUTE, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ); + + let members: string[] | null = null; + parsed.forEachChild((node) => { + if (!ts.isInterfaceDeclaration(node)) return; + if (node.name.text !== "RecordActivityEntry") return; + members = node.members + .map((m) => (m.name && ts.isIdentifier(m.name) ? m.name.text : "")) + .filter((n) => n.length > 0) + .sort(); + }); + + // The interface was found at all. `toEqual` against null would report the + // absence as a mismatch of the list, which reads like the wrong failure. + expect( + members, + "RecordActivityEntry not found in the route", + ).not.toBeNull(); + expect(members).toEqual(["accesses", "action", "actor", "createdAt", "id"]); + }); +}); diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts new file mode 100644 index 000000000..5ef5aef9f --- /dev/null +++ b/src/__tests__/managed-profile-contract.test.ts @@ -0,0 +1,159 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { openApiPaths } from "@/lib/openapi/routes"; + +const root = process.cwd(); + +/** Every `route.ts` under the API tree. */ +async function apiRouteFiles(): Promise { + const entries = await readdir(path.join(root, "src/app/api"), { + recursive: true, + }); + return entries + .filter((entry) => entry.endsWith("route.ts")) + .map((entry) => path.join("src/app/api", entry)); +} + +/** The published path a route file serves, or null when it is unpublished. */ +function publishedPathFor(file: string): string | null { + const apiPath = file + .replace(/^src\/app/, "") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); + return apiPath in openApiPaths ? apiPath : null; +} + +describe("managed-profile contract", () => { + it("publishes the last-Guardian refusal on every published route that emits it", async () => { + // The failure this catches is the one the release audit found: the floor + // is enforced on four surfaces and was DESCRIBED on the one route that + // cannot reach it, while two routes that answer it every time said only + // "that access had already ended". Emitters are discovered from the route + // sources, so a fifth one cannot ship undescribed. + const files = await apiRouteFiles(); + expect(files.length).toBeGreaterThan(100); + + const emitters: string[] = []; + for (const file of files) { + const source = await readFile(path.join(root, file), "utf8"); + if (!source.includes("LastManagedGuardianError")) continue; + if (!source.includes("managed_profile.guardian.required")) continue; + emitters.push(file); + } + // Non-zero, and known: the two grant routes. The lifecycle surfaces that + // also raise it (account deletion, the data wipe) answer through their own + // envelopes and are not in this spec. + expect(emitters).toContain("src/app/api/account/grants/[id]/route.ts"); + expect(emitters).toContain( + "src/app/api/account/grants/[id]/renounce/route.ts", + ); + + const silent = emitters.filter((file) => { + const apiPath = publishedPathFor(file); + if (apiPath === null) return false; + return !JSON.stringify(openApiPaths[apiPath]).includes( + "managed_profile.guardian.required", + ); + }); + expect( + silent, + "published routes that emit the last-Guardian refusal without publishing it", + ).toEqual([]); + }); + + it("requires a cookie-backed fresh MFA proof for creation", async () => { + const source = await readFile( + path.join(root, "src/app/api/managed-profiles/route.ts"), + "utf8", + ); + + expect(source).toContain("requireFreshMfa"); + expect(source).not.toContain("requireFreshMfaIfEnrolled"); + }); + + it("requires a cookie-backed fresh MFA proof for profile deletion", async () => { + const source = await readFile( + path.join(root, "src/app/api/managed-profiles/[id]/route.ts"), + "utf8", + ); + + expect(source).toContain("requireFreshMfa"); + expect(source).not.toContain("requireFreshMfaIfEnrolled"); + }); + + it("publishes no Guardian refusal a request cannot produce", async () => { + // `managed_profile.guardian.self` was written, shipped and never + // reachable: `self_grant` needs the grantor to equal the grantee, and the + // grantor of a Guardian grant is the PROFILE. Inviting yourself lands on + // the duplicate refusal; naming the profile is refused a step earlier as + // `managed_grantee`. Both doors are asserted in + // `tests/integration/managed-profile-surface.test.ts`, so the arm was + // known to be dead rather than assumed to be, and it was removed rather + // than frozen into the published contract. + const route = await readFile( + path.join(root, "src/app/api/managed-profiles/[id]/guardians/route.ts"), + "utf8", + ); + const spec = await readFile( + path.join(root, "docs/api/openapi.yaml"), + "utf8", + ); + + // Non-zero control: the codes that ARE reachable are still named, so this + // cannot pass by matching an empty file or a renamed family. + expect(route).toContain("managed_profile.guardian.duplicate"); + expect(route).toContain("managed_profile.guardian.managed_invitee"); + expect(route).not.toContain('errorCode: "managed_profile.guardian.self"'); + expect(spec).not.toContain("managed_profile.guardian.self"); + }); + + it("does not publish an emancipation API surface", async () => { + const source = await readFile( + path.join(root, "prisma/schema.prisma"), + "utf8", + ); + + expect(source.toLowerCase()).not.toContain("emancipation"); + + const routes = await readdir( + path.join(root, "src/app/api/managed-profiles"), + { recursive: true }, + ); + expect( + routes.some((entry) => entry.toLowerCase().includes("emancip")), + ).toBe(false); + }); + + it("does not claim notification fanout before its consent plan exists", async () => { + const source = await readFile( + path.join(root, "prisma/schema.prisma"), + "utf8", + ); + + expect(source).not.toContain("notification dispatcher fans reminders"); + expect(source).toContain("later notification plan"); + }); + + it("uses the same record lock for every Guardian-reducing transition", async () => { + const lifecycle = await readFile( + path.join(root, "src/lib/managed-profiles/lifecycle.ts"), + "utf8", + ); + const grants = await readFile( + path.join(root, "src/lib/sharing/grants.ts"), + "utf8", + ); + const accountDeletion = await readFile( + path.join(root, "src/app/api/settings/account/route.ts"), + "utf8", + ); + + expect(lifecycle).toContain("pg_advisory_xact_lock"); + expect(lifecycle).toContain("clearManagedProfileMarker"); + expect(lifecycle).toContain("deleteManagedProfile"); + expect(grants).toContain("reduceManagedProfileGuardian"); + expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle"); + }); +}); diff --git a/src/__tests__/managed-record-navigation.test.tsx b/src/__tests__/managed-record-navigation.test.tsx new file mode 100644 index 000000000..5f8fd08d7 --- /dev/null +++ b/src/__tests__/managed-record-navigation.test.tsx @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { classifySettingsDestination } from "@/lib/record-settings"; + +const pathnameRef = { value: "/settings/security" }; +const activeRecordRef = { value: true }; +/** The record on screen. `managed` unless a case says otherwise. */ +const recordRef: { value: { kind: "managed" | "shared"; level: string } } = { + value: { kind: "managed", level: "manage" }, +}; + +vi.mock("next/navigation", () => ({ + usePathname: () => pathnameRef.value, +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: activeRecordRef.value + ? { + id: "guardian", + modules: undefined, + accountAccess: { + active: { + accountId: "managed-record", + username: "managed-record", + displayName: "Managed record", + access: "write", + level: recordRef.value.level, + recordKind: recordRef.value.kind, + sections: null, + canWrite: true, + }, + accounts: [], + canSwitch: true, + }, + } + : null, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +import { + SETTINGS_SECTIONS, + SettingsShell, +} from "@/components/settings/settings-shell"; +import { SharedRecordBanner } from "@/components/layout/shared-record-banner"; + +function renderSettings(pathname = "/settings/security") { + pathnameRef.value = pathname; + return renderToStaticMarkup( + + +
managed settings content
+
+
, + ); +} + +/** + * The destinations a record offers, by what kind of record it is. + * + * v1.37.0 — a managed profile offers guardian configuration AND the record + * content a MANAGE holder may write, because a Guardian holds MANAGE. The + * `manage-writable` kind is what carries the second half; before it existed + * the anamnesis form had no page a Guardian could open, while the routes + * behind it had admitted the write since the level shipped. + */ +const MANAGED_KINDS = new Set(["managed-guardian", "manage-writable"]); + +describe("managed record navigation", () => { + it("shows only managed settings destinations while retaining an unavailable deep-link heading", () => { + recordRef.value = { kind: "managed", level: "manage" }; + const html = renderSettings(); + const managedDestinations = SETTINGS_SECTIONS.filter((section) => + MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind), + ); + const unavailableDestinations = SETTINGS_SECTIONS.filter( + (section) => + !MANAGED_KINDS.has(classifySettingsDestination(section.slug).kind), + ); + + // The partition is non-empty on both sides. A classification change that + // emptied either would make one of the loops below prove nothing. + expect(managedDestinations.length).toBeGreaterThan(1); + expect(unavailableDestinations.length).toBeGreaterThan(1); + // And the destination this release opened is really on the offered side. + expect(managedDestinations.map((s) => s.slug)).toContain("anamnesis"); + + expect(html).toContain('data-active-record-id="managed-record"'); + expect(html).toContain('id="settings-section-security-title"'); + expect(html).not.toMatch(/href="[^"]*managed-record/); + + for (const section of managedDestinations) { + expect( + html.match(new RegExp(`href="/settings/${section.slug}"`, "g")) + ?.length ?? 0, + ).toBe(2); + } + + for (const section of unavailableDestinations) { + expect(html).not.toContain(`href="/settings/${section.slug}"`); + } + }); + + it("offers an adult manager the record content and none of the configuration", () => { + // The other half of the same classification, and the half that is easiest + // to get wrong in the generous direction: a delegate manages somebody's + // health record, not their account. Anamnese is offered; modules, + // thresholds and notification routing are not, even though a Guardian + // reaches all three. + recordRef.value = { kind: "shared", level: "manage" }; + const html = renderSettings(); + + expect(html).toContain('href="/settings/anamnesis"'); + for (const guardianOnly of [ + "account", + "modules", + "notifications", + "thresholds", + "coach", + ]) { + expect(html, guardianOnly).not.toContain( + `href="/settings/${guardianOnly}"`, + ); + } + }); + + it("offers a WRITE delegate no settings destination at all", () => { + // The refusal side. `manage-writable` is admitted by LEVEL, not by being + // in a shared record: a WRITE grant reaches no Settings page, and a + // destination it could see would be one the route below refuses. + recordRef.value = { kind: "shared", level: "write" }; + const html = renderSettings(); + + expect(html).not.toContain('href="/settings/anamnesis"'); + for (const section of SETTINGS_SECTIONS) { + expect(html, section.slug).not.toContain( + `href="/settings/${section.slug}"`, + ); + } + }); + + it("offers a managed entry below MANAGE nothing at all", () => { + // A guardian grant is always MANAGE today, which is exactly why the level + // check was easy to leave out of the managed branch. Without it a managed + // entry that ever arrived below MANAGE would list destinations the section + // gate refuses, sending somebody to a page that explains it cannot open. + recordRef.value = { kind: "managed", level: "read" }; + const html = renderSettings(); + + for (const section of SETTINGS_SECTIONS) { + expect(html, section.slug).not.toContain( + `href="/settings/${section.slug}"`, + ); + } + }); + + it("keeps the exit control present while administering a managed record", () => { + recordRef.value = { kind: "managed", level: "manage" }; + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain('data-account-id="managed-record"'); + expect(html).toContain('data-slot="shared-record-banner-exit"'); + }); +}); diff --git a/src/__tests__/notification-delivery-identity.test.ts b/src/__tests__/notification-delivery-identity.test.ts new file mode 100644 index 000000000..19698e453 --- /dev/null +++ b/src/__tests__/notification-delivery-identity.test.ts @@ -0,0 +1,419 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const transactionMock = vi.fn(); +const authorizationClaimQueryMock = vi.fn(); +const authorizationUpdateMock = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + notificationChannel: { + findMany: vi.fn(), + update: vi.fn(), + upsert: vi.fn(), + }, + user: { findUnique: vi.fn() }, + accountGrant: { findMany: vi.fn() }, + notificationEgressAuthorization: { + update: (...args: unknown[]) => authorizationUpdateMock(...args), + }, + auditLog: { create: vi.fn() }, + $transaction: (...args: unknown[]) => transactionMock(...args), + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/crypto", () => ({ + decrypt: (value: string) => value, + encrypt: (value: string) => value, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ + addWarning: vi.fn(), + addMeta: vi.fn(), + }), +})); + +const sendViaWebPushMock = vi.fn(); +const sendViaNtfyMock = vi.fn(); +const sendViaApnsMock = vi.fn(); + +vi.mock("@/lib/notifications/senders/apns", () => ({ + sendViaApns: (...args: unknown[]) => sendViaApnsMock(...args), +})); +vi.mock("@/lib/notifications/senders/email", () => ({ + sendViaEmail: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/ntfy", () => ({ + sendViaNtfy: (...args: unknown[]) => sendViaNtfyMock(...args), +})); +vi.mock("@/lib/notifications/senders/telegram", () => ({ + sendViaTelegram: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/web-push", () => ({ + sendViaWebPush: (...args: unknown[]) => sendViaWebPushMock(...args), +})); +vi.mock("@/lib/notifications/senders/webhook", () => ({ + sendViaWebhook: vi.fn(), +})); +vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({ + recordPushAttempt: vi.fn(), +})); + +import { prisma } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications/dispatcher"; + +const recordUserId = "managed-record"; +const recipientUserId = "guardian-recipient"; + +beforeEach(() => { + vi.resetAllMocks(); + authorizationClaimQueryMock.mockResolvedValue([{ id: "authorization" }]); + authorizationUpdateMock.mockResolvedValue({ id: "authorization" }); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + managedProfileAt: new Date(), + } as never); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "recipient-channel", + userId: recipientUserId, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never); + vi.mocked(prisma.notificationChannel.update).mockResolvedValue({ + consecutiveFailures: 0, + } as never); + transactionMock.mockImplementation( + async (operation: (tx: unknown) => Promise) => + operation({ + $queryRaw: (...args: unknown[]) => authorizationClaimQueryMock(...args), + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + where.id === recordUserId + ? { managedProfileAt: new Date() } + : { managedProfileAt: null }, + ), + }, + accountGrant: { findFirst: vi.fn(async () => ({ id: "grant" })) }, + }), + ); + sendViaWebPushMock.mockResolvedValue({ ok: true }); + sendViaNtfyMock.mockResolvedValue({ ok: true }); + sendViaApnsMock.mockResolvedValue({ ok: true }); +}); + +describe("notification delivery identity", () => { + it("principal resolution uses the recipient channel for a managed record", async () => { + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: true, + channelsAttempted: 1, + channelsSucceeded: 1, + }); + expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: recipientUserId, enabled: true }, + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledWith( + recipientUserId, + expect.objectContaining({ + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }), + ); + // Preflight chooses recipient channels, then a short locked transaction + // records the final authorization claim before provider I/O starts. + expect(transactionMock).toHaveBeenCalledTimes(2); + expect(transactionMock).toHaveBeenLastCalledWith(expect.any(Function)); + expect(authorizationClaimQueryMock).toHaveBeenCalledTimes(3); + expect(authorizationUpdateMock).toHaveBeenCalledWith({ + where: { id: "authorization" }, + data: { completedAt: expect.any(Date), outcome: "ok" }, + }); + }); + + it("does not invoke a provider when final Guardian authorization is revoked", async () => { + let transactionCount = 0; + transactionMock.mockImplementation( + async (operation: (tx: unknown) => Promise) => { + transactionCount += 1; + return operation({ + $queryRaw: (...args: unknown[]) => + authorizationClaimQueryMock(...args), + user: { + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + where.id === recordUserId + ? { managedProfileAt: new Date() } + : { managedProfileAt: null }, + ), + }, + accountGrant: { + findFirst: vi.fn(async () => + transactionCount === 1 ? { id: "grant" } : null, + ), + }, + }); + }, + ); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(authorizationClaimQueryMock).toHaveBeenCalledTimes(2); + expect(sendViaWebPushMock).not.toHaveBeenCalled(); + }); + + it("records a provider timeout on the claim without retaining the lifecycle lock", async () => { + sendViaWebPushMock.mockRejectedValue(new Error("provider timeout")); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 1, + channelsSucceeded: 0, + }); + expect(authorizationUpdateMock).toHaveBeenCalledWith({ + where: { id: "authorization" }, + data: { completedAt: expect.any(Date), outcome: "sender_threw" }, + }); + }); + + it("refuses a managed record without an explicit recipient", async () => { + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled(); + expect(sendViaWebPushMock).not.toHaveBeenCalled(); + }); + + it("uses the recipient preference without changing record content", async () => { + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "recipient-channel", + userId: recipientUserId, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + ] as never); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + recordUserId, + recipientUserId, + title: "Record content", + message: "Record schedule", + }); + + expect(outcome).toEqual({ + dispatched: false, + channelsAttempted: 0, + channelsSucceeded: 0, + }); + expect(prisma.notificationChannel.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: recipientUserId, enabled: true }, + }), + ); + expect(sendViaWebPushMock).not.toHaveBeenCalled(); + }); + + it("renders for each Guardian while isolating channel preferences", async () => { + const firstGuardian = "guardian-en"; + const secondGuardian = "guardian-de"; + + ( + prisma.user.findUnique as unknown as ReturnType + ).mockImplementation(async ({ where }: { where: { id: string } }) => { + if (where.id === recordUserId) { + return { managedProfileAt: new Date() } as never; + } + if (where.id === firstGuardian) { + return { + managedProfileAt: null, + locale: "en", + notificationPrefs: { medication: { clientManaged: true } }, + } as never; + } + return { + managedProfileAt: null, + locale: "de", + notificationPrefs: { medication: { clientManaged: true } }, + } as never; + }); + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { granteeId: firstGuardian, grantee: { managedProfileAt: null } }, + { granteeId: secondGuardian, grantee: { managedProfileAt: null } }, + ] as never); + ( + prisma.notificationChannel.findMany as unknown as ReturnType + ).mockImplementation(async ({ where }: { where: { userId: string } }) => { + if (where.userId === firstGuardian) { + return [ + { + id: "first-web-disabled", + userId: firstGuardian, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + { + id: "first-ntfy-enabled", + userId: firstGuardian, + type: "NTFY", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never; + } + return [ + { + id: "second-web-enabled", + userId: secondGuardian, + type: "WEB_PUSH", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + { + id: "second-ntfy-disabled", + userId: secondGuardian, + type: "NTFY", + config: "{}", + nextRetryAt: null, + preferences: [{ eventType: "MEDICATION_REMINDER", enabled: false }], + }, + ] as never; + }); + + const outcome = await dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "record-language-title", + message: "record-language-message", + renderForRecipient: (locale) => ({ + title: `title-${locale}`, + message: `message-${locale}`, + }), + }); + + expect(outcome).toEqual({ + dispatched: true, + channelsAttempted: 2, + channelsSucceeded: 2, + }); + expect(sendViaNtfyMock).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + recipientUserId: firstGuardian, + title: "title-en", + message: "message-en", + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledWith( + secondGuardian, + expect.objectContaining({ + recipientUserId: secondGuardian, + title: "title-de", + message: "message-de", + }), + ); + expect(sendViaWebPushMock).toHaveBeenCalledTimes(1); + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + }); + + it("bypasses client-managed APNs suppression only for managed Guardian delivery", async () => { + const guardian = "guardian-apns"; + ( + prisma.user.findUnique as unknown as ReturnType + ).mockImplementation( + async ({ where }: { where: { id: string } }) => + (where.id === recordUserId + ? { managedProfileAt: new Date() } + : { + managedProfileAt: null, + notificationPrefs: { medication: { clientManaged: true } }, + }) as never, + ); + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + { granteeId: guardian, grantee: { managedProfileAt: null } }, + ] as never); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([ + { + id: "guardian-apns", + userId: guardian, + type: "APNS", + config: "{}", + nextRetryAt: null, + preferences: [], + }, + ] as never); + + await expect( + dispatchNotification({ + eventType: "MEDICATION_REMINDER", + userId: recordUserId, + title: "Record content", + message: "Record schedule", + }), + ).resolves.toMatchObject({ dispatched: true, channelsSucceeded: 1 }); + + expect(sendViaApnsMock).toHaveBeenCalledWith( + guardian, + expect.objectContaining({ + recordUserId, + recipientUserId: guardian, + }), + ); + }); +}); diff --git a/src/__tests__/openapi-account-sharing-components.test.ts b/src/__tests__/openapi-account-sharing-components.test.ts new file mode 100644 index 000000000..f50de3d7a --- /dev/null +++ b/src/__tests__/openapi-account-sharing-components.test.ts @@ -0,0 +1,98 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { buildOpenApiDocument } from "@/lib/openapi/registry"; + +/** + * Every schema id the sharing route table declares reaches the published + * document. + * + * The failure this exists for looks like nothing at all. `.meta()` in Zod 4 is + * immutable: it returns a NEW schema carrying the metadata and registers that + * one, so `schema.meta({ id, description })` written as a statement registers + * an instance no route references. The generator then emits the request body + * inline and anonymous, the id never becomes a component, and the object-level + * description never leaves the source file. Nothing throws, nothing drifts, + * `openapi:check` stays green, and the sentence somebody wrote for the client + * team is readable only by someone already reading the code. + * + * Four of the sharing bodies were written that way, and one of them carried the + * clause scoping every rule on the invitation endpoint to an INVITED grant — + * the exact sentence a reader needs in order not to carry the step-up and the + * refusals across to a Guardian grant. It was found by reading the artifact + * instead of the source, which is not a repeatable way to find things. + * + * ## What is checked + * + * The ids are read out of the route table's own text rather than listed here, + * so a body added tomorrow is covered without anybody remembering this file. + * The set is asserted non-empty first: a matcher that silently stopped matching + * would otherwise report a green run over nothing, which is the same class of + * defect as the one under test. + * + * The document is BUILT rather than read off disk. `scripts/check-openapi.ts` + * already pins `docs/api/openapi.yaml` to exactly this document and CI fails on + * drift, so building it here checks the same artifact without parsing a + * megabyte of YAML. + * + * ## What it cannot see + * + * Only this route module. The idiom is long-standing across the registry and + * fifty-seven other ids are declared the same way in modules this file does not + * read; widening the sweep is a separate change with a separate diff, recorded + * in the post-release inventory. + */ +const ROUTE_TABLE = join( + process.cwd(), + "src/lib/openapi/routes/account-sharing.ts", +); + +/** Every `id: "…"` the route table declares, in source order. */ +function declaredIds(): string[] { + const source = readFileSync(ROUTE_TABLE, "utf8"); + return [...source.matchAll(/\bid:\s*"([A-Za-z0-9_]+)"/g)].map( + (match) => match[1]!, + ); +} + +describe("the sharing route table's schema ids reach the document", () => { + const ids = declaredIds(); + const schemas = buildOpenApiDocument().components?.schemas ?? {}; + + it("has ids to check", () => { + // The non-zero control. Rename the file, change the quoting style, drop the + // `id` key convention, and this leg fails instead of the file passing + // vacuously. + expect(ids.length).toBeGreaterThan(10); + expect(ids).toContain("AccountGrantInvite"); + expect(ids).toContain("CreateManagedProfileRequest"); + expect(ids).toContain("InviteManagedProfileGuardianRequest"); + expect(ids).toContain("AccountSwitchRequest"); + }); + + it("publishes every declared id as a named component", () => { + const missing = ids.filter((id) => !(id in schemas)); + expect(missing).toEqual([]); + }); + + it("publishes the invited-grant scoping clause with the request body", () => { + // The sentence that was lost, checked where a client would read it rather + // than where it was written. + const invite = schemas["AccountGrantInvite"] as + { description?: string } | undefined; + expect(invite?.description ?? "").toMatch( + /Every sentence here describes an INVITED grant on this endpoint/, + ); + // And the boundary it states is the corrected one. The stale wording denied + // every settings surface, which stopped being true when the anamnesis + // surface opened to a manage delegate. + expect(invite?.description ?? "").not.toMatch( + /never reaches[^.]*\bsettings\b/i, + ); + expect(invite?.description ?? "").toMatch( + /record content versus account configuration/i, + ); + }); +}); diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts new file mode 100644 index 000000000..c96b35114 --- /dev/null +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -0,0 +1,335 @@ +/** + * v1.37.0 — one refusal, on every path a delegate can reach. + * + * A route that resolves through `requireRecordAuth` / `requireGuardianAuth` can + * be asked to act on somebody else's record, and can therefore answer 403 + * `sharing.access.denied`. Before this file that fact was published once, on the + * per-request selector parameter, and on none of the hundred and twenty + * operations that produce it: a client reading the spec for `/api/measurements` + * learned that the header existed somewhere and nothing about what the read + * does when the grant behind it ends. This guard closes the pair — the set of + * fenced operations is recomputed from the route sources on every run, and + * every member has to publish the same sentence, byte for byte. + * + * ## Byte-identical, and why it is the point + * + * The refusal carries no reason on the wire. A selector naming an account that + * does not exist, one naming an account that granted nothing, a grant whose + * sections do not reach the surface, and a read grant on a write — all four are + * the same status, the same code and the same bytes, because a distinguishable + * refusal is an account-enumeration oracle for anyone with a login. A contract + * that described them differently per path would invite a client to tell them + * apart, and the first client that tried would be reading tea leaves. So the + * description is ONE string, spliced into every fenced operation, and a + * paraphrase on one path fails here. + * + * ## What this guard deliberately does not do + * + * - It classifies per VERB, not per file, because the two disagree on + * seventeen route modules: `GET /api/dashboard/widgets` is delegable and its + * `PUT` is not. The matcher takes each handler's own top-level declaration + * and follows every declaration in the same module it names, transitively — + * which is how `POST /api/custom-metrics` (body in `postCustomMetric`) and + * `POST /api/cycle/day-logs` (body in `postDayLog`, wrapped in + * `withIdempotency` and declared BELOW the `GET`) are classified correctly. A + * first draft that segmented on export boundaries and matched call sites got + * both of those wrong, in both directions. + * - It resolves within the module only. A handler whose fence lived in an + * imported helper would read as unfenced; `sharing-surface-guard.test.ts` is + * what holds the declaration to the route module itself. + * - It says nothing about the verbs that are NOT fenced. Those refuse a + * switched caller with `sharing.not_permitted` — a different fact, published + * today only where the sharing family itself documents it. Naming it on every + * authenticated path in the application is a larger contract change than a + * freeze should make. + * - Comments are stripped before matching. `.../guardians/route.ts` names both + * fence helpers in a docblock explaining why it uses neither, and counting + * prose would classify an actor surface as delegable. + * - It covers the PUBLISHED surface, which is not the whole delegable surface. + * 116 route modules resolve the fence; 90 of them are in the OpenAPI document + * and 26 have never been (mood entries, the record-settings tree, the + * medication intake tree, several insight reads). That gap predates this + * release and is not closed here — "frozen" means the published contract is + * complete and consistent, not that every delegable route is described. What + * this guard does guarantee is that one of those 26 cannot be published + * without its refusal, because publishing it puts it in scope on the next run. + */ +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { openApiPaths } from "@/lib/openapi/routes"; +import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared"; + +const ROOT = process.cwd(); +const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const; +type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** A published operation, resolved to the module that serves it. */ +interface PublishedOperation { + path: string; + method: HttpMethod; + /** The operation object as the route table declares it. */ + operation: Record; + /** The handler's own declaration plus every module-local one it names. */ + handlerSource: string; +} + +function stripComments(text: string): string { + return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); +} + +function routeModulePath(apiPath: string): string { + return join( + ROOT, + "src/app", + apiPath.replace(/\{([^}]+)\}/g, "[$1]"), + "route.ts", + ); +} + +/** + * Every top-level declaration in a module, by name. + * + * The unit is the declaration and not the line range between two exports, + * because a route module puts helpers wherever it likes: `cycle/day-logs` + * declares `postDayLog` BELOW its `GET`, so a naive "from this export to the + * next" slice hands GET a body that belongs to POST. + */ +function topLevelChunks(source: string): Map { + const declaration = + /^(?:export\s+)?(?:async\s+function|function|const|let|class)\s+([A-Za-z0-9_$]+)/gm; + const found = [...source.matchAll(declaration)]; + const chunks = new Map(); + found.forEach((match, index) => { + const from = match.index ?? 0; + const to = found[index + 1]?.index ?? source.length; + chunks.set(match[1], source.slice(from, to)); + }); + return chunks; +} + +/** + * The text a handler can reach: its own declaration plus every top-level + * declaration in the same module it names, transitively. + * + * Both indirections in the tree need this. `apiHandler(postCustomMetric)` + * names its body as a bare reference, and + * `apiHandler(withIdempotency<[NextRequest]>(postDayLog))` names it as an + * argument to a wrapper — neither is a call site, so a matcher keyed on + * `name(` finds nothing and the handler reads as unfenced. It resolves within + * the module only: a fence resolved in an imported helper would be missed, and + * `sharing-surface-guard.test.ts` is what holds the declaration to the route + * module. + */ +function handlerText(source: string, method: HttpMethod): string { + const chunks = topLevelChunks(source); + const own = chunks.get(method.toUpperCase()); + if (!own) return ""; + + const seen = new Set([method.toUpperCase()]); + let text = own; + for (let depth = 0; depth < 5; depth += 1) { + const named = [...text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\b/g)].map( + (match) => match[1], + ); + const next = named.filter((name) => chunks.has(name) && !seen.has(name)); + if (next.length === 0) break; + for (const name of next) { + seen.add(name); + text += chunks.get(name); + } + } + return text; +} + +function publishedOperations(): PublishedOperation[] { + const operations: PublishedOperation[] = []; + const missing: string[] = []; + for (const [path, item] of Object.entries(openApiPaths)) { + const modulePath = routeModulePath(path); + if (!existsSync(modulePath)) { + missing.push(path); + continue; + } + const source = stripComments(readFileSync(modulePath, "utf8")); + for (const method of HTTP_METHODS) { + const operation = (item as Record)[method]; + if (!operation || typeof operation !== "object") continue; + operations.push({ + path, + method, + operation: operation as Record, + handlerSource: handlerText(source, method), + }); + } + } + // A published path with no route module would silently drop out of every + // assertion below, so it fails here instead. + expect(missing, "published paths with no route module").toEqual([]); + return operations; +} + +/** Does this handler resolve the record fence? */ +function isFenced(operation: PublishedOperation): boolean { + return /require(?:Record|Guardian)Auth\s*\(/.test(operation.handlerSource); +} + +function refusal( + operation: PublishedOperation, +): { description?: string; content?: unknown } | null { + const responses = operation.operation.responses as + Record | undefined; + return responses?.["403"] ?? null; +} + +const operations = publishedOperations(); +const fenced = operations.filter(isFenced); +const unfenced = operations.filter((operation) => !isFenced(operation)); +const name = (operation: PublishedOperation) => + `${operation.method.toUpperCase()} ${operation.path}`; + +describe("the sharing refusal every delegable path publishes", () => { + it("discovers a real set of fenced operations", () => { + // Non-zero, and anchored at both ends: a matcher that stopped matching + // would leave every assertion below vacuously true. + expect(operations.length).toBeGreaterThan(200); + expect(fenced.length).toBeGreaterThan(100); + + const fencedNames = fenced.map(name); + expect(fencedNames).toContain("GET /api/measurements"); + expect(fencedNames).toContain("POST /api/labs"); + expect(fencedNames).toContain("GET /api/dashboard/widgets"); + + const unfencedNames = unfenced.map(name); + // The other half of the same module: `PUT` and `DELETE` on the widget + // layout are the caller's own settings and refuse a switch outright. + expect(unfencedNames).toContain("PUT /api/dashboard/widgets"); + expect(unfencedNames).toContain("DELETE /api/dashboard/widgets"); + // An actor surface, and a route whose docblock names both fence helpers + // while calling neither. + expect(unfencedNames).toContain("GET /api/auth/me"); + expect(unfencedNames).toContain("GET /api/managed-profiles/{id}/guardians"); + }); + + it("publishes the refusal on every fenced operation", () => { + const silent = fenced.filter((operation) => { + const response = refusal(operation); + return ( + !response || + !response.description?.includes(SHARING_ACCESS_DENIED_DESCRIPTION) + ); + }); + + if (silent.length > 0) { + throw new Error( + `${silent.length} delegable operation(s) publish no sharing refusal:\n` + + silent.map((operation) => ` ❌ ${name(operation)}`).join("\n") + + "\n\nSpread `...recordRefusal()` into the operation's responses — or " + + "`...recordRefusal()` when it already answers 403 " + + "for a module gate or a consent gate.", + ); + } + }); + + it("says it the same way every time", () => { + // One string, not ninety paraphrases. Collected from the published objects + // rather than asserted against the constant, so a hand-written copy that + // differs by a comma is what this catches. + const sentences = new Set( + fenced.map((operation) => { + const description = refusal(operation)?.description ?? ""; + const at = description.indexOf(SHARING_ACCESS_DENIED_DESCRIPTION); + return description.slice(at); + }), + ); + expect(sentences.size).toBe(1); + expect([...sentences][0]).toBe(SHARING_ACCESS_DENIED_DESCRIPTION); + + // What the sentence has to carry: the code a client branches on, and the + // indistinguishability that keeps it from being an enumeration oracle. + expect(SHARING_ACCESS_DENIED_DESCRIPTION).toContain( + "sharing.access.denied", + ); + expect(SHARING_ACCESS_DENIED_DESCRIPTION.length).toBeGreaterThan(200); + }); + + it("keeps it off the paths that cannot answer with it", () => { + const overreaching = unfenced.filter((operation) => + refusal(operation)?.description?.includes( + SHARING_ACCESS_DENIED_DESCRIPTION, + ), + ); + expect(overreaching.map(name)).toEqual([]); + }); + + it("draws only from the reviewed delegable allowlists", () => { + // Containment, not equality, and per FILE rather than per verb — which is + // how `sharing-surface-guard.test.ts` keys its lists, because the + // declaration a human reviewed is "this module may act on another record", + // with the verb-level detail in the entry's prose beside it. + // + // What this closes: the guard above recomputes the delegable set from the + // route sources, so a route that started resolving the fence would be + // discovered and dressed with a refusal automatically — published as + // delegable without anybody deciding it should be. Held to the reviewed + // lists, that becomes two failures instead of a silent contract change. + const guard = readFileSync( + join(ROOT, "src/__tests__/sharing-surface-guard.test.ts"), + "utf8", + ); + const allowlist = (name: string): string[] => { + const start = guard.indexOf(`const ${name}`); + expect( + start, + `${name} is missing from the surface guard`, + ).toBeGreaterThan(-1); + const block = guard.slice(start, guard.indexOf("\n};", start)); + const keys = [...block.matchAll(/^ {2}"(app\/[^"]+)":/gm)].map( + (match) => match[1], + ); + // A block that matched nothing would make the union vacuous and the + // containment below unfalsifiable. + expect(keys.length, name).toBeGreaterThan(0); + return keys; + }; + + const reviewed = new Set([ + ...allowlist("DELEGABLE_ROUTES"), + ...allowlist("DELEGABLE_MANAGE_ROUTES"), + ...allowlist("GUARDIAN_ROUTES"), + ]); + expect(reviewed.size).toBeGreaterThan(50); + + const contributing = new Set( + fenced.map((operation) => + routeModulePath(operation.path) + .slice(join(ROOT, "src/").length) + .replaceAll("\\", "/"), + ), + ); + expect(contributing.size).toBeGreaterThan(50); + + const unreviewed = [...contributing].filter((file) => !reviewed.has(file)); + if (unreviewed.length > 0) { + throw new Error( + `${unreviewed.length} published operation(s) resolve the record fence from a module the surface guard has not reviewed:\n` + + unreviewed.map((file) => ` ❌ src/${file}`).join("\n") + + "\n\nA module becomes delegable by decision, not by discovery: add it " + + "to the matching list in sharing-surface-guard.test.ts with the " + + "reason, or take the fence back out.", + ); + } + }); + + it("carries the refusal into the generated document", () => { + const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); + expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60)); + // The emitter aliases identical response objects, so the sentence appears + // once per DISTINCT composition rather than once per path. Both ends are + // checked: the text is there, and the paths that reference it outnumber it. + const aliases = spec.match(/"403": \*/g)?.length ?? 0; + expect(aliases).toBeGreaterThan(fenced.length - 10); + }); +}); diff --git a/src/__tests__/published-env-reaches-the-container.test.ts b/src/__tests__/published-env-reaches-the-container.test.ts new file mode 100644 index 000000000..3847c4a0f --- /dev/null +++ b/src/__tests__/published-env-reaches-the-container.test.ts @@ -0,0 +1,133 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * An environment variable the product SHOWS a user has to reach the container. + * + * ## The failure this exists for + * + * `docker-compose.yml`'s `environment:` block is a whitelist. Docker Compose + * reads `.env` for `${VAR}` substitution, so a value an operator sets there + * appears to be configured, and a variable not named in the block never + * arrives in the process. v1.5.2 shipped that exact defect for + * `SESSION_COOKIE_SECURE`: documented, settable, and silently absent, which on + * a plain-HTTP self-host reads as "login is broken". + * + * `AUDIT_LOG_RETENTION_DAYS` is a sharper version of the same class, because + * the number is not merely enforced — it is PUBLISHED. `GET + * /api/account/grants` and `GET /api/account/activity` both send it, the + * activity list prints it, and the dialog that ends somebody's access states + * how long "who entered what" stays answerable. An operator who sets 90 and + * does not get it told every user 365, in a sentence about their own data. + * + * ## What this checks, and what it cannot + * + * The three places an operator meets a variable: the compose whitelist, the + * self-host example, and the production example. It cannot check that the + * value is honoured — `getAuditLogRetentionDays()` has its own tests — only + * that a value set the documented way is not dropped on the floor between + * `.env` and the process. + * + * The list is deliberately short. It is not every variable in the manifest: + * `scripts/check-env.ts` owns that question and CI runs it against + * `.env.production.example`. This is the narrower one that check-env cannot + * answer, because it reads a file rather than the compose whitelist. + * + * v1.37.0 — a fourth place joined the three. Both of these variables were + * documented in the two example files and whitelisted in compose, and NEITHER + * was in `scripts/env-manifest.json` — so `pnpm check-env` had never heard of + * them, and an operator running the pre-deploy check against their own + * environment was told nothing about the two settings this guard exists for. + * Three places held them in step and the fourth, the one CI runs, did not. + */ +const ROOT = process.cwd(); +const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8"); +const ENV_EXAMPLE = readFileSync(join(ROOT, ".env.example"), "utf8"); +const PROD_EXAMPLE = readFileSync( + join(ROOT, ".env.production.example"), + "utf8", +); +const MANIFEST = readFileSync(join(ROOT, "scripts/env-manifest.json"), "utf8"); + +/** Every variable name the manifest declares, in any group. */ +const MANIFEST_NAMES: string[] = ( + JSON.parse(MANIFEST) as { + groups: Array<{ variables: Array<{ name: string; anyOf?: string[] }> }>; + } +).groups.flatMap((group) => + group.variables.flatMap((variable) => [ + variable.name, + ...(variable.anyOf ?? []), + ]), +); + +/** + * Variables whose value the product states back to a user. + * + * A variable earns a place here by being readable somewhere in the interface, + * not by being important. That is the property that makes a missing whitelist + * entry a lie rather than an inconvenience. + */ +const PUBLISHED_TO_USERS = [ + { + name: "AUDIT_LOG_RETENTION_DAYS", + shownBy: "the shared-access activity window and the revoke dialog", + }, + { + name: "SESSION_COOKIE_SECURE", + shownBy: + "nothing directly — the original of this defect class, kept as a control", + }, +] as const; + +describe("a variable the product publishes reaches the container", () => { + it("has a matcher that can fail", () => { + // The pins. Every leg below asserts a substring is PRESENT, and a file + // that failed to load would be an empty string in which nothing is + // present — so the failure would be loud rather than silent. These make + // the fixtures' own size the evidence. + expect(PUBLISHED_TO_USERS.length).toBeGreaterThan(1); + expect(COMPOSE).toContain("environment:"); + expect(ENV_EXAMPLE.length).toBeGreaterThan(1000); + expect(PROD_EXAMPLE.length).toBeGreaterThan(1000); + // The manifest really parsed, and really carries names. An empty list + // would make the manifest leg below a quiet pass for every entry. + expect(MANIFEST_NAMES.length).toBeGreaterThan(20); + expect(MANIFEST_NAMES).toContain("DATABASE_URL"); + expect(MANIFEST_NAMES).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE"); + // And a name nothing declares is genuinely absent from all three, so the + // matchers below are not passing on a substring that is everywhere. + for (const file of [COMPOSE, ENV_EXAMPLE, PROD_EXAMPLE]) { + expect(file).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE"); + } + }); + + for (const { name, shownBy } of PUBLISHED_TO_USERS) { + describe(name, () => { + it(`is on the compose whitelist, so a value in .env arrives (${shownBy})`, () => { + // The substitution form, not merely the name: a bare mention in a + // comment is how this class of defect hides. + expect(COMPOSE).toMatch( + new RegExp(`^\\s*${name}:\\s*"\\$\\{${name}`, "m"), + ); + }); + + it("is documented in the self-host example", () => { + expect(ENV_EXAMPLE).toContain(name); + }); + + it("is documented in the production example", () => { + expect(PROD_EXAMPLE).toContain(name); + }); + + it("is declared in the manifest, so `pnpm check-env` reports it", () => { + // The fourth place, and the one CI actually runs. A variable an + // operator is told to set, and which the pre-deploy check has never + // heard of, is documented everywhere except where it would be checked. + expect(MANIFEST_NAMES).toContain(name); + }); + }); + } +}); diff --git a/src/__tests__/record-fence-headerless-transport-guard.test.ts b/src/__tests__/record-fence-headerless-transport-guard.test.ts new file mode 100644 index 000000000..3ccd9731d --- /dev/null +++ b/src/__tests__/record-fence-headerless-transport-guard.test.ts @@ -0,0 +1,327 @@ +/** + * A fenced route may not be reached by a transport that cannot carry a header. + * + * The record-session fence is asserted on request HEADERS, attached inside + * `src/lib/api/api-fetch.ts`. Three browser transports never go through that + * wrapper and cannot be made to: + * + * * `` — the browser issues the subresource itself; + * * `