diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba3240aa..90c38799e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,137 @@ # Changelog +## [1.38.16] — 2026-09-10 + +The API says what it refused and why, on every route, and the admin page +says whether last night's off-host copy actually landed for each account. + +### Added + +- **Off-host backup freshness per account.** The nightly job reported + counts, which cannot name the failing account. It now keeps a ledger row + per account (`OffhostBackupState`: last attempt, last success, size), + written the moment the object is durably in the bucket and under its own + error boundary, so a ledger write that fails cannot turn a landed backup + into a failure. Admin → Backups shows one row per account with a state + judged against the nightly schedule with six hours of grace for the Berlin + clock: `fresh` (within one period), `due` (within two), `stale`, `never` + (walked by a run, no object yet) and `unknown` (labelled "No record yet" + in the console; the honest answer on the first day after this release). + Stale first. Never a bucket listing; an empty state says so when off-host + backup is not configured. The row is in the wipe plan and registered as + not part of the backup itself. Runbook updated. + +- **The server side of the needs-based onboarding.** A record now holds its + onboarding answers (who the record is for, the areas to watch, medication + on a schedule, where readings come from, an upcoming visit, units), the + ordered steps with stable ids and a status each, when the flow completed + and which first result was offered. `GET /api/auth/me` publishes it as + `onboarding` for the record the payload describes; + `PATCH /api/onboarding/answers` takes one step at a time and is + idempotent, `POST /api/onboarding/complete` derives the module map from + the answers exactly once and only when every question is answered or + passed, `POST /api/onboarding/restart` re-asks the questions and keeps the + modules. The derivation turns on what the answers name and writes an + explicit off for the rest, but never turns off a module the person + switched on by hand and never one whose domain already holds rows, so + nothing is lost by a choice; the mapping from needs to modules lives in + the module registry next to the keys. The getting-started checklist orders + its items from the answers and no longer reads every account that never + entered the flow as unfinished. The web wizard that asks these questions + follows in a later release; today's first-run steps are unchanged. + +- **The error-reporting card tells the truth.** A green "Configured" badge + appeared from a DSN alone, even with sending off, and never said where + reports go. The forwarder now records the last successful and the last + failed delivery (timestamps and a short reason, never a response body); + the badge is green only when a report left within fourteen days, amber + otherwise with the reason, and the card names the target host, never the + key. `docs/ops/deploy.md` gains a note on setting it for an instance you + operate yourself; there is deliberately no default target. + +### Fixed + +- **A cycle-only share no longer sees the rest of the record.** + `GET /api/cycle/insights` was declared for the `cycle` section but + returned luteal and follicular averages for weight, resting heart rate, + HRV, sleep, steps, glucose and mood score, which are `measurements` and + `mind` data, to a delegate whose grant opened only the cycle. The route + asks the grant per section now and skips the queries it does not cover; + the owner and an unscoped grant get the same answer as before. One helper + answers "which sections does this grant open" for every route; two + verbatim copies of it are gone. + +- **Controls a delegate could not use are not offered.** The vaccination + pass showed add, edit and delete to a read-level delegate; a visit row + opened an editable sheet with Save and Delete for read and write levels; + the capture surfaces and the dashboard's quick entry offered kinds whose + section the grant did not open; nine pages stayed open to any delegate + because of a coarse "in a shared record" test. Each control asks the + section its own route requires (`canWriteDomain` / `canManageDomain`), the + coarse "may this person add" answer is retired, the nine page gates read + the record's own module map, and a structural guard sweeps every component + for the shape rather than naming files, because the first sweep found six + and missed three. + +- **A wipe, an account deletion and an encrypted export refuse under an + active switch.** They always did (the fresh-second-factor gate opens with + the plain authentication that refuses any switch) but the contract did not + say so; the three operations now publish the refusal, and a test drives it + through the browser's own carrier. + +- **A booster reminder is not minted for a record its grant does not + cover.** The mint refuses rather than silently skipping, with a test that + no row is written. + +### Changed + +- **Every shape refusal carries the issue list.** 59 refusal sites in 57 + routes answered a schema refusal with a bare 422 and a sentence; they go + through the shared helper now, so `details.issues` names each field the + way the rest of the tree already did. `error` and `meta` are unchanged; + six of the eleven dotted tokens that are still the whole `error` string + also ride as `meta.errorCode`, the five Coach reminder tokens do not yet. + A structural sweep reads every `safeParse` refusal in `src/app/api` and + fails when one bypasses the helper; its allowlist is empty. A second sweep + inventories the eleven token-string 422s, each with a written reason: one + is a published contract, five keep the string because the status did not + move and the token rides in `meta.errorCode` as well, five are the Coach + reminder surfaces that move as one piece or not at all. + +- **A body that is not JSON at all is a 400 everywhere.** Seven preference + routes answered 422 with a dotted token as the whole message; they answer + 400 with a sentence and the same token in `meta.errorCode`, the status + roughly two hundred other routes already use. A body that parses but fails + the schema is still the 422 beside it. The native client branches on + `meta.errorCode`, which did not change; the health-score configuration + route keeps its published 422 with a written reason. + `PUT /api/auth/me/report-selection` also names the leaf ids it does not + know in `meta.unknownLeaves` instead of refusing with a bare token. + +- **Two more writes honour `Idempotency-Key`.** The GLP-1 log and the custom + cycle symptom, the two row-creating writes the native client's offline + queue is most likely to replay after a lost response, join the routes that + return the first answer with `X-Idempotent-Replay: true` instead of a + second row. Documented per route. + +- **Single-record writes share one generous per-account bucket.** Eleven + routes that create or edit one row had no rate limit at all; they share + `record-write:` at 300 per minute, keyed on the acting account, + applied after authentication, with the 429 carrying `Retry-After`, the + `X-RateLimit-*` triple and `meta.errorCode = record_write.rate_limited` so + a client can tell the shared bucket from a route's own. The batch + endpoints keep their own buckets. A test pulls the key and the window out + of the limiter's query and holds the documentation to them. + +- **The error codes are a published list.** 257 `errorCode` values across 47 + surfaces, plus a group for the handful that carry no prefix, are + catalogued in `src/lib/openapi/error-codes.ts` and rendered into the error + envelope's description, grouped by surface. A guard walks the import graph + from every route (about fourteen hundred modules, the generated client + never opened) and fails in both directions: a code emitted anywhere a + route can reach but absent from the catalogue, and a catalogue entry + nothing emits. Existing codes keep their spelling. + ## [1.38.15] — 2026-09-10 The rest of #939 for managed profiles, one figure for medication adherence diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17e365281..241eb8b55 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.38.15 + version: 1.38.16 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -581,7 +581,11 @@ paths: temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. - Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off." + Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` + section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives + the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged + discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are + unchanged." responses: "200": description: Phase-correlation rows + headline. @@ -1789,6 +1793,20 @@ paths: type: string format: binary "401": *a1 + "403": + description: >- + The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access + token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 /api/auth/check-user: @@ -12237,8 +12255,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: @@ -12460,8 +12485,15 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "403": - description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal + description: >- + The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at. + + + Refused: the request was made while the session is acting on another account (`meta.errorCode` = + `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it + refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any + level opens it. content: application/json: schema: @@ -14201,12 +14233,19 @@ paths: tags: - Records summary: Plan a booster reminder from a dose - description: "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: + description: >- + Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as - `vaccination.booster.planned`." + `vaccination.booster.planned`. + + + The operation is declared under the health background and the row it mints belongs to the measurements section, + so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 + `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does + not. parameters: - in: path name: id @@ -14233,7 +14272,25 @@ paths: schema: $ref: "#/components/schemas/MintVaccinationBoosterEnvelope" "401": *a1 - "403": *a9 + "403": + description: >- + Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there + (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written. + + + 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" "404": *a28 "422": *a4 "429": *a5 @@ -14720,10 +14777,17 @@ paths: tags: - Mental health summary: Record a completed screener - description: Stores one administration (item answers encrypted) and writes the derived total as a server-owned - PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 - and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive - PHQ-9 item-9 the response carries the locale-aware crisis-resource set. + description: >- + Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / + GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals + run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the + response carries the locale-aware crisis-resource set. + + + The projection row is the one write this operation makes outside its declared section: the operation is fenced + on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a + screener whose own total is missing from the record would be an inconsistency inside the section the grant does + open. The consent copy for the mood-and-mind section names it. parameters: - *a6 requestBody: @@ -29898,8 +29962,8 @@ components: sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json, source-priority.body.invalid_shape; tokens — tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid, - vaccination.booster-no-antigen, vaccination.encounter-not-found, vaccination.identity-required, - vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found, + vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict, diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md index abd6a128a..05e2a9b14 100644 --- a/docs/self-hosting/account-sharing.md +++ b/docs/self-hosting/account-sharing.md @@ -49,16 +49,16 @@ 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 | +| 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, the facts in the health profile, visits and the practices behind them, and the vaccination record | +| 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 @@ -68,8 +68,12 @@ 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. +digest, every insight) read across sections, so a grant naming sections +never opens one. A figure derived from part of a record reads as a figure +about the person, and it would not be one. Whole-record grants do not all +open them equally either: the daily digest and the dashboard's snapshot +answer at read, while the health score, the dashboard's summary and the +insights need manage. A stored scope never grows. A section added by a later release is not in a set somebody ticked before it existed. @@ -80,11 +84,24 @@ 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. | +| 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, marking a dose taken or skipped, a visit, a practice, and a vaccination dose. 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. | + +Some entries are creates only **manage** reaches, and the split runs by +entry rather than by section: an allergy, a family-history entry, a mood +entry, a screener, a cycle day, a water entry and a preventive-care +reminder are all created at manage, in sections a write grant can +otherwise add to. Nothing in the document vault is writable at any level. + +One entry needs two sections rather than a higher level. Planning the +booster a vaccination suggests files it as a preventive-care reminder, +which lives under Readings, so a grant that opens Health background alone +logs the dose and is refused the booster. Logging the dose is unaffected +either way, and the app does not offer the booster to a grant that cannot +plan it. 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 @@ -106,12 +123,19 @@ whole feature is built on, and it is drawn on the server: | API tokens, the MCP connector | A durable credential that outlives the access | | 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 | +| Full exports, the encrypted archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | | 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 | +One export is the exception, and it is deliberate: the doctor report +(`POST /api/export/health-record`) answers at manage on a whole-record +grant. It renders a prepared artefact from a selection the caller declares +rather than handing the record over whole, which is the difference the row +above is drawing. The full exports and the passphrase-encrypted archive +stay with the owner. + 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 diff --git a/e2e/medication-compliance-journey.spec.ts b/e2e/medication-compliance-journey.spec.ts index d745c670a..83f01b3dc 100644 --- a/e2e/medication-compliance-journey.spec.ts +++ b/e2e/medication-compliance-journey.spec.ts @@ -231,8 +231,8 @@ async function openSeedingSurface(page: Page): Promise { } /** - * The two dose times the journey's medication runs on: now, and twelve hours - * from now. + * The two dose times the journey's medication runs on: now, and a twin later + * the same day. * * They are derived from the clock rather than picked from the wizard's * suggestion chips, and that is the whole reason the flow is stable. The card @@ -241,17 +241,27 @@ async function openSeedingSurface(page: Page): Promise { * belongs to yesterday or to tomorrow, and the day the write lands on decides * what the dashboard tile reads. A slot anchored at "now" is inside its own * on-time window by construction, so the take is always today's, whenever the - * suite runs. Its twin twelve hours away is the second dose of the day. + * suite runs. + * + * The twin must be on today's calendar as well, because the skip lands on it + * and the tile reads the latest day: a twin twelve hours on wraps past + * midnight for every run that starts after noon, the skip then lands on + * tomorrow's slot, and today reads one taken over two expected (50) where the + * skip-parity figure is 100. So the twin is twelve hours on before noon and + * the last minute of the day after it; either way the card offers it next. */ async function clockAnchoredDoseTimes( page: Page, ): Promise<{ open: string; other: string }> { return page.evaluate(() => { const pad = (n: number) => String(n).padStart(2, "0"); + const hhmm = (minutes: number) => + `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; const now = new Date(); + const nowMinutes = now.getHours() * 60 + now.getMinutes(); return { - open: `${pad(now.getHours())}:${pad(now.getMinutes())}`, - other: `${pad((now.getHours() + 12) % 24)}:${pad(now.getMinutes())}`, + open: hhmm(nowMinutes), + other: hhmm(Math.min(nowMinutes + 12 * 60, 24 * 60 - 1)), }; }); } @@ -362,7 +372,7 @@ test.describe("medication adherence journey", () => { // The tile reads THE LATEST day, and a day boundary crossed mid-flow moves // that day out from under both tile assertions: a fresh day has two doses - // scheduled, none taken, and answers 0 where 50 is asserted. The flow is a + // scheduled, none taken, and answers 0 where 100 is asserted. The flow is a // couple of minutes long, so declining to start in the last ten of the day // is enough. The clock is the browser's, which the config pins to the zone // the compliance engine falls back to. diff --git a/messages/de.json b/messages/de.json index 731a3fe83..67859327b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "Stimmung und Psyche", - "detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen." + "detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen. Ein ausgefüllter Fragebogen legt seinen Summenwert zusätzlich als Messwert ab." }, "cycle": { "label": "Zyklus", diff --git a/messages/en.json b/messages/en.json index ce91dcf4e..8f3dee7cc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "Mood and mind", - "detail": "Mood entries and the history of the mental-health questionnaires." + "detail": "Mood entries and the history of the mental-health questionnaires. A completed questionnaire also files its total score as a reading." }, "cycle": { "label": "Cycle", diff --git a/messages/es.json b/messages/es.json index ea5b3c2f8..257ef0064 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "Ánimo y bienestar mental", - "detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental." + "detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental. Un cuestionario completado también registra su puntuación total como una medición." }, "cycle": { "label": "Ciclo", diff --git a/messages/fr.json b/messages/fr.json index 69d529480..f05bd52b5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "Humeur et santé mentale", - "detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale." + "detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale. Un questionnaire rempli enregistre aussi son score total comme une mesure." }, "cycle": { "label": "Cycle", diff --git a/messages/it.json b/messages/it.json index 8ef53019f..f98863e1d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "Umore e benessere mentale", - "detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale." + "detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale. Un questionario compilato registra anche il suo punteggio totale come una misurazione." }, "cycle": { "label": "Ciclo", diff --git a/messages/ko.json b/messages/ko.json index 8972d6d32..ef9268f8d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "기분과 마음", - "detail": "기분 기록과 정신건강 설문 이력이에요." + "detail": "기분 기록과 정신건강 설문 이력이에요. 작성한 설문은 총점도 측정값으로 함께 저장돼요." }, "cycle": { "label": "생리 주기", diff --git a/messages/pl.json b/messages/pl.json index af4945d32..32776344a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10609,7 +10609,7 @@ }, "mind": { "label": "Nastrój i zdrowie psychiczne", - "detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego." + "detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego. Wypełniony kwestionariusz zapisuje też swój wynik łączny jako pomiar." }, "cycle": { "label": "Cykl", diff --git a/package.json b/package.json index e3401361b..2177036ba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.15", + "version": "1.38.16", "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/public/sw.js b/public/sw.js index 45a57b778..58f9dd341 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.38.15"; + /* @sw-version-fallback */ "v1.38.16"; 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/src/__tests__/domain-write-support-guard.test.ts b/src/__tests__/domain-write-support-guard.test.ts index 76ed8c0cb..277f2107d 100644 --- a/src/__tests__/domain-write-support-guard.test.ts +++ b/src/__tests__/domain-write-support-guard.test.ts @@ -24,6 +24,16 @@ * domain outside the vocabulary other than `record` fails the run outright, * because the table is keyed on the vocabulary and would silently lack it. * + * What it deliberately does not hold, stated so nobody reads more into a green + * run than it earns: the table is EXISTENTIAL per section. It answers "does + * any route in this section accept this need", and within a section the answer + * varies by verb — `POST /api/encounters` is WRITE while `POST /api/allergies` + * beside it is MANAGE, both under `profile`. So this guard proves the table + * and the routes agree about the section, never that one control's own verb is + * covered. That is the control's job: it asks the hook for its own verb class, + * and a control that read "my section is writable, therefore my create is" + * would be wrong in three of the four sections marked writable. + * * Mutation checks, run: * - set `mind.write` to `true` in the table → "agrees with the routes" red; * - change `requireRecordAuth("manage", "mind")` in `mood-entries/route.ts` diff --git a/src/__tests__/helpers/source-files.ts b/src/__tests__/helpers/source-files.ts index 0fb711277..9a2a3cff1 100644 --- a/src/__tests__/helpers/source-files.ts +++ b/src/__tests__/helpers/source-files.ts @@ -73,3 +73,70 @@ export function walkSourceFiles( } return files; } + +/** + * A source file with its comments removed, line comments FIRST. + * + * The order is the whole reason this is shared rather than re-spelled per + * guard. Every private copy in this directory strips block comments first, + * with one non-greedy `/\*[\s\S]*?\*\//` pass, and that pass cannot tell a + * real block opener from a `/*` that happens to sit inside a line comment. + * This tree has one: `src/app/mood/page-client.tsx` explains its gate with + * "Every `/api/mood-entries/*` route also enforces the gate", and the `/*` in + * that path opened a fake block that swallowed the next 2.2 kB of real code — + * the module gate three lines below it included. A guard reading that source + * was searching a file with a hole in it and reporting a clean sweep. + * + * So each line is walked once, left to right: whichever opener comes first + * wins, a line comment ends the line, and a block comment carries its state to + * the next one. Line-comment detection needs `//` at the start of a line or + * after whitespace, so a URL inside a string survives. + * + * Its honest limit: it is a scanner, not a parser. A `/*` inside a string + * literal still opens a block, and a guard that needs more than this needs a + * real parse. + */ +export function stripComments(source: string): string { + const lines: string[] = []; + let inBlock = false; + + for (const line of source.split("\n")) { + let rest = line; + let kept = ""; + + while (rest.length > 0) { + if (inBlock) { + const closes = rest.indexOf("*/"); + if (closes === -1) break; + rest = rest.slice(closes + 2); + inBlock = false; + continue; + } + + const lineComment = rest.search(/(^|\s)\/\//); + const blockComment = rest.indexOf("/*"); + + if ( + blockComment !== -1 && + (lineComment === -1 || blockComment < lineComment) + ) { + kept += rest.slice(0, blockComment); + rest = rest.slice(blockComment + 2); + inBlock = true; + continue; + } + + if (lineComment !== -1) { + kept += rest.slice(0, rest.indexOf("//", lineComment)); + break; + } + + kept += rest; + break; + } + + lines.push(kept); + } + + return lines.join("\n"); +} diff --git a/src/__tests__/module-gate-record-scoped.test.ts b/src/__tests__/module-gate-record-scoped.test.ts new file mode 100644 index 000000000..a71f3de71 --- /dev/null +++ b/src/__tests__/module-gate-record-scoped.test.ts @@ -0,0 +1,120 @@ +/** + * A module gate decides from the RECORD's map, never from "am I a delegate". + * + * Every surface that paints a module reads `user?.modules?.X`, and a run of + * them used to widen that read with `inSharedRecord || …`. The prefix was + * right when it was written: `GET /api/auth/me` published the ACTOR's module + * map, so a delegate's own preferences would have closed a page in somebody + * else's record. Since the map became the record's, masked to what the grant + * opens, the same prefix fails open in the other direction — the navigation + * correctly drops the entry while a typed URL still renders the page for a + * record whose owner switched the module off, and the routes behind these + * pages carry no module gate of their own, so the page works. + * + * A DISCOVERED set rather than a named one. The first version of this guard + * froze the six files one sweep happened to find; three more were carrying the + * identical expression at the time, and a named list is structurally unable to + * say so. So the sweep walks every component under `src/app` and + * `src/components`, and the property is about the SHAPE: no boolean `||` may + * join `inSharedRecord` to a module read. The window the matcher searches + * cannot cross a `;`, a `{` or a `}`, so it sees one expression at a time and + * a delegate check elsewhere in the same file is none of its business. + * + * A structural check rather than a render, because the thing that regressed is + * one `||`: reaching these pages in a unit test would mean standing up the + * auth query, the router and the shell, and none of that is where the defect + * lives. + * + * Its limit, stated: it holds the two spellings of the OR. A gate that widened + * itself some other way — a ternary, a helper returning the delegate flag — + * would pass. The non-vacuity floor below is what keeps a broken walker from + * reading as a clean sweep. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { stripComments, walkSourceFiles } from "./helpers/source-files"; + +const ROOT = process.cwd(); +/** Floors pinned below the real counts, so a broken walk throws. */ +const ROOTS: ReadonlyArray<[string, number]> = [ + ["src/app", 100], + ["src/components", 700], +]; + +/** The module read every gated surface makes. */ +const MODULE_READ = "user?.modules?."; + +/** + * The two spellings of the widening, in one expression each. + * + * `[^;{}]*` is the whole point: it cannot cross a statement end or a block + * boundary, so a file that reads the module map in one place and asks about + * the delegate in another does not match. + */ +const WIDENED = [ + /inSharedRecord\s*\|\|[^;{}]*user\?\.modules\?\./, + /user\?\.modules\?\.[^;{}]*\|\|\s*inSharedRecord/, +]; + +/** Every `.tsx` under the roots, with its comment-stripped source. */ +function componentSources(): Array<{ file: string; source: string }> { + const out: Array<{ file: string; source: string }> = []; + for (const [root, floor] of ROOTS) { + const files = walkSourceFiles(join(ROOT, root), { + floor, + extensions: [".tsx"], + }); + for (const rel of files) { + if (rel.includes("__tests__/")) continue; + out.push({ + file: `${root}/${rel}`, + source: stripComments(readFileSync(join(ROOT, root, rel), "utf8")), + }); + } + } + return out; +} + +describe("a module gate reads the record's map alone", () => { + const sources = componentSources(); + const gated = sources.filter(({ source }) => source.includes(MODULE_READ)); + + it("discovers a real set of module-gated surfaces", () => { + // The empty-match trap: a walker that stopped walking, or a spelling of + // the module read that moved, would make every assertion below vacuous. + expect(sources.length, "no component was walked at all").toBeGreaterThan( + 500, + ); + expect( + gated.length, + "no surface was found reading the module map", + ).toBeGreaterThan(20); + // Spot checks, so a sweep that found twenty of the wrong files still + // fails: these are module-gated pages by construction. + const files = new Set(gated.map((entry) => entry.file)); + for (const file of [ + "src/app/vaccinations/page.tsx", + "src/app/labs/page.tsx", + "src/app/mood/page-client.tsx", + "src/components/documents/documents-view.tsx", + ]) { + expect(files, `${file} was not discovered`).toContain(file); + } + }); + + it("finds no gate widened by the delegate flag", () => { + const widened = gated + .filter(({ source }) => WIDENED.some((pattern) => pattern.test(source))) + .map(({ file }) => file); + + expect( + widened, + `these surfaces widen a module gate for a delegate; the record's masked map already answers for both cases:\n${widened + .map((file) => ` ❌ ${file}`) + .join("\n")}`, + ).toEqual([]); + }); +}); diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts index 9d0cbeff1..c7d285554 100644 --- a/src/__tests__/openapi-sharing-denial.test.ts +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -45,16 +45,16 @@ * - 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. + * - It covered the PUBLISHED surface and not the whole delegable one. That was + * the honest limit at v1.37.0, when 116 route modules resolved the fence and + * 26 of them were in no OpenAPI document at all. The gap is closed on this + * tree, so the limit is now an assertion rather than a caveat: the last leg + * below recomputes the fenced modules from the route sources and requires + * every one of them to be published. Two counts is one too many, so it names + * no number — a hard-coded figure is what turned the paragraph it replaces + * into a false claim about the contract. */ -import { readFileSync, existsSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -63,6 +63,8 @@ import { openApiPaths } from "@/lib/openapi/routes"; import { SHARING_ACCESS_DENIED_DESCRIPTION } from "@/lib/openapi/routes/shared"; import { RETIRED_ROUTES } from "@/lib/http/retired-routes"; +import { stripComments } from "./helpers/source-files"; + /** Published paths that deliberately have no handler behind them. */ const retiredPaths = new Set(RETIRED_ROUTES.map((route) => route.path)); @@ -80,8 +82,40 @@ interface PublishedOperation { handlerSource: string; } -function stripComments(text: string): string { - return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); +/** + * Every `route.ts` under `src/app/api` whose handlers resolve the record fence. + * + * Comments stripped first, for the reason the operation matcher strips them: + * one module names both fence helpers in a docblock explaining why it uses + * neither. + */ +function fencedRouteModules(): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (entry.name !== "route.ts") continue; + const source = stripComments(readFileSync(full, "utf8")); + if (/require(RecordAuth|GuardianAuth)\s*\(/.test(source)) { + out.push(full); + } + } + }; + walk(join(ROOT, "src/app/api")); + return out; +} + +/** `…/src/app/api/labs/[id]/route.ts` → `/api/labs/{id}`. */ +function apiPathFor(moduleFile: string): string { + return moduleFile + .slice(join(ROOT, "src/app").length) + .replaceAll("\\", "/") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); } function routeModulePath(apiPath: string): string { @@ -334,6 +368,33 @@ describe("the sharing refusal every delegable path publishes", () => { } }); + it("publishes every module that resolves the fence", () => { + // The reverse direction of the leg above, and the one the docblock used to + // apologise for: that one holds every PUBLISHED fenced operation to the + // refusal, and says nothing about a fenced module nobody published. A + // module missing from the document is a delegable surface a generated + // client cannot see and a reader of the contract cannot find, which is + // exactly how the mood-entry tree and the record-settings tree stayed + // invisible for a release. + const modules = fencedRouteModules(); + expect( + modules.length, + "no fenced route module was discovered — the matcher found nothing", + ).toBeGreaterThan(100); + + const published = new Set(Object.keys(openApiPaths)); + const missing = modules + .map((rel) => apiPathFor(rel)) + .filter((apiPath) => !published.has(apiPath)); + + expect( + missing, + `these route modules resolve the record fence and are in no published path:\n${missing + .map((apiPath) => ` ❌ ${apiPath}`) + .join("\n")}`, + ).toEqual([]); + }); + it("carries the refusal into the generated document", () => { const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60)); diff --git a/src/__tests__/sharing-legacy-client-contract.test.ts b/src/__tests__/sharing-legacy-client-contract.test.ts index 67eaa3d0b..63dd4f71e 100644 --- a/src/__tests__/sharing-legacy-client-contract.test.ts +++ b/src/__tests__/sharing-legacy-client-contract.test.ts @@ -161,12 +161,11 @@ describe("what the canonical readers make of the same payload", () => { expect(capabilities.sections).toEqual(fixture.sections); expect(capabilities.recordKind).toBe(fixture.recordKind); expect(capabilities.inSharedRecord).toBe(true); - // Resolved server-side and rendered, never re-derived: `canAdd` follows - // `canWrite`, and `canManage` follows the published `manageableDomains` - // list rather than the level — a delegate's management reach is the - // server's answer, section by section. + // Resolved server-side and rendered, never re-derived: `canWrite` is the + // published level, and both per-section answers follow the published + // lists rather than the level — a delegate's reach is the server's + // answer, section by section. expect(capabilities.canWrite).toBe(fixture.canWrite); - expect(capabilities.canAdd).toBe(fixture.canWrite); const manageable = active?.manageableDomains ?? []; expect(capabilities.canManage).toBe(manageable.length > 0); expect(manageable.length > 0).toBe(fixture.level === "manage"); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 9906a35c5..007a75247 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -121,6 +121,12 @@ export const GET = apiHandler(async (request: NextRequest) => { // Apple Watch wrist/skin temperature feeds the temperature-trend // ovulation layer. Read the WRIST_TEMPERATURE measurements as nightly // values; the engine derives the trailing-mean deviation itself. + // + // This is a measurements read on a route declared under `cycle`, and it + // needs no section predicate for one reason: no value from it reaches the + // response. It goes into `predictCycle` and what comes out the other side + // is a shifted date. The crosstab next door reads the same channel and IS + // fenced, because there the values are the answer. prisma.measurement.findMany({ where: { userId: user.id, diff --git a/src/app/api/cycle/insights/route.ts b/src/app/api/cycle/insights/route.ts index d23d5c1da..287406817 100644 --- a/src/app/api/cycle/insights/route.ts +++ b/src/app/api/cycle/insights/route.ts @@ -16,6 +16,7 @@ */ import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError } from "@/lib/api-response"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -50,7 +51,7 @@ const WINDOW_DAYS = 365; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireRecordAuth("read", "cycle"); + const { user, grantId } = await requireRecordAuth("read", "cycle"); const gate = await requireCycleEnabled(user.id, user.gender); if (!gate.enabled) return gate.response; @@ -68,6 +69,19 @@ export const GET = apiHandler(async () => { const today = moodDateKey(new Date(), tz); const from = addDays(today, -WINDOW_DAYS); + // The crosstab is a cycle surface that reads two other sections. The phase + // labels are cycle data; the outcome columns are not. `lutealAvg`, + // `follicularAvg` and `delta` ship resting heart rate, HRV, sleep duration, + // steps, weight, temperature, glucose and the mood score in display units, + // and a grant scoped to the cycle alone was never consent for any of them. + // So the two foreign channels are fenced at the query, not at the response: + // a section the caller is outside of is never read, so nothing downstream + // can leak it back — the rows simply do not exist, exactly as they do not + // for a record with no measurements. + const visible = await actingDomainVisibility(prisma, grantId); + const seesMeasurements = visible("measurements"); + const seesMind = visible("mind"); + const [ cycles, dayLogRows, @@ -116,25 +130,28 @@ export const GET = apiHandler(async () => { select: { measuredAt: true, value: true }, }), // The outcome metrics the phase contrast compares — soft-delete-scoped, - // canonical-source deduped per day inside `metricDayMap`. - prisma.measurement.findMany({ - where: { - userId: user.id, - deletedAt: null, - type: { in: PHASE_CROSSTAB_METRIC_TYPES }, - measuredAt: { - gte: new Date(Date.parse(`${from}T00:00:00Z`)), - }, - }, - orderBy: { measuredAt: "asc" }, - select: { - type: true, - value: true, - measuredAt: true, - source: true, - deviceType: true, - }, - }), + // canonical-source deduped per day inside `metricDayMap`. Skipped whole + // for a caller outside `measurements`. + seesMeasurements + ? prisma.measurement.findMany({ + where: { + userId: user.id, + deletedAt: null, + type: { in: PHASE_CROSSTAB_METRIC_TYPES }, + measuredAt: { + gte: new Date(Date.parse(`${from}T00:00:00Z`)), + }, + }, + orderBy: { measuredAt: "asc" }, + select: { + type: true, + value: true, + measuredAt: true, + source: true, + deviceType: true, + }, + }) + : [], prisma.user.findUnique({ where: { id: user.id }, select: { sourcePriorityJson: true }, @@ -142,15 +159,18 @@ export const GET = apiHandler(async () => { // MOOD outcome (QA HIGH): mood lives in MoodEntry (1–5 score), not a // Measurement row, so read it here and inject it into the crosstab as a // synthetic MOOD_CHANNEL_KEY measurement (same FDR / day-floor guards). - prisma.moodEntry.findMany({ - where: { - userId: user.id, - deletedAt: null, - moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, - }, - orderBy: { moodLoggedAt: "asc" }, - select: { score: true, moodLoggedAt: true }, - }), + // Skipped whole for a caller outside `mind`. + seesMind + ? prisma.moodEntry.findMany({ + where: { + userId: user.id, + deletedAt: null, + moodLoggedAt: { gte: new Date(Date.parse(`${from}T00:00:00Z`)) }, + }, + orderBy: { moodLoggedAt: "asc" }, + select: { score: true, moodLoggedAt: true }, + }) + : [], ]); // The latest open cycle runs to the predicted next-period start so the @@ -239,6 +259,8 @@ export const GET = apiHandler(async () => { lagged_discovered: lagged.discovered.length, lagged_pairs_tested: lagged.pairsTested, symptom_patterns: symptomPatterns.length, + measurements_visible: seesMeasurements, + mind_visible: seesMind, }, }); diff --git a/src/app/api/encounters/[id]/links/route.ts b/src/app/api/encounters/[id]/links/route.ts index 076ec4547..c22af968e 100644 --- a/src/app/api/encounters/[id]/links/route.ts +++ b/src/app/api/encounters/[id]/links/route.ts @@ -27,10 +27,8 @@ import { } from "@/lib/api-response"; import { encounterLinkSchema } from "@/lib/validations/encounters"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadEncounterLinks, -} from "@/lib/encounters/service"; +import { loadEncounterLinks } from "@/lib/encounters/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/restore/route.ts b/src/app/api/encounters/[id]/restore/route.ts index c37cd9500..73c5731fd 100644 --- a/src/app/api/encounters/[id]/restore/route.ts +++ b/src/app/api/encounters/[id]/restore/route.ts @@ -22,12 +22,12 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, loadEncounterLinks, resolveOwnerNotificationContext, shouldHaveReminder, } from "@/lib/encounters/service"; import { reanchorAppointmentReminder } from "@/lib/encounters/appointment-reminder"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/[id]/route.ts b/src/app/api/encounters/[id]/route.ts index b33bc64d9..930e04f4f 100644 --- a/src/app/api/encounters/[id]/route.ts +++ b/src/app/api/encounters/[id]/route.ts @@ -35,7 +35,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, closeCheckupForVisit, findOwnAppointmentReminderId, @@ -54,6 +53,7 @@ import { type RestoreSkipLog, } from "@/lib/export/restore-skips"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 5abab7694..bdc74237c 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -36,7 +36,6 @@ import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; import { ENCOUNTER_INCLUDE, - actingDomainVisibility, applyEncounterLinks, loadEncounterLinksForMany, closeCheckupForVisit, @@ -51,6 +50,7 @@ import { summarizeRestoreSkips, type RestoreSkipLog, } from "@/lib/export/restore-skips"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; export const GET = apiHandler(async (request: NextRequest) => { const { user, grantId } = await requireRecordAuth("read", "profile"); diff --git a/src/app/api/mental-health/assessments/route.ts b/src/app/api/mental-health/assessments/route.ts index 4229ecfd3..74c81f181 100644 --- a/src/app/api/mental-health/assessments/route.ts +++ b/src/app/api/mental-health/assessments/route.ts @@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise { // into the record: the ordinary way this instrument is used when the person // it is about cannot run their own record. The row records what the person // in the room reported; the audit row says who typed it. + // + // It writes ONE row outside its declared section, and the crossing is + // deliberate rather than an oversight: the `*_SCORE` Measurement below is a + // projection of this administration, minted `COMPUTED`, keyed on + // `assessment:`, and it exists only so the trend reads through the chart + // and rollup tier without ever touching the encrypted item content. Fencing + // it on `measurements` the way the booster mint is fenced would file a + // screener whose own total is missing from the record — an inconsistency + // inside the section the grant DOES open, to avoid a derived point in one it + // does not. The consent copy for this section says so, which is where a + // widening belongs. const { user, actor } = await requireRecordAuth("manage", "mind"); // Opt-in module (default OFF) — same gate as GET. diff --git a/src/app/api/vaccinations/[id]/booster/route.ts b/src/app/api/vaccinations/[id]/booster/route.ts index da406db47..19a87d846 100644 --- a/src/app/api/vaccinations/[id]/booster/route.ts +++ b/src/app/api/vaccinations/[id]/booster/route.ts @@ -13,11 +13,21 @@ * even when a delegate is transcribing. The antigen is read from the dose's * catalogue entry server-side, never from the body — a client cannot key a * reminder onto an antigen the dose does not contain. + * + * The row it mints is a `MeasurementReminder`, and that model belongs to the + * `measurements` section: every direct route over it declares that domain, and + * creating one needs MANAGE there. So the act crosses a seam, and a grant + * scoped to the health background alone was never consent for the other side + * of it — it would produce a row on the owner's checkup list that the delegate + * can neither read nor change through the section that owns it. The seam is + * fenced the way the visits service fences a checkup closure: the mint runs + * only while the caller is inside `measurements`. */ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -36,9 +46,24 @@ type RouteParams = { params: Promise<{ id: string }> }; export const POST = apiHandler( async (request: NextRequest, { params }: RouteParams) => { - const { user } = await requireRecordAuth("write", "profile"); + const { user, grantId } = await requireRecordAuth("write", "profile"); const { id } = await params; + // Refused rather than skipped, and that is the difference from the visit's + // checkup closure: there the closure is one effect of a larger save and + // saying "not done" keeps the save honest, while here the reminder IS the + // request. Reporting success over a row that was never written would tell + // the person their booster is planned when nothing rings. + const visible = await actingDomainVisibility(prisma, grantId); + if (!visible("measurements")) { + annotate({ meta: { sharing_refusal: "booster_out_of_scope" } }); + return apiError( + "Planning a booster needs access to this record's measurements", + 403, + { errorCode: "vaccination.booster-out-of-scope" }, + ); + } + const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 8 * 1024, }); diff --git a/src/app/api/vaccinations/[id]/links/route.ts b/src/app/api/vaccinations/[id]/links/route.ts index 7a9b10442..d8ba5cbed 100644 --- a/src/app/api/vaccinations/[id]/links/route.ts +++ b/src/app/api/vaccinations/[id]/links/route.ts @@ -26,10 +26,8 @@ import { } from "@/lib/api-response"; import { vaccinationLinkSchema } from "@/lib/validations/vaccinations"; import { linkTargets, unlinkTargets } from "@/lib/links"; -import { - actingDomainVisibility, - loadVaccinationDocuments, -} from "@/lib/vaccinations/service"; +import { loadVaccinationDocuments } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/restore/route.ts b/src/app/api/vaccinations/[id]/restore/route.ts index 685e61657..0d5600b60 100644 --- a/src/app/api/vaccinations/[id]/restore/route.ts +++ b/src/app/api/vaccinations/[id]/restore/route.ts @@ -20,10 +20,10 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, loadVaccinationDocuments, resolveSeriesFor, } from "@/lib/vaccinations/service"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 8a7c77bf2..1b85c023d 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -32,7 +32,6 @@ import { vaccinationUpdateSchema } from "@/lib/validations/vaccinations"; import { toVaccinationDTO } from "@/lib/vaccinations/dto"; import { VACCINATION_INCLUDE, - actingDomainVisibility, applyVaccinationLinks, loadVaccinationDocuments, resolveOwnedEncounter, @@ -40,6 +39,7 @@ import { resolveSeriesFor, } from "@/lib/vaccinations/service"; import type { Prisma } from "@/generated/prisma/client"; +import { actingDomainVisibility } from "@/lib/sharing/acting-domains"; type RouteParams = { params: Promise<{ id: string }> }; diff --git a/src/app/cycle/page.tsx b/src/app/cycle/page.tsx index ab7a29e57..a4ab8c9aa 100644 --- a/src/app/cycle/page.tsx +++ b/src/app/cycle/page.tsx @@ -4,7 +4,6 @@ import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { CycleView } from "@/components/cycle/cycle-view"; @@ -23,10 +22,9 @@ import { CycleView } from "@/components/cycle/cycle-view"; */ export default function CyclePage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); - const enabled = inSharedRecord || user?.modules?.cycle !== false; + const enabled = user?.modules?.cycle !== false; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/[id]/page.tsx b/src/app/illness/[id]/page.tsx index c0275553a..98e4c174c 100644 --- a/src/app/illness/[id]/page.tsx +++ b/src/app/illness/[id]/page.tsx @@ -14,7 +14,6 @@ import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessEpisodeDetail } from "@/components/illness/illness-episode-detail"; import { BackLink } from "@/components/ui/back-link"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useMounted } from "@/hooks/use-mounted"; import { useTranslations } from "@/lib/i18n/context"; @@ -25,12 +24,11 @@ export default function IllnessEpisodePage({ }) { const { id } = use(params); const { user, isAuthenticated, isLoading } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const mounted = useMounted(); const router = useRouter(); const { t } = useTranslations(); - const enabled = inSharedRecord || user?.modules?.illness === true; + const enabled = user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/illness/page.tsx b/src/app/illness/page.tsx index 9511acf3c..6fef680cc 100644 --- a/src/app/illness/page.tsx +++ b/src/app/illness/page.tsx @@ -4,7 +4,6 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; -import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { PageAuthGate } from "@/components/ui/page-auth-gate"; import { IllnessView } from "@/components/illness/illness-view"; @@ -21,11 +20,10 @@ import { IllnessView } from "@/components/illness/illness-view"; */ export default function IllnessPage() { const { user, isLoading, isAuthenticated } = useAuth(); - const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); // Born-gated: enabled ONLY when the resolved map says `true`. - const enabled = inSharedRecord || user?.modules?.illness === true; + const enabled = user?.modules?.illness === true; useEffect(() => { if (isLoading) return; diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index 768d7e5b5..65824629e 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -44,7 +44,8 @@ export default function LabsPage() { // Scanning a report is not: it files a document and commits through the OCR // route, and neither is delegated. So a WRITE delegate keeps the manual add // and loses the choice menu around it. - const { canAdd, inSharedRecord } = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const mounted = useMounted(); const router = useRouter(); const queryClient = useQueryClient(); @@ -62,7 +63,7 @@ export default function LabsPage() { // absent key reads as enabled, so a direct URL hit only bounces on an // explicit `false`. Every `/api/labs/*` route also enforces the gate // server-side, so this is a UX redirect, not the security boundary. - const enabled = inSharedRecord || user?.modules?.labs !== false; + const enabled = user?.modules?.labs !== false; const ocrCapability = useOcrCapability( shouldProbeOcrCapability({ isAuthenticated, @@ -152,7 +153,7 @@ export default function LabsPage() { - ) : canAdd ? ( + ) : canAddLab ? ( )} - {canAdd && ( + {canAddAnything && ( {/* v1.4.33 maintainer-item-7 — restore proportional sizing @@ -152,10 +158,12 @@ export function DashboardHeader({ phase-A3 fix #1 hardened this with a unit guard at `src/app/__tests__/quick-add-labels.test.ts` — both labels must differ from each other AND from `common.add`. */} - onQuickEntry("measurement")}> - + {canAddMeasurement && ( + onQuickEntry("measurement")}> + + )} {canAddMood && ( onQuickEntry("mood")}> )} diff --git a/src/components/dashboard/quick-entry-sheets.tsx b/src/components/dashboard/quick-entry-sheets.tsx index 4b39eaef5..189958163 100644 --- a/src/components/dashboard/quick-entry-sheets.tsx +++ b/src/components/dashboard/quick-entry-sheets.tsx @@ -34,17 +34,7 @@ import type { ShareDomain } from "@/lib/sharing/scope"; export type QuickEntryDialog = "measurement" | "mood" | "medicationIntake" | null; -/** - * v1.36.x — which of the three the delegation admits, and the same rule the - * capture picker states at length. - * - * A WRITE grant covers entering a reading and marking a dose. A mood entry is - * not an admitted verb and the server refuses it under a switch. - */ -const DELEGABLE_QUICK_ENTRIES: ReadonlySet> = - new Set(["measurement", "medicationIntake"]); - -/** v1.38.12 — the section each sheet writes to; see the capture picker. */ +/** The section each sheet writes to; see the capture picker. */ const QUICK_ENTRY_DOMAIN: Readonly< Record, ShareDomain> > = { @@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly< */ export function admittedQuickEntry( open: QuickEntryDialog, - caps: Pick, + caps: Pick, ): QuickEntryDialog { if (open === null) return open; - if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open; - return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null; + return caps.canWriteDomain(QUICK_ENTRY_DOMAIN[open]) ? open : null; } /** diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index 6b3f5c60d..67e5ac4aa 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -251,8 +251,7 @@ export function DocumentsView() { const queryClient = useQueryClient(); const coachLaunch = useCoachLaunch(); - const moduleEnabled = - inSharedRecord || user?.modules?.inboundDocuments === true; + const moduleEnabled = user?.modules?.inboundDocuments === true; // UX redirect only — the API routes enforce the gate server-side. useEffect(() => { diff --git a/src/components/encounters/encounter-sheet.tsx b/src/components/encounters/encounter-sheet.tsx index 26e2a8d0e..abac9c3fb 100644 --- a/src/components/encounters/encounter-sheet.tsx +++ b/src/components/encounters/encounter-sheet.tsx @@ -20,6 +20,16 @@ * saved and a plain sentence saying the checkup stayed open. That is reported * rather than skipped in silence: somebody who believes a checkup was closed * will not look at it again. + * + * Capability note: this sheet carries no capability check of its own, by + * decision. Save is a create on one mount and an edit on the other, and Delete + * exists only in edit mode — so the question belongs to whoever opens it. + * `VisitsSection` hands it a record only under `canManageDomain("profile")`, + * and the two mounts that can only create (`EpisodeVisitsCard`, + * `VorsorgeSection`) pass `encounter={null}` behind their own write check. A + * THIRD mount that hands this sheet a record without asking the manage + * question reintroduces an ungated Delete, and nothing here will fail. Ask at + * the caller, or move the check in here for everyone. */ import { useState } from "react"; import { Trash2 } from "lucide-react"; diff --git a/src/components/encounters/visit-card.tsx b/src/components/encounters/visit-card.tsx index 7f4ecdf32..0baaf512e 100644 --- a/src/components/encounters/visit-card.tsx +++ b/src/components/encounters/visit-card.tsx @@ -35,7 +35,14 @@ export function VisitCard({ onOpen, }: { encounter: Encounter; - onOpen: (encounter: Encounter) => void; + /** + * Opens the edit sheet. Omitted when the caller may not change the visit — + * `PATCH` and `DELETE /api/encounters/{id}` are both `("manage", "profile")`, + * so a READ or WRITE delegate gets the card as a card and not as a button. + * A row that reads as tappable and answers 403 is the affordance saying + * something about the grant that is not true. + */ + onOpen?: (encounter: Encounter) => void; }) { const { t } = useTranslations(); const format = useFormatters(); @@ -71,11 +78,7 @@ export function VisitCard({ className="gap-2 py-3 md:py-4" > - + ); } + +/** + * The row's content, tappable or not. + * + * One element either way rather than two copies of the content: the read-only + * form keeps the same spacing the button had, so a card that loses its edit + * affordance does not also change shape. + */ +function Body({ + onOpen, + encounter, + children, +}: { + onOpen?: (encounter: Encounter) => void; + encounter: Encounter; + children: React.ReactNode; +}) { + if (!onOpen) return
{children}
; + return ( + + ); +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 2af5b67d0..3b2ad8c9a 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -30,9 +30,13 @@ import { VisitCard } from "./visit-card"; export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const { t } = useTranslations(); - const { canWriteDomain } = useRecordCapabilities(); - // `POST /api/encounters` answers at WRITE under the profile section. + const { canWriteDomain, canManageDomain } = useRecordCapabilities(); + // `POST /api/encounters` answers at WRITE under the profile section; the + // edit and the delete behind the row are both MANAGE. Only the add was + // asked, so every row stayed tappable into a sheet whose Save and Delete + // the server refuses. const canAddVisit = canWriteDomain("profile"); + const canManageVisits = canManageDomain("profile"); const list = useEncounters(enabled); // `null` closed, `"new"` create, a row edits it. @@ -109,7 +113,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} @@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { ))} diff --git a/src/components/illness/illness-view.tsx b/src/components/illness/illness-view.tsx index 74855f074..509f9de17 100644 --- a/src/components/illness/illness-view.tsx +++ b/src/components/illness/illness-view.tsx @@ -320,7 +320,8 @@ export function IllnessView() { // v1.36.x — opening an illness entry is an admitted delegated write. Logging // a day against one, editing it, resolving it and deleting it are not, so a // delegate can start the record of an illness and nothing more. - const { canAdd, inSharedRecord } = useRecordCapabilities(); + const { canWriteDomain, inSharedRecord } = useRecordCapabilities(); + const canAddEpisode = canWriteDomain("illness"); const { data: episodes, isLoading, @@ -412,7 +413,7 @@ export function IllnessView() { )} {/* v1.18.6 (MOD-02) — the add button reads "hinzufügen" like every other module, not the bespoke "neue Episode". */} - {canAdd && ( + {canAddEpisode && ( diff --git a/src/components/labs/lab-list.tsx b/src/components/labs/lab-list.tsx index 2f077bee4..e11c4ed98 100644 --- a/src/components/labs/lab-list.tsx +++ b/src/components/labs/lab-list.tsx @@ -93,7 +93,8 @@ function LabRangeBarSlot({ reading }: { reading: LabResultDto }) { export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { const { t } = useTranslations(); - const { canAdd } = useRecordCapabilities(); + const { canWriteDomain } = useRecordCapabilities(); + const canAddLab = canWriteDomain("labs"); const { prefs } = useModuleListPrefs("labs"); // v1.22 — a short, factual line under each marker heading describing what the @@ -184,7 +185,7 @@ export function LabList({ onAddFirst }: { onAddFirst?: () => void } = {}) { title={t("labs.emptyTitle")} description={t("labs.emptyDescription")} action={ - onAddFirst && canAdd ? ( + onAddFirst && canAddLab ? ( ) : undefined } diff --git a/src/components/layout/__tests__/capture-picker-discard.test.tsx b/src/components/layout/__tests__/capture-picker-discard.test.tsx index a2c0b3819..f701b3a94 100644 --- a/src/components/layout/__tests__/capture-picker-discard.test.tsx +++ b/src/components/layout/__tests__/capture-picker-discard.test.tsx @@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/__tests__/capture-picker.test.tsx b/src/components/layout/__tests__/capture-picker.test.tsx index 52a3fb878..ecb0d3463 100644 --- a/src/components/layout/__tests__/capture-picker.test.tsx +++ b/src/components/layout/__tests__/capture-picker.test.tsx @@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: () => true, canManageDomain: () => true, diff --git a/src/components/layout/capture-picker.tsx b/src/components/layout/capture-picker.tsx index c92c0a859..b3415e891 100644 --- a/src/components/layout/capture-picker.tsx +++ b/src/components/layout/capture-picker.tsx @@ -51,24 +51,18 @@ import { type CaptureKind = "measurement" | "medication" | "mood"; /** - * v1.36.x — which of the capture surfaces a delegation admits. + * The section each capture surface writes to. * - * A WRITE grant covers entering a reading and marking a dose. It does not - * cover a mood entry: that verb stays on the owner's own authentication and - * the server refuses it under a switch. The picker is the fastest path into - * all three on a phone, so it is the surface where offering an unadmitted one - * costs the most. - */ -const DELEGABLE_CAPTURE_KINDS: ReadonlySet = new Set([ - "measurement", - "medication", -]); - -/** - * v1.38.12 — the section each capture surface writes to. A MANAGE grant that - * reaches the section's routes is offered the surface; a mood entry is a - * MANAGE create under `mind`, so a guardian gets it and a WRITE delegate does - * not. + * One question per kind, asked of that kind's own section. The picker used to + * ask a coarse one as well — a closed list of kinds a WRITE grant admits, + * offered whenever the grant could write anywhere — and that answer is the + * grant's LEVEL with no scope term at all. A WRITE grant scoped to `["labs"]` + * read as true and got a weight form the server refuses; one scoped to + * `["documents"]`, which takes no delegated write at any level, got both. + * + * `writableDomains` is already the level × scope × route-table intersection, + * so asking it per section answers both cases and the mood entry's + * MANAGE-only create as well. */ const CAPTURE_KIND_DOMAIN: Readonly> = { measurement: "measurements", @@ -117,13 +111,10 @@ export function admittedCaptureKind( * cannot tap the button that opens it. */ export function visibleCaptureKinds( - caps: Pick, + caps: Pick, kinds: ReadonlyArray, ): CaptureKind[] { - return kinds.filter((kind) => { - if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true; - return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind); - }); + return kinds.filter((kind) => caps.canWriteDomain(CAPTURE_KIND_DOMAIN[kind])); } interface CapturePickerProps { diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 300dcec0e..47f9c9fbd 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -224,11 +224,28 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ tourId: "nav-documents", requiresModule: "inboundDocuments", }, - // v1.36.0 — Insights and the Coach carry no `sharedRecord` flag, so they - // drop out under a switch. Both are AI surfaces, and AI egress of a - // person's health data rides the consent THAT person gave for their own - // use; a delegate triggering it would create a consent-shaped act the owner - // never made. Non-delegable in v1, server-side and here (design §4). + // Insights and the Coach carry no `sharedRecord` flag, so they drop out + // under a switch. The reason has changed since v1.36.0 and the old wording + // ("non-delegable, server-side and here") is no longer true of the tiles: + // twenty-seven `/api/insights/*` reads plus `/api/dashboard/summary` and + // `/api/export/health-record` declare `("manage", "record")`, and the + // AI-egress objection they were closed for is answered by construction — + // the record resolver stamps `setDelegatedGenerationSuppressed`, so a + // delegate reads what the owner's own account generated and causes no + // egress. + // + // What keeps the door shut is the OVERVIEW's own reads rather than the + // tiles': `/api/insights/layout` (`insights/layout/route.ts`) and + // `/api/insights/generate` (`insights/generate/route.ts`) both resolve + // `requireAuth()`, which refuses any switch at any level. Opening the entry + // without deciding what those two mean for a delegate — whose saved layout, + // and whether the briefing reads or regenerates — would hand a MANAGE + // delegate a page whose chrome 403s while its tiles answer. That is a + // decision about two more routes, not a flag on this one. + // + // The Coach stays closed on the original argument, unchanged: + // `/api/insights/chat` is genuinely not delegable, and the page is a + // generation surface rather than a read of one. { href: "/insights", tKey: "nav.insights", diff --git a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx index 584e016f4..74b48d6a2 100644 --- a/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx +++ b/src/components/measurement-reminders/__tests__/vorsorge-dashboard-card-error-state.test.tsx @@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({ vi.mock("@/hooks/use-record-capabilities", () => ({ useRecordCapabilities: () => ({ canManage: true, - canAdd: true, + canWriteDomain: () => true, canManageDomain: () => true, }), })); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index efa88d127..d85ad6a8c 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -292,8 +292,9 @@ export function MeasurementList({ // v1.36.x — inside somebody else's record, only the add path survives: // entering a reading is an admitted delegated write, editing and deleting // one are not. The row controls are dropped rather than disabled. - const { canAdd, canManage, inSharedRecord, canWrite } = + const { canWriteDomain, canManage, inSharedRecord, canWrite } = useRecordCapabilities(); + const canAddMeasurement = canWriteDomain("measurements"); const activeRecord = user?.accountAccess?.active ?? null; // Named only when the sentence below is owed: a delegate who may add but // not change. In the caller's own record, and for a read-only delegate who @@ -1068,7 +1069,7 @@ export function MeasurementList({ > {t("measurements.emptyResetFilter")} - ) : onAddFirst && canAdd ? ( + ) : onAddFirst && canAddMeasurement ? ( + ) : null; + return (
} description={t("vaccinations.subtitle")} - actions={ - - } + actions={addButton} /> { // Offer the booster only when the catalogue entry carries an // interval; a free-text or one-off dose never prompts. - if (boosterOfferFor(created)) setBoosterFor(created); + if (canPlanBooster && boosterOfferFor(created)) + setBoosterFor(created); }} /> @@ -102,21 +119,26 @@ export function VaccinationsView() { onRetry={() => void refetch()} /> ) : records.length > 0 ? ( - + ) : ( } title={t("vaccinations.empty.title")} description={t("vaccinations.empty.description")} action={ - + canAddDose ? ( + + ) : undefined } /> )} diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index ef791c475..473404d93 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -10,10 +10,10 @@ * Mutation checks, run: * - return `canManage: true` for a WRITE record → "a delegate may not change * what is already there" goes red. - * - return `canAdd: true` for a READ record → "a read-only delegate adds - * nothing" goes red. - * - make the `!active` branch return `canAdd: false` → "the caller's own - * record keeps everything" goes red. + * - answer `canWriteDomain` from `level` instead of the published list → + * "a read-only delegate adds nothing" and the scoped legs go red. + * - make the `!active` branch answer `canWriteDomain: false` → "the caller's + * own record keeps everything" goes red. * - answer `canManageDomain` from `level === "manage"` instead of the list → * "the vault is never manageable" goes red. */ @@ -76,7 +76,6 @@ describe("resolveRecordCapabilities", () => { expect(own).toMatchObject({ inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, // No grant, so no level and no narrowing — not a fabricated "manage" // over all eight sections, which a consumer would then have to tell @@ -91,10 +90,12 @@ describe("resolveRecordCapabilities", () => { } expect(resolveRecordCapabilities(undefined)).toMatchObject({ inSharedRecord: false, - canAdd: true, canManage: true, recordKind: "self", }); + expect(resolveRecordCapabilities(undefined).canWriteDomain("labs")).toBe( + true, + ); }); it("refuses a malformed published block instead of treating it as own record", () => { @@ -102,7 +103,6 @@ describe("resolveRecordCapabilities", () => { expect(refused).toMatchObject({ inSharedRecord: true, canWrite: false, - canAdd: false, canManage: false, level: null, sections: [], @@ -120,7 +120,6 @@ describe("resolveRecordCapabilities", () => { expect(reader).toMatchObject({ inSharedRecord: true, canWrite: false, - canAdd: false, canManage: false, level: "read", sections: null, @@ -139,7 +138,6 @@ describe("resolveRecordCapabilities", () => { expect(writer).toMatchObject({ inSharedRecord: true, canWrite: true, - canAdd: true, canManage: false, level: "write", sections: null, @@ -155,6 +153,26 @@ describe("resolveRecordCapabilities", () => { } }); + it("a grant that can write nothing is still a WRITE grant, and offers nothing", () => { + // The reason the coarse answer is gone. `canWrite` is the LEVEL and has no + // scope term, so a WRITE grant scoped to the vault — which takes no + // delegated write at any level — answers true while every section answers + // false. A control gating on the level would paint an upload button that + // 403s at `grantCoversDomain`. + const vaultOnly = resolveRecordCapabilities({ + ...WRITABLE, + sections: ["documents"], + writableDomains: [], + manageableDomains: [], + }); + expect(vaultOnly.canWrite).toBe(true); + for (const domain of SHARE_DOMAINS) { + expect(vaultOnly.canWriteDomain(domain), domain).toBe(false); + expect(vaultOnly.canManageDomain(domain), domain).toBe(false); + } + expect(vaultOnly.canManage).toBe(false); + }); + it("carries the level and the sections through untouched", () => { // The two facts v1.37.0 adds are bound, not interpreted. `sections` is // the server's list in the server's order; `level` is the server's word. @@ -177,7 +195,6 @@ describe("resolveRecordCapabilities", () => { expect(manager).toMatchObject({ inSharedRecord: true, canWrite: true, - canAdd: true, canManage: true, level: "manage", sections: null, @@ -219,7 +236,12 @@ describe("resolveRecordCapabilities", () => { access: "write", canWrite: false, }; - expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false); + for (const domain of SHARE_DOMAINS) { + expect( + resolveRecordCapabilities(disagreeing).canWriteDomain(domain), + domain, + ).toBe(false); + } // The same posture for the lists: a scoped WRITE grant is writable only // where its list says so, not wherever its level would reach. const scopedWriter: AccountAccessEntry = { @@ -246,7 +268,7 @@ describe("resolveRecordCapabilities", () => { * every delegable route will refuse, while `active` being null would otherwise * make this hook answer "your own record, all controls". That combination * paints an add button on a page whose every write is about to 403 — the exact - * failure `canAdd` exists to end. + * failure the per-section answers exist to end. */ describe("an unprovable record context withholds every control", () => { it("holds when the raw selector names a record the grant no longer opens", () => { @@ -254,7 +276,9 @@ describe("an unprovable record context withholds every control", () => { recordContextIsUnproven({ epoch: 3, scope: "acct-owner" }, null), ).toBe(true); const held = resolveRecordCapabilities(null, false, false, true); - expect(held.canAdd).toBe(false); + for (const domain of SHARE_DOMAINS) { + expect(held.canWriteDomain(domain), domain).toBe(false); + } expect(held.canManage).toBe(false); // REFUSED, not pending. `recordSessionPending` renders a bare spinner with // no controls, which is right for a switch in flight and a wedge here: this diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index 524c075a8..2aaf563bd 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -43,9 +43,8 @@ const ALWAYS = () => true; * server's table (`domain-write-support.ts`), published per grant as two * lists on `accountAccess.active`. Four answers carry that: * - * - `canAdd` — may this person add one of the admitted kinds. * - `canWriteDomain(domain)` — is there a delegated write in this section - * the grant satisfies. + * the grant satisfies. The only question a create affordance asks. * - `canManageDomain(domain)` — may this person change what is already * there in this section, or add what a WRITE grant does not cover. * - `canManage` — the coarse switch: is there ANY section this person may @@ -53,7 +52,11 @@ const ALWAYS = () => true; * because a control belongs to one section and the answer differs per * section (the vault is read-only under every grant). * - * A surface that offers an admitted create asks `canAdd`. A control that + * A surface that offers an admitted create asks `canWriteDomain` for its own + * section. There is deliberately no coarse "may this person add anything": + * `canWrite` is the grant's level with no scope term, so a WRITE grant scoped + * to `["labs"]` answers true and a weight form built on that answer 403s at + * `grantCoversDomain`. A control that * edits, deletes or creates what WRITE does not cover asks `canManageDomain` * for its own section. A control whose route resolves the caller rather than * the record (settings, credentials, AI budget, chart preferences) asks @@ -92,10 +95,13 @@ export interface RecordCapabilities { accessRefused?: boolean; /** Is this browser acting on somebody else's record right now. */ inSharedRecord: boolean; - /** The grant's resolved level for the record on screen. */ + /** + * The grant's resolved level for the record on screen — LEVEL ONLY, with no + * scope term. A WRITE grant scoped to the document vault answers true here + * and can write nothing anywhere. Never gate a control on it; ask + * {@link canWriteDomain} for the control's own section. + */ canWrite: boolean; - /** May the caller add an entry of a kind the delegation admits. */ - canAdd: boolean; /** * May the caller change what exists somewhere in this record, or add what * the delegation excludes. In one's own record, always. In a shared record, @@ -166,7 +172,6 @@ export function resolveRecordCapabilities( recordSessionPending: recordSessionPending || undefined, inSharedRecord: true, canWrite: false, - canAdd: false, canManage: false, canWriteDomain: NEVER, canManageDomain: NEVER, @@ -179,7 +184,6 @@ export function resolveRecordCapabilities( return { inSharedRecord: false, canWrite: false, - canAdd: true, canManage: true, canWriteDomain: ALWAYS, canManageDomain: ALWAYS, @@ -201,7 +205,6 @@ export function resolveRecordCapabilities( return { inSharedRecord: true, canWrite: active.canWrite, - canAdd: active.canWrite, canManage: manageable.size > 0, canWriteDomain: (domain) => writable.has(domain), canManageDomain: (domain) => manageable.has(domain), @@ -243,7 +246,7 @@ export function resolveRecordCapabilities( * opens: every delegable route will refuse, while `active` being null makes * this hook answer "your own record, all controls". That combination paints an * add button on a page whose every write is about to 403 — the exact failure - * `canAdd` was introduced to end. So a disagreement holds instead. + * the per-section answers exist to end. So a disagreement holds instead. * * A null or absent `recordSession` is not a disagreement. It is the Bearer * transport (no session row, no switch state) or a server image that predates diff --git a/src/lib/encounters/service.ts b/src/lib/encounters/service.ts index 705596437..7f080ca56 100644 --- a/src/lib/encounters/service.ts +++ b/src/lib/encounters/service.ts @@ -18,7 +18,6 @@ import { replaceTargets, type LinkTargetKind, } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { EncounterLinksDTO } from "@/lib/encounters/dto"; import { @@ -59,27 +58,6 @@ export const ENCOUNTER_INCLUDE = { reminder: { select: { nextDueAt: true } }, } as const satisfies Prisma.EncounterInclude; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * Which section of the record each link family's TARGET belongs to. * diff --git a/src/lib/monitoring/glitchtip-delivery.ts b/src/lib/monitoring/glitchtip-delivery.ts index c14f15749..c18ea0020 100644 --- a/src/lib/monitoring/glitchtip-delivery.ts +++ b/src/lib/monitoring/glitchtip-delivery.ts @@ -110,23 +110,3 @@ export function summariseGlitchtipDelivery( windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS, }; } - -export async function getGlitchtipDeliveryState(): Promise { - try { - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, - select: { - glitchtipLastOkAt: true, - glitchtipLastFailureAt: true, - glitchtipLastFailureReason: true, - }, - }); - return { - lastOkAt: settings?.glitchtipLastOkAt ?? null, - lastFailureAt: settings?.glitchtipLastFailureAt ?? null, - lastFailureReason: settings?.glitchtipLastFailureReason ?? null, - }; - } catch { - return { lastOkAt: null, lastFailureAt: null, lastFailureReason: null }; - } -} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 3cbeda8c8..49080d36f 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -4,10 +4,11 @@ * `meta.errorCode` is the mechanism this API chose for machine-readable errors * — the envelope's own description tells a client to branch on it rather than * on the prose — and until this file existed a client could not enumerate it. - * Sixteen of the two hundred and forty-two codes appeared somewhere in - * `docs/api/openapi.yaml`; the rest existed only in server source, so the only - * way to learn one was to trigger the error in testing and the only way to - * learn a new one had appeared was to trigger that too. + * When this file was written, sixteen of its two hundred and forty-two codes + * appeared somewhere in `docs/api/openapi.yaml`; the rest existed only in + * server source, so the only way to learn one was to trigger the error in + * testing and the only way to learn a new one had appeared was to trigger + * that too. * * The list is generated from the code and then committed as a literal, so it is * reviewable in a diff and stable across a release. `error-code-catalogue.test.ts` @@ -297,6 +298,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = vaccination: [ "vaccination.booster-invalid", "vaccination.booster-no-antigen", + "vaccination.booster-out-of-scope", "vaccination.encounter-not-found", "vaccination.identity-required", "vaccination.invalid", diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index f251500e6..f0d03b608 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -874,7 +874,7 @@ export const cyclePaths: NonNullable = { tags: ["Cycle"], summary: "Cycle-phase correlation insights (v1.15.0)", description: - "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off.", + "FDR-guarded luteal-vs-follicular phase contrast per outcome metric (RHR / HRV / sleep / steps / weight / temperatures), plus the single headline finding (resting-heart-rate-by-phase, falling back to HRV). The same Welch t-test + Benjamini-Hochberg machinery the mood-factor crosstab runs; only rows with p < 0.05 AND q ≤ 0.10 surface. Strictly gender-gated — phase never appears on the general `/api/insights/correlations` route. Observational only, never causal. Gated: `cycle.disabled` 403 when the feature is off. Declared for the `cycle` section; the outcome columns are fenced per section at the query, so a grant scoped to the cycle alone receives the phase rows with no `lutealAvg` / `follicularAvg` / `delta` for measurement or mood outcomes and no lagged discovery, exactly as a record with nothing logged would read. The owner and a whole-record grant are unchanged.", responses: { "200": { description: "Phase-correlation rows + headline.", diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index 6cc215f33..dfd157c28 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -20,7 +20,11 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { backupPayloadSchema } from "@/lib/validations/backup"; -import { errorEnvelope, stdResponses } from "./shared"; +import { + errorEnvelope, + SHARING_NOT_PERMITTED_DESCRIPTION, + stdResponses, +} from "./shared"; const exportQuery = z .object({ @@ -305,6 +309,12 @@ export const exportPaths: NonNullable = { }, }, }, + "403": { + description: + "The Bearer token carries a narrow scope. The encrypted archive needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/mental-health.ts b/src/lib/openapi/routes/mental-health.ts index 6e9080da6..6687f11c6 100644 --- a/src/lib/openapi/routes/mental-health.ts +++ b/src/lib/openapi/routes/mental-health.ts @@ -135,7 +135,8 @@ export const mentalHealthPaths: NonNullable = { tags: ["Mental health"], summary: "Record a completed screener", description: - "Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.", + "Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.\n\n" + + "The projection row is the one write this operation makes outside its declared section: the operation is fenced on `mind`, and the measurement it derives lands in `measurements`. It is deliberate and unconditional — a screener whose own total is missing from the record would be an inconsistency inside the section the grant does open. The consent copy for the mood-and-mind section names it.", parameters: [idempotencyKeyParameter], requestBody: { content: { diff --git a/src/lib/openapi/routes/settings.ts b/src/lib/openapi/routes/settings.ts index 5edde619c..224f66438 100644 --- a/src/lib/openapi/routes/settings.ts +++ b/src/lib/openapi/routes/settings.ts @@ -19,6 +19,7 @@ import { dataEnvelope, errorEnvelope, recordRefusal, + SHARING_NOT_PERMITTED_DESCRIPTION, stdResponses, } from "./shared"; @@ -615,7 +616,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, @@ -801,7 +803,8 @@ export const settingsPaths: NonNullable = { }, "403": { description: - "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.", + "The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" + + SHARING_NOT_PERMITTED_DESCRIPTION, content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 09f524625..a97b71a82 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -431,6 +431,25 @@ export function idempotentWrite(): IdempotentConflictResponse { export const SHARING_ACCESS_DENIED_DESCRIPTION = "Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it."; +/** + * The OTHER sharing refusal, for a route that names no record at all. + * + * A route resolving through `requireAuth` serves the caller and only the + * caller, and refuses outright while the browser is inside somebody else's + * record rather than quietly answering with the caller's own rows. That + * posture matters most on the routes where being wrong is unrecoverable — the + * record wipe, the account deletion, the encrypted archive — and it was + * documented nowhere on them. A client that switches records has to know which + * of its calls stop working, and finding out by wiping the wrong record is not + * a contract. + * + * One sentence, spliced onto whatever else the operation's 403 already says, + * for the same reason the delegable refusal is one string: a paraphrase per + * path invites a client to tell them apart. + */ +export const SHARING_NOT_PERMITTED_DESCRIPTION = + "Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it."; + /** * The 403 above, optionally sharing the status with the reasons a route already * refuses for. diff --git a/src/lib/openapi/routes/vaccinations.ts b/src/lib/openapi/routes/vaccinations.ts index de80ef54d..3a4708dae 100644 --- a/src/lib/openapi/routes/vaccinations.ts +++ b/src/lib/openapi/routes/vaccinations.ts @@ -364,14 +364,17 @@ export const vaccinationPaths: NonNullable = { tags: ["Records"], summary: "Plan a booster reminder from a dose", description: - "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.", + "Mints (or re-anchors) the booster reminder a logged dose suggests. The minted row is an ordinary `origin: VORSORGE` measurement reminder carrying the dose's primary antigen as a server-side match key; it lists on the preventive-care surface and rings through the same engine as every other checkup. Re-confirming re-anchors the existing reminder instead of minting a second one: 201 carries a fresh mint (`minted: true`), 200 a re-anchor. A free-text-only dose has no antigen to remind on and is refused with 422. Audits as `vaccination.booster.planned`.\n\n" + + "The operation is declared under the health background and the row it mints belongs to the measurements section, so it crosses a section boundary: a delegate whose grant does not also open `measurements` is refused with 403 `vaccination.booster-out-of-scope` and nothing is written. The dose itself still logs — only the booster does not.", requestParams: idPath, requestBody: { required: true, content: { "application/json": { schema: vaccinationBoosterRequest } }, }, responses: { - ...recordRefusal(), + ...recordRefusal( + "Refused: the caller's grant does not open this record's measurements, and the booster reminder lives there (`meta.errorCode` = `vaccination.booster-out-of-scope`). Nothing was written.", + ), "200": { description: "Existing booster reminder re-anchored.", content: { diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 672484ac7..41fb46cad 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -96,19 +96,39 @@ export interface AccountAccessEntry { */ sections: ShareDomain[] | null; /** - * May this caller ADD to that record. Resolved server-side. It does not by - * itself mean edit or delete — a WRITE grant adds and does nothing more, and - * that stayed true when a third level arrived. A MANAGE grant answers true - * here as well, and what it additionally admits is said by `level` rather - * than smuggled into this boolean. + * The grant's LEVEL, resolved server-side: is it at or above WRITE. + * + * It is not "may this caller add something", and reading it that way is how + * section-blind controls happen. There is no scope term in it: a WRITE grant + * scoped to the document vault answers `true` here with + * `writableDomains: []`, because the vault takes no delegated write at any + * level. Ask {@link AccountAccessEntry.writableDomains} for the section the + * control belongs to; this boolean answers only whether the grant is a + * reading one. + * + * It does not by itself mean edit or delete — a WRITE grant adds and does + * nothing more, and that stayed true when a third level arrived. A MANAGE + * grant answers true here as well, and what it additionally admits is said + * by `level` rather than smuggled into this boolean. */ canWrite: boolean; /** - * v1.38.12 — the sections this grant can add to, resolved server-side from - * the grant's level and scope against the delegated write routes that - * exist (`domain-write-support.ts`). In the consent screen's order. Empty - * for a READ grant, and empty for any section whose routes accept no - * delegated write at all — the vault, today. + * v1.38.12 — the sections with AT LEAST ONE delegated write route this + * grant satisfies, resolved server-side from its level and scope against + * the routes that exist (`domain-write-support.ts`). In the consent + * screen's order. Empty for a READ grant, and empty for any section whose + * routes accept no delegated write at all — the vault, today. + * + * Existential, and the word matters: within a section the answer varies by + * verb, and this list cannot say which. `profile` is writable because a + * visit, a practitioner and a dose answer at WRITE, while an allergy and a + * family-history entry are MANAGE creates; `measurements` is writable + * because a reading is, while a water entry and a checkup reminder are + * MANAGE. So a control does not read "my section is writable, therefore my + * create is": it asks the hook for its own verb class — + * `canWriteDomain` for a create the WRITE level covers, `canManageDomain` + * for one it does not. A per-verb table would be a second place the route + * declarations live, and the declarations are the thing that decides. * * A list rather than a boolean because the answer differs per section, and * the client's per-control question is "this section", never "any". diff --git a/src/lib/sharing/acting-domains.ts b/src/lib/sharing/acting-domains.ts new file mode 100644 index 000000000..039062bc2 --- /dev/null +++ b/src/lib/sharing/acting-domains.ts @@ -0,0 +1,33 @@ +/** + * Which sections of the record the caller is actually inside. + * + * Lives here rather than beside one feature because a route that reads or + * writes across its own declared section is a shape the product keeps growing, + * and its callers are already spread over four features. No list of them here: + * a comment that enumerates its own callers is a comment that goes stale on + * the next one. Two verbatim copies of this existed in the visits and the + * vaccination service, with a note on the second saying a second spelling + * would be a second thing to keep in step. + * + * Returns a predicate rather than a set so the owner path costs nothing: with + * no grant there is nothing to look up and every domain is open. A grant that + * vanished between the auth check and here answers "no" to everything, which + * is the fail-closed direction. + */ +import type { Prisma } from "@/generated/prisma/client"; + +import { grantCoversDomain } from "@/lib/sharing/grants"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +export async function actingDomainVisibility( + tx: Prisma.TransactionClient, + grantId: string | null, +): Promise<(domain: ShareDomain) => boolean> { + if (grantId === null) return () => true; + const grant = await tx.accountGrant.findUnique({ + where: { id: grantId }, + select: { scopeJson: true }, + }); + if (!grant) return () => false; + return (domain) => grantCoversDomain(grant, domain); +} diff --git a/src/lib/sharing/domain-write-support.ts b/src/lib/sharing/domain-write-support.ts index ad182b434..f5a2725e8 100644 --- a/src/lib/sharing/domain-write-support.ts +++ b/src/lib/sharing/domain-write-support.ts @@ -74,6 +74,15 @@ const LEVEL_RANK: Record = { * `sections === null` is the entire record. A MANAGE grant always carries it * (`grants.ts` refuses a scope on a MANAGE invitation), a WRITE grant may. * + * The answer is EXISTENTIAL per section, never per verb: "is there a route in + * this section at this need the grant satisfies". Three of the four sections + * that answer `write: true` also carry MANAGE-only creates — an allergy and a + * family-history entry under `profile`, a water entry and a checkup reminder + * under `measurements`, a snooze and a re-recorded dose under `medications` — + * and no list keyed on the section can distinguish them. A control asks the + * hook for its own verb class instead; the route declarations stay the one + * place the split is decided. + * * For `need === "write"` the answer is "is there a non-read route in this * section the grant satisfies": a WRITE grant reaches the section's `"write"` * routes, a MANAGE grant reaches those and its `"manage"` routes as well. diff --git a/src/lib/sharing/scope.ts b/src/lib/sharing/scope.ts index 784297cf4..5ea941070 100644 --- a/src/lib/sharing/scope.ts +++ b/src/lib/sharing/scope.ts @@ -68,7 +68,16 @@ export const SHARE_DOMAINS = [ "profile", /** Illness episodes and the day logs under them. */ "illness", - /** Mood entries and mental-health screener history. */ + /** + * Mood entries and mental-health screener history. + * + * One write in this section lands outside it, and the consent copy names it: + * completing a screener also files the instrument's total as a `COMPUTED` + * measurement, so the trend reads through the chart and rollup tier without + * the encrypted item content going anywhere. It is a projection of the + * administration rather than a second reading, and withholding it would file + * a screener whose own total is missing from the record. + */ "mind", /** Cycle tracking. */ "cycle", diff --git a/src/lib/vaccinations/service.ts b/src/lib/vaccinations/service.ts index 8d929ff5f..8d2212bd2 100644 --- a/src/lib/vaccinations/service.ts +++ b/src/lib/vaccinations/service.ts @@ -11,7 +11,6 @@ import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { listTargets, replaceTargets } from "@/lib/links"; -import { grantCoversDomain } from "@/lib/sharing/grants"; import type { ShareDomain } from "@/lib/sharing/scope"; import type { VaccinationDocumentDTO } from "@/lib/vaccinations/dto"; import { @@ -37,29 +36,6 @@ export const SERIES_SELECT = { seriesDoses: true, } as const satisfies Prisma.VaccinationRecordSelect; -/** - * Which sections of the record the caller is actually inside. - * - * Returns a predicate rather than a set so the owner path costs nothing: with - * no grant there is nothing to look up and every domain is open. A grant that - * vanished between the auth check and here answers "no" to everything, which - * is the fail-closed direction. Same shape as the visits service, deliberately - * — the two features sit on the same seam and a second spelling of this would - * be a second thing to keep in step. - */ -export async function actingDomainVisibility( - tx: Prisma.TransactionClient, - grantId: string | null, -): Promise<(domain: ShareDomain) => boolean> { - if (grantId === null) return () => true; - const grant = await tx.accountGrant.findUnique({ - where: { id: grantId }, - select: { scopeJson: true }, - }); - if (!grant) return () => false; - return (domain) => grantCoversDomain(grant, domain); -} - /** * The whole live history for one account, in the shape the derivation wants. * diff --git a/tests/integration/cycle-insights-scoped-delegate.test.ts b/tests/integration/cycle-insights-scoped-delegate.test.ts new file mode 100644 index 000000000..482afc6f5 --- /dev/null +++ b/tests/integration/cycle-insights-scoped-delegate.test.ts @@ -0,0 +1,329 @@ +/** + * What a cycle-only delegate learns from `GET /api/cycle/insights`. + * + * The route is declared `("read", "cycle")`, and everything about the phase + * labels belongs there. Its outcome columns do not: `lutealAvg`, + * `follicularAvg` and `delta` carry resting heart rate, HRV, sleep, steps, + * weight, temperature, glucose and the mood score in display units, and those + * are `measurements` and `mind` data reached through a cycle grant. + * + * So two properties, both about somebody holding `["cycle"]` and nothing else: + * + * 1. **No foreign figure crosses the seam.** No row whose `metricKey` names + * a measurement channel, no mood row, and no lagged discovery built out + * of either. + * 2. **The cycle-native answer still arrives.** The symptom-by-phase + * patterns are cycle data and stay, so the fix is a fence rather than a + * blanket refusal. + * + * The unscoped grant is asserted in the same fixture, because a change that + * hid the figures from everyone would satisfy the first property and break + * the feature. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "cycle-insights-owner"; +const DELEGATE_ID = "cycle-insights-delegate"; + +/** Cycle geometry the fixture logs, chosen so both phases clear the day floor. */ +const CYCLE_LENGTH = 28; +const PERIOD_LENGTH = 5; +const OBSERVED_CYCLES = 5; + +function isoDay(offsetDays: number): string { + const d = new Date(Date.now() + offsetDays * 24 * 60 * 60 * 1000); + return d.toISOString().slice(0, 10); +} + +function atNoon(day: string): Date { + return new Date(`${day}T12:00:00Z`); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +/** Sign in as the delegate and switch into the owner's record. */ +async function switchInto(scope: ShareDomain[] | null) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +/** + * Five observed cycles, then a weight and a mood value on every labelled day + * whose size depends on the phase. + * + * The separation is deliberately huge (80 kg vs 70 kg, mood 2 vs 4) so the + * Welch + Benjamini-Hochberg gate the route runs cannot be the reason a row is + * missing: with this fixture a missing row means the fence removed it. + */ +async function seedRecord() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `cycle-insights-${name}`, + email: `cycle-insights-${name}@example.test`, + gender: "FEMALE", + timezone: "UTC", + }, + }); + } + + // Prediction off, so the phase map the route builds is the one this fixture + // can reproduce from the logged cycles alone. + await prisma.cycleProfile.create({ + data: { + userId: OWNER_ID, + cycleTrackingEnabled: true, + predictionEnabled: false, + lutealPhaseLength: 14, + }, + }); + + const starts: string[] = []; + for (let i = OBSERVED_CYCLES; i >= 1; i--) { + starts.push(isoDay(-i * CYCLE_LENGTH)); + } + for (const [index, startDate] of starts.entries()) { + const next = starts[index + 1] ?? null; + await prisma.menstrualCycle.create({ + data: { + userId: OWNER_ID, + startDate, + endDate: next + ? isoDay(-(OBSERVED_CYCLES - index) * CYCLE_LENGTH - 1 + CYCLE_LENGTH) + : null, + periodEndDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + PERIOD_LENGTH - 1, + ), + ovulationDate: isoDay( + -(OBSERVED_CYCLES - index) * CYCLE_LENGTH + CYCLE_LENGTH - 15, + ), + tz: "UTC", + }, + }); + } + + const { buildPhaseDayMap } = await import("@/lib/cycle/engine-adapter"); + const cycles = await prisma.menstrualCycle.findMany({ + where: { userId: OWNER_ID }, + orderBy: { startDate: "asc" }, + }); + const today = isoDay(0); + const phaseByDay = buildPhaseDayMap( + cycles, + null, + 14, + isoDay(-OBSERVED_CYCLES * CYCLE_LENGTH), + today, + today, + ); + + // One catalogue symptom, logged on enough phase-labelled days to clear the + // pattern floor. Without it `symptomPatterns` is empty in every leg and the + // "the cycle-native answer still arrives" property could not be shown even + // by a leg that asserted it. + // + // Upserted rather than created: the symptom catalogue is seeded by migration + // and survives the per-test truncation, so a plain create passes on the + // first leg and collides on the second. + const category = await prisma.cycleSymptomCategory.upsert({ + where: { key: "cycle-insights-cat" }, + update: {}, + create: { key: "cycle-insights-cat", labelKey: "cycle.category.test" }, + }); + const symptom = await prisma.cycleSymptom.upsert({ + where: { key: "cycle-insights-cramps" }, + update: {}, + create: { + categoryId: category.id, + key: "cycle-insights-cramps", + labelKey: "cycle.symptom.cramps", + }, + }); + + let luteal = 0; + let follicular = 0; + for (const [day, phase] of phaseByDay) { + if (phase !== "LUTEAL" && phase !== "FOLLICULAR") continue; + const high = phase === "LUTEAL"; + if (high) luteal++; + else follicular++; + // A little jitter in both channels, so the Welch test has a variance to + // work with and the row is not dropped for a reason the fence did not + // cause. + const jitter = ((luteal + follicular) % 3) * 0.1; + await prisma.measurement.create({ + data: { + userId: OWNER_ID, + type: "WEIGHT", + value: (high ? 80 : 70) + jitter, + unit: "kg", + measuredAt: atNoon(day), + }, + }); + await prisma.moodEntry.create({ + data: { + userId: OWNER_ID, + mood: high ? "SCHLECHT" : "GUT", + score: (high ? 1 : 4) + ((luteal + follicular) % 2), + date: day, + moodLoggedAt: atNoon(day), + tz: "UTC", + }, + }); + // Cycle-native data, logged on the luteal days only so the pattern has a + // dominant phase to report. + if (high) { + await prisma.cycleDayLog.create({ + data: { + userId: OWNER_ID, + date: day, + tz: "UTC", + symptomLinks: { create: [{ symptomId: symptom.id }] }, + }, + }); + } + } + + expect( + luteal, + "the fixture labels enough luteal days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); + expect( + follicular, + "the fixture labels enough follicular days to clear the crosstab floor", + ).toBeGreaterThanOrEqual(5); +} + +interface InsightsBody { + rows: Array<{ metricKey: string }>; + headline: { metricKey: string } | null; + lagged: { discovered: unknown[] }; + symptomPatterns: Array<{ symptomKey: string; topPhase: string }>; +} + +async function readInsights(): Promise { + const { GET } = await import("@/app/api/cycle/insights/route"); + // The handler declares no parameters; the wrapper still reads the request + // off its first argument, and the method it carries is what the record + // resolver escalates on. + const handler = GET as unknown as (request: Request) => Promise; + const res = await handler(new Request("http://localhost/api/cycle/insights")); + expect(res.status).toBe(200); + const parsed = (await res.json()) as { data: InsightsBody }; + return parsed.data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecord(); +}); + +describe("cycle insights fence the sections it reads across", () => { + it("gives the owner the measurement and the mood contrast", async () => { + await signIn(OWNER_ID); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys, "the weight contrast is the fixture's whole point").toContain( + "weight", + ); + expect(keys, "and the mood contrast beside it").toContain("mood"); + expect( + body.symptomPatterns.map((row) => row.symptomKey), + "and the cycle-native patterns the fence must not touch", + ).toContain("cycle-insights-cramps"); + }); + + it("gives an entire-record delegate the same figures", async () => { + await switchInto(null); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect(keys).toContain("weight"); + expect(keys).toContain("mood"); + }); + + it("gives a cycle-only delegate no measurement and no mood figure", async () => { + await switchInto(["cycle"]); + const body = await readInsights(); + + expect( + body.rows, + "every crosstab row is a measurement or a mood column", + ).toHaveLength(0); + expect(body.headline).toBeNull(); + expect( + body.lagged.discovered, + "the lagged matrix is built from the same two sections", + ).toHaveLength(0); + // The other half of the property, and the one a blanket refusal would + // break: the symptom-by-phase patterns are cycle data and the grant opens + // cycle, so they arrive in full. + expect( + body.symptomPatterns.map((row) => row.symptomKey), + "the cycle-native answer still arrives", + ).toContain("cycle-insights-cramps"); + }); + + it("gives a cycle+mind delegate the mood figure and not the weight", async () => { + await switchInto(["cycle", "mind"]); + const body = await readInsights(); + + const keys = body.rows.map((row) => row.metricKey); + expect( + keys, + "mind is held, so the mood column is within the grant", + ).toContain("mood"); + expect(keys, "measurements is not held").not.toContain("weight"); + }); +}); diff --git a/tests/integration/record-settings-authorization.test.ts b/tests/integration/record-settings-authorization.test.ts index e4f4341d2..f36d0438e 100644 --- a/tests/integration/record-settings-authorization.test.ts +++ b/tests/integration/record-settings-authorization.test.ts @@ -79,7 +79,17 @@ describe("record settings authorization", () => { expect(route).toContain("details: { changed }"); expect(route).not.toContain("details: { recordId:"); expect(route).toContain("invalidateUserMedications(access.recordId, {"); - expect(route).toContain("invalidateUserHealthScore(access.recordId)"); + // The module write is one record-keyed function shared with the setup + // flow's derivation; the health-score invalidation lives there, so the + // route must call it with the record and the helper must still invalidate. + expect(route).toContain("writeRecordModulePreferences({"); + expect(route).toContain("recordId: access.recordId,"); + expect(route).not.toContain("invalidateUserHealthScore("); + const moduleWrite = await readFile( + fromRoot("src/lib/record-settings/modules.ts"), + "utf8", + ); + expect(moduleWrite).toContain("invalidateUserHealthScore(input.recordId)"); expect(route).toContain("getAllEffectiveRanges(record, overrides)"); expect(route).toContain( "effective: getAllEffectiveRanges(record, overrides)", diff --git a/tests/integration/switched-session-destructive-refusal.test.ts b/tests/integration/switched-session-destructive-refusal.test.ts new file mode 100644 index 000000000..dfd48f675 --- /dev/null +++ b/tests/integration/switched-session-destructive-refusal.test.ts @@ -0,0 +1,183 @@ +/** + * The three destructive account routes, driven with a live record switch on. + * + * `DELETE /api/settings/data` wipes a record, `DELETE /api/settings/account` + * erases an account, and `POST /api/export/encrypted` puts a whole record into + * one file. None of them names a record: they resolve the CALLER and act on + * whatever that caller owns. So the question a switched session asks of them is + * not "may I do this to the record I am inside" — it is "what happens when the + * browser believes it is somewhere else and the route does not check". + * + * The answer must be a refusal, and the same one every undeclared route gives: + * 403, `sharing.not_permitted`, "cannot be used while acting on another + * account". Silently serving the delegate's own record is the failure this + * pins against, and it is the worst-shaped one in the product — a person who + * believes they are looking after somebody else's record presses "delete + * everything" and erases their own. + * + * Both halves are asserted: the status AND that nothing moved. A refusal that + * happens after the transaction would satisfy the first alone. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "switch-destructive-owner"; +const DELEGATE_ID = "switch-destructive-delegate"; + +/** The delegate's own reading. It must still be there afterwards. */ +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `switch-destructive-${name}`, + email: `switch-destructive-${name}@example.test`, + timezone: "UTC", + }, + }); + } + await prisma.measurement.create({ + data: { + userId: DELEGATE_ID, + type: "WEIGHT", + value: 72, + unit: "kg", + measuredAt: new Date(), + }, + }); +} + +/** + * Sign the delegate in and switch into the owner's record at MANAGE — the + * widest grant the product mints, so nothing below can be explained away by + * the grant simply being too narrow. + */ +async function switchIntoOwnersRecord() { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access: "MANAGE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await getPrismaClient().session.create({ + data: { + userId: DELEGATE_ID, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, OWNER_ID); +} + +/** The one refusal all three must give. */ +async function expectSwitchRefusal(response: Response) { + expect(response.status).toBe(403); + const body = (await response.json()) as { + error: string; + meta?: { errorCode?: string }; + }; + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + expect(body.error).toBe( + "This endpoint cannot be used while acting on another account", + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); + await switchIntoOwnersRecord(); +}); + +describe("a switched session reaches none of the record-wide destructive routes", () => { + it("refuses the record wipe and wipes neither record", async () => { + const { DELETE } = await import("@/app/api/settings/data/route"); + await expectSwitchRefusal( + await DELETE( + new Request("http://localhost/api/settings/data", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE" }), + }) as never, + ), + ); + + const prisma = getPrismaClient(); + expect( + await prisma.measurement.count({ where: { userId: DELEGATE_ID } }), + "the delegate's own reading survived", + ).toBe(1); + expect( + await prisma.user.count({ + where: { id: { in: [OWNER_ID, DELEGATE_ID] } }, + }), + ).toBe(2); + }); + + it("refuses the account deletion and deletes neither account", async () => { + const { DELETE } = await import("@/app/api/settings/account/route"); + await expectSwitchRefusal( + await DELETE( + new Request("http://localhost/api/settings/account", { + method: "DELETE", + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }) as never, + ), + ); + + expect( + await getPrismaClient().user.count({ + where: { id: { in: [OWNER_ID, DELEGATE_ID] } }, + }), + ).toBe(2); + }); + + it("refuses the encrypted export and hands out no archive", async () => { + const { POST } = await import("@/app/api/export/encrypted/route"); + const response = await POST( + new Request("http://localhost/api/export/encrypted", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ passphrase: "a-long-enough-passphrase-123" }), + }) as never, + ); + await expectSwitchRefusal(response); + // Not an archive under any content type: a refusal that still streamed the + // bytes would read as a 403 to a status check and as an export to a user. + expect(response.headers.get("content-type")).not.toContain( + "application/octet-stream", + ); + }); +}); diff --git a/tests/integration/vaccination-booster-scoped-delegate.test.ts b/tests/integration/vaccination-booster-scoped-delegate.test.ts new file mode 100644 index 000000000..3427956f0 --- /dev/null +++ b/tests/integration/vaccination-booster-scoped-delegate.test.ts @@ -0,0 +1,166 @@ +/** + * `POST /api/vaccinations/{id}/booster` crosses a section boundary, and the + * grant has to be on both sides of it. + * + * The operation is declared `("write", "profile")`, which is right for the act + * — filing the Impfpass and arming the booster it suggests are one thing to + * the person doing them. The ROW it mints is not: a `MeasurementReminder` + * belongs to the measurements section, every direct route over that model says + * so, and creating one there needs MANAGE. So a grant opening the health + * background alone would otherwise produce a reminder on the owner's checkup + * list that the delegate can neither read nor change through the section that + * owns it. + * + * Refused rather than silently skipped: the reminder is the whole request, and + * a 201 over a row that was never written would tell the person their booster + * is planned while nothing rings. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { NextRequest } from "next/server"; + +import type { ShareDomain } from "@/lib/sharing/scope"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const OWNER_ID = "booster-scope-owner"; +const DELEGATE_ID = "booster-scope-delegate"; + +/** A catalogue antigen that carries a booster interval. */ +const ANTIGEN = "tetanus"; + +let doseId: string; + +async function seedRecords() { + const prisma = getPrismaClient(); + for (const [id, name] of [ + [OWNER_ID, "owner"], + [DELEGATE_ID, "delegate"], + ] as const) { + await prisma.user.create({ + data: { + id, + username: `booster-scope-${name}`, + email: `booster-scope-${name}@example.test`, + timezone: "UTC", + }, + }); + } + const dose = await prisma.vaccinationRecord.create({ + data: { + userId: OWNER_ID, + occurredAt: new Date("2026-03-01T00:00:00.000Z"), + antigenSlug: ANTIGEN, + }, + }); + doseId = dose.id; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function switchInto( + access: "WRITE" | "MANAGE", + scope: ShareDomain[] | null, +) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: OWNER_ID, + granteeId: DELEGATE_ID, + access, + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID }); + const session = await signIn(DELEGATE_ID); + await switchSessionTo(session.id, OWNER_ID); +} + +async function planBooster(): Promise { + const { POST } = await import("@/app/api/vaccinations/[id]/booster/route"); + return POST( + new NextRequest(`http://localhost/api/vaccinations/${doseId}/booster`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ intervalMonths: 120, label: "Tetanus booster" }), + }), + { params: Promise.resolve({ id: doseId }) }, + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + await seedRecords(); +}); + +describe("planning a booster asks for the section the reminder lives in", () => { + it("mints for the owner", async () => { + await signIn(OWNER_ID); + expect((await planBooster()).status).toBe(201); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(1); + }); + + it("mints for a delegate holding both sections", async () => { + await switchInto("WRITE", ["profile", "measurements"]); + expect((await planBooster()).status).toBe(201); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + ).toBe(1); + }); + + it("refuses a profile-only delegate and writes no reminder", async () => { + await switchInto("WRITE", ["profile"]); + const response = await planBooster(); + expect(response.status).toBe(403); + const body = (await response.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("vaccination.booster-out-of-scope"); + expect( + await getPrismaClient().measurementReminder.count({ + where: { userId: OWNER_ID }, + }), + "no reminder was left on the owner's checkup list", + ).toBe(0); + }); + + it("mints for a whole-record delegate", async () => { + await switchInto("MANAGE", null); + expect((await planBooster()).status).toBe(201); + }); +});