Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard

# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/error-codes.ts
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 18:20:07 +02:00
commit 55ff6df205
87 files changed
+2080 -399

No files matched your search

+132
View File
@@ -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:<accountId>` 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
+77 -13
View File
@@ -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,
+42 -18
View File
@@ -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
+16 -6
View File
@@ -231,8 +231,8 @@ async function openSeedingSurface(page: Page): Promise<void> {
}
/**
* 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<void> {
* 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.
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -10609,7 +10609,7 @@
},
"mind": {
"label": "기분과 마음",
"detail": "기분 기록과 정신건강 설문 이력이에요."
"detail": "기분 기록과 정신건강 설문 이력이에요. 작성한 설문은 총점도 측정값으로 함께 저장돼요."
},
"cycle": {
"label": "생리 주기",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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`
+67
View File
@@ -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");
}
@@ -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([]);
});
});
+72 -11
View File
@@ -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));
@@ -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");
+6
View File
@@ -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,
+51 -29
View File
@@ -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,
},
});
+2 -4
View File
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+1 -1
View File
@@ -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");
@@ -159,6 +159,17 @@ async function postAssessment(request: NextRequest): Promise<Response> {
// 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:<id>`, 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.
+26 -1
View File
@@ -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,
});
+2 -4
View File
@@ -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 }> };
@@ -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 }> };
+1 -1
View File
@@ -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 }> };
+1 -3
View File
@@ -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;
+1 -3
View File
@@ -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;
+1 -3
View File
@@ -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;
+4 -3
View File
@@ -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() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : canAdd ? (
) : canAddLab ? (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
+5 -4
View File
@@ -35,7 +35,8 @@ export function resolveMeasurementReturnTo(
export default function MeasurementsPage() {
const { isAuthenticated, isLoading } = useAuth();
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -148,7 +149,7 @@ export default function MeasurementsPage() {
// admits, so this is the header action a delegate keeps. A read-only
// delegate loses it entirely rather than meeting a disabled one.
actions={
canAdd ? (
canAddMeasurement ? (
<Button
data-slot="measurement-add"
className="min-h-11 sm:min-h-9"
@@ -168,12 +169,12 @@ export default function MeasurementsPage() {
answers. `?add=<TYPE>` opens it without passing the button, and a
deep link is the same affordance as the control that produces it, so
it gets the same gate. Gating the open rather than only the param
also covers the first-paint window: `canAdd` reads true until
also covers the first-paint window: `canAddMeasurement` reads true until
`/api/auth/me` settles, and a sheet opened in that frame withdraws
when the answer lands instead of standing on a form the server
refuses. */}
<ResponsiveSheet
open={dialogOpen && canAdd}
open={dialogOpen && canAddMeasurement}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
@@ -48,6 +48,7 @@ vi.mock("@/components/medications/log-intake-dialog", () => ({
import MedicationsPage from "@/app/medications/page-client";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const OWNER = {
accountId: "acct-owner",
@@ -75,7 +76,17 @@ const READ_ONLY: AccountAccess = {
};
const WRITABLE: AccountAccess = {
accounts: [OWNER],
active: { ...OWNER, access: "write", level: "write", canWrite: true },
active: {
...OWNER,
access: "write",
level: "write",
canWrite: true,
// Published the way the server publishes it: the sections a WRITE grant
// over the whole record can add to. A fixture that said `canWrite: true`
// over an empty list described a grant the server never mints, and the
// control asks the list.
writableDomains: delegatedDomains("write", null, "write"),
},
canSwitch: true,
};
+10 -10
View File
@@ -169,14 +169,14 @@ export default function MedicationsPageClient() {
// to tapping each card in turn — so withholding it made a caregiver with
// five morning tablets tap five times for nothing. The customize page stays
// withheld; sharing does not cover it at all.
const { canAdd, inSharedRecord } = useRecordCapabilities();
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddMedication = canWriteDomain("medications");
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
// turned off the whole page disappears (the nav entry is hidden by the same
// gate) and the list query never fires (the API would 403 anyway).
// Default-on: an absent key reads as enabled, so the page only hides on an
// explicit `false`.
const medicationsEnabled =
inSharedRecord || user?.modules?.medications !== false;
const medicationsEnabled = user?.modules?.medications !== false;
const router = useRouter();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
@@ -417,7 +417,7 @@ export default function MedicationsPageClient() {
card's own one-tap job). Calm outline variant so the primary
Add button keeps the visual lead; same responsive 44-px
mobile tap floor as its neighbours. */}
{canAdd && dueMeds.length >= 2 && (
{canAddMedication && dueMeds.length >= 2 && (
<Button
variant="outline"
onClick={() => setTakeAllOpen(true)}
@@ -455,7 +455,7 @@ export default function MedicationsPageClient() {
button's responsive tap-target floor (`min-h-11 sm:min-h-9`) so
both primary "add" entry points clear the WCAG 2.5.5 44px mobile
minimum identically. */}
{canAdd && (
{canAddMedication && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
@@ -516,7 +516,7 @@ export default function MedicationsPageClient() {
title={t("medications.emptyTitle")}
description={t("medications.emptyDescription")}
action={
canAdd ? (
canAddMedication ? (
<Button size="sm" onClick={openCreate}>
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
@@ -614,9 +614,9 @@ export default function MedicationsPageClient() {
its own ResponsiveSheet shell with the sticky footer. */}
{/* v1.36.x — `?new=1` (the retired `/medications/new` route redirects
here) opens the create wizard without passing the gated Add control,
so the wizard asks the same `canAdd` the control asks. */}
so the wizard asks the same `canAddMedication` the control asks. */}
<MedicationWizardDialog
open={dialogOpen && canAdd}
open={dialogOpen && canAddMedication}
onOpenChange={setDialogOpen}
mode="create"
onSuccess={closeDialog}
@@ -632,10 +632,10 @@ export default function MedicationsPageClient() {
attribution + inventory consumption identical to N individual
taps — see take-all-due.ts); failed medications stay due. */}
{/* Gated on the same answer the button is, for the first-paint reason
`/measurements` documents: `canAdd` reads true until `/api/auth/me`
`/measurements` documents: `canAddMedication` reads true until `/api/auth/me`
settles, and a dialog opened in that frame withdraws when the answer
lands rather than standing on a confirm the server would refuse. */}
{takeAllOpen && canAdd && (
{takeAllOpen && canAddMedication && (
<TakeAllDueDialog
open={takeAllOpen}
onOpenChange={setTakeAllOpen}
+1 -3
View File
@@ -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 { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
@@ -27,10 +26,9 @@ import { MentalWellbeing } from "@/components/mental-health/mental-wellbeing";
*/
export default function MentalWellbeingPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
const enabled = inSharedRecord || user?.modules?.mentalHealth === true;
const enabled = user?.modules?.mentalHealth === true;
useEffect(() => {
if (isLoading) return;
+1 -1
View File
@@ -39,7 +39,7 @@ export default function MoodPageClient() {
// absent key reads as enabled, so a direct URL hit only bounces on an
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
// server-side, so this is a UX redirect, not the security boundary.
const enabled = inSharedRecord || user?.modules?.mood !== false;
const enabled = user?.modules?.mood !== false;
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
// whatever this page currently has mounted (`type: "active"` scopes the
+3 -2
View File
@@ -254,7 +254,8 @@ export default function DashboardPageClient({
value: number | null | undefined,
): number | null =>
value == null ? null : unitDisplay.toDisplayDelta(type, value);
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const [quickEntryDialog, setQuickEntryDialog] =
useState<QuickEntryDialog>(null);
@@ -2028,7 +2029,7 @@ export default function DashboardPageClient({
title={t("dashboard.emptyTitle")}
description={t("dashboard.emptyDescription")}
action={
canAdd ? (
canAddMeasurement ? (
<Button
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
+5 -6
View File
@@ -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 { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
@@ -22,13 +21,13 @@ import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
*/
export default function VaccinationsPage() {
const { user, isLoading, isAuthenticated } = useAuth();
const { inSharedRecord } = useRecordCapabilities();
const router = useRouter();
// Default-on: hidden only when the resolved map says `false` explicitly. A
// shared-record session always sees it — the delegate's own module map does
// not govern the owner's record.
const enabled = inSharedRecord || user?.modules?.vaccinations !== false;
// Default-on: hidden only when the resolved map says `false` explicitly.
// The map is the RECORD's, masked to what the grant opens, so a delegate is
// answered about the record they are in rather than about their own — and a
// module the owner has switched off closes this page for them too.
const enabled = user?.modules?.vaccinations !== false;
useEffect(() => {
if (isLoading) return;
@@ -14,8 +14,8 @@
* path is not proven at all. That leg lives in `e2e/account-sharing.spec.ts`.
*
* Mutation checks, run:
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
* read-only legs for the intake row and the card menu go red.
* - `useRecordCapabilities` answering `canWriteDomain` for every section →
* the read-only legs for the intake row and the card menu go red.
* - `DeleteButton` dropping its `canManageDomain` bail → "no row delete inside
* somebody else's record" goes red; answering it for every section → the
* vault leg of "a guardian's row delete follows the section" goes red.
@@ -31,8 +31,12 @@ import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import type {
AccountAccess,
AccountAccessEntry,
} from "@/lib/sharing/account-access-view";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
import type { ShareDomain } from "@/lib/sharing/scope";
const OWNER = {
accountId: "acct-owner",
@@ -87,6 +91,44 @@ const MANAGING: AccountAccess = {
canSwitch: true,
};
/**
* A grant with a SCOPE, published the way the server publishes one.
*
* Every fixture above carries `sections: null`, and that is the hole the
* section-blind controls lived in: a WRITE grant scoped to one section answers
* `canWrite: true` like any other, so a control asking the coarse question was
* offered in sections the grant never opened. The two lists are the level ×
* scope × route-table intersection, derived here by the same function the
* server derives them with rather than by hand.
*/
function grant(
level: AccountAccessEntry["level"],
sections: ShareDomain[] | null,
): AccountAccess {
return {
accounts: [OWNER],
active: {
...OWNER,
access: level === "read" ? "read" : "write",
level,
sections,
canWrite: level !== "read",
writableDomains: delegatedDomains(level, sections, "write"),
manageableDomains: delegatedDomains(level, sections, "manage"),
},
canSwitch: true,
};
}
/** The write answer per section, for the pure helpers that take only that. */
function writeCaps(
level: AccountAccessEntry["level"],
sections: ShareDomain[] | null,
) {
const writable = new Set(delegatedDomains(level, sections, "write"));
return { canWriteDomain: (domain: ShareDomain) => writable.has(domain) };
}
const mockAccessRef: { value: AccountAccess } = { value: OWN_RECORD };
vi.mock("@/hooks/use-auth", () => ({
@@ -140,6 +182,9 @@ import { TodayHero } from "@/components/daily/today-hero";
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
import { LedgerRowItem } from "@/components/medications/dose-history-ledger";
import { VaccinationsView } from "@/components/vaccinations/vaccinations-view";
import { VisitsSection } from "@/components/encounters/visits-section";
import type { EncounterDTO } from "@/lib/encounters/dto";
import { queryKeys } from "@/lib/query-keys";
import type { DailyDigest } from "@/lib/daily/digest";
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
@@ -450,17 +495,13 @@ describe("linking a document to an illness episode", () => {
describe("the capture picker's kinds", () => {
const ALL = ["measurement", "medication", "mood"] as const;
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
const WRITER_CAPS = { canAdd: true, canManageDomain: () => false };
const READER_CAPS = { canAdd: false, canManageDomain: () => false };
/** The picker asks one question per kind, of that kind's own section. */
const caps = writeCaps;
const OWNER_CAPS = { canWriteDomain: () => true };
const WRITER_CAPS = caps("write", null);
const READER_CAPS = caps("read", null);
// A guardian: every section with a delegated route answers at MANAGE.
const GUARDIAN_CAPS = {
canAdd: true,
canManageDomain: (domain: string) =>
delegatedDomains("manage", null, "manage").includes(
domain as "measurements",
),
};
const GUARDIAN_CAPS = caps("manage", null);
it("offers everything in the caller's own record", () => {
expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([
@@ -490,6 +531,37 @@ describe("the capture picker's kinds", () => {
it("offers a read-only delegate nothing", () => {
expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]);
});
it("offers a scoped WRITE delegate only the kinds its sections cover", () => {
// The gap this closes. The coarse answer was the grant's LEVEL with no
// scope term, so a labs-scoped WRITE grant read as true and the picker
// offered a weight form and a dose the server refuses at
// `grantCoversDomain`.
expect(visibleCaptureKinds(caps("write", ["labs"]), [...ALL])).toEqual([]);
expect(
visibleCaptureKinds(caps("write", ["measurements"]), [...ALL]),
).toEqual(["measurement"]);
expect(
visibleCaptureKinds(caps("write", ["measurements", "medications"]), [
...ALL,
]),
).toEqual(["measurement", "medication"]);
});
it("offers a documents-scoped WRITE delegate nothing at all", () => {
// The worst case: the vault takes no delegated write at any level, so the
// grant's writable list is empty while `canWrite` still answers true.
expect(visibleCaptureKinds(caps("write", ["documents"]), [...ALL])).toEqual(
[],
);
});
it("offers a scoped MANAGE delegate the mood entry only with the mind section", () => {
expect(visibleCaptureKinds(caps("manage", ["mind"]), [...ALL])).toEqual([
"mood",
]);
expect(visibleCaptureKinds(caps("manage", ["labs"]), [...ALL])).toEqual([]);
});
});
/* -------------------------------------------------------------------------- */
@@ -577,7 +649,7 @@ describe("a form opened before the record answered", () => {
});
it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => {
const DELEGATE = { canAdd: true, canManageDomain: () => false };
const DELEGATE = writeCaps("write", null);
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
@@ -586,25 +658,209 @@ describe("a form opened before the record answered", () => {
});
it("keeps the mood sheet for a guardian, whose mind routes answer at MANAGE", () => {
const GUARDIAN = {
canAdd: true,
canManageDomain: (domain: string) => domain === "mind",
};
expect(admittedQuickEntry("mood", GUARDIAN)).toBe("mood");
expect(admittedQuickEntry("mood", writeCaps("manage", null))).toBe("mood");
});
it("withdraws a sheet whose section the grant's scope leaves out", () => {
const LABS_ONLY = writeCaps("write", ["labs"]);
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, LABS_ONLY), sheet).toBe(null);
}
});
it("withdraws every quick-entry sheet from a read-only delegate", () => {
const READER = { canAdd: false, canManageDomain: () => false };
const READER = writeCaps("read", null);
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, READER), sheet).toBe(null);
}
});
it("leaves the owner's own sheets alone", () => {
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
const OWNER_CAPS = { canWriteDomain: () => true };
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
}
expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null);
});
});
/* -------------------------------------------------------------------------- */
/* The immunization log */
/* -------------------------------------------------------------------------- */
describe("the immunization log's add, edit and delete", () => {
const DOSE = {
id: "dose-1",
occurredAt: "2026-03-01T00:00:00.000Z",
antigenSlug: null,
vaccineName: "Tetanus",
doseNumber: null,
seriesDoses: null,
lotNumber: null,
site: null,
catalogEntry: null,
series: [],
practitioner: null,
encounter: null,
reminderId: null,
note: null,
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
};
const seedList = (client: QueryClient) => {
client.setQueryData(queryKeys.vaccinationList(null), {
vaccinations: [DOSE],
});
};
/** The row carries the edit affordance only while it opens something. */
const rowIsTappable = (html: string) =>
html.includes('data-slot-open="vaccination-row-open"');
it("offers all three in the caller's own record", () => {
const html = render(OWN_RECORD, <VaccinationsView />, seedList);
expect(html).toContain('data-slot="vaccination-add"');
// The row opens the edit sheet, which is where the delete lives.
expect(rowIsTappable(html)).toBe(true);
});
it("offers a read-only delegate none of them", () => {
// `POST /api/vaccinations` is WRITE and everything else is MANAGE, so a
// READ delegate was being shown three controls and refused by all three.
const html = render(
grant("read", ["profile"]),
<VaccinationsView />,
seedList,
);
expect(html).not.toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html)).toBe(false);
});
it("offers a WRITE delegate the add and not the row", () => {
const html = render(
grant("write", ["profile"]),
<VaccinationsView />,
seedList,
);
expect(html).toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html), "editing and deleting a dose are MANAGE").toBe(
false,
);
});
it("offers a MANAGE delegate holding the section both", () => {
const html = render(
grant("manage", ["profile"]),
<VaccinationsView />,
seedList,
);
expect(html).toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html)).toBe(true);
});
it("offers a MANAGE delegate outside the section neither", () => {
// The page is presentable to a `profile` grant; a grant scoped elsewhere
// that still lands on the URL gets a log it can read and nothing else.
const html = render(
grant("manage", ["labs"]),
<VaccinationsView />,
seedList,
);
expect(html).not.toContain('data-slot="vaccination-add"');
expect(rowIsTappable(html)).toBe(false);
});
it("offers the empty state's add on the same rule", () => {
const seedEmpty = (client: QueryClient) => {
client.setQueryData(queryKeys.vaccinationList(null), {
vaccinations: [],
});
};
expect(render(OWN_RECORD, <VaccinationsView />, seedEmpty)).toContain(
'data-slot="vaccination-add-empty"',
);
expect(
render(grant("read", ["profile"]), <VaccinationsView />, seedEmpty),
).not.toContain('data-slot="vaccination-add-empty"');
});
});
/* -------------------------------------------------------------------------- */
/* The visits list */
/* -------------------------------------------------------------------------- */
describe("a visit row's edit sheet", () => {
const VISIT: EncounterDTO = {
id: "visit-1",
occurredAt: "2026-08-01T09:00:00.000Z",
status: "DONE",
kind: "ROUTINE",
practitioner: null,
reason: null,
outcome: null,
reminderNextDueAt: null,
createdAt: "2026-08-01T09:00:00.000Z",
updatedAt: "2026-08-01T09:00:00.000Z",
};
const seedList = (client: QueryClient) => {
client.setQueryData(
queryKeys.encounterList(null, null, undefined, undefined),
{
upcoming: [],
past: [VISIT],
},
);
};
/** The row's content is a button only while it opens something. */
const rowIsTappable = (html: string) =>
html.includes('data-slot="visit-card-open"');
it("opens for the owner", () => {
expect(rowIsTappable(render(OWN_RECORD, <VisitsSection />, seedList))).toBe(
true,
);
});
it("does not open for a READ or a WRITE delegate", () => {
// The add button was the only thing asked, and it asks the right question
// — `POST /api/encounters` is WRITE. The row underneath opens a sheet
// whose Save is `PATCH` and whose Delete is `DELETE`, both MANAGE.
for (const level of ["read", "write"] as const) {
const html = render(
grant(level, ["profile"]),
<VisitsSection />,
seedList,
);
expect(html, `${level} still sees the row`).toContain(
'data-slot="visit-card"',
);
expect(rowIsTappable(html), `${level} may open the sheet`).toBe(false);
}
});
it("keeps the add button for a WRITE delegate", () => {
// The two answers are different questions and must not collapse into one.
expect(
render(grant("write", ["profile"]), <VisitsSection />, seedList),
).toContain('data-slot="visits-add"');
expect(
render(grant("read", ["profile"]), <VisitsSection />, seedList),
).not.toContain('data-slot="visits-add"');
});
it("opens for a MANAGE delegate holding the section, and not one outside it", () => {
expect(
rowIsTappable(
render(grant("manage", ["profile"]), <VisitsSection />, seedList),
),
).toBe(true);
expect(
rowIsTappable(
render(grant("manage", ["labs"]), <VisitsSection />, seedList),
),
).toBe(false);
});
});
@@ -44,9 +44,21 @@ import { formatBytes } from "@/lib/format/bytes";
const FRESHNESS_STYLE: Record<OffhostBackupFreshness, string> = {
fresh: "border-success/30 bg-success/10 text-success",
due: "border-warning/30 bg-warning/10 text-warning",
stale: "border-destructive/30 bg-destructive/10 text-destructive",
never: "border-border bg-muted text-muted-foreground",
unknown: "border-border bg-muted text-muted-foreground",
stale: "border-destructive/30 bg-destructive/10 text-foreground",
never: "border-border bg-muted text-foreground",
unknown: "border-border bg-muted text-foreground",
};
/**
* `--destructive` as 12px badge text on its own wash measures 4.08:1 in the
* light theme and 3.99:1 in the dark one, and `--muted-foreground` on
* `bg-muted` 3.6:1 in the dark one, all under the AA floor. So the badge
* takes the shape the admin status rows already use: the token paints a small
* indicator and the wording stays in `text-foreground`. The word states the
* condition, so nothing hangs on the colour alone.
*/
const FRESHNESS_INDICATOR: Partial<Record<OffhostBackupFreshness, string>> = {
stale: "bg-destructive",
};
function freshnessLabel(
@@ -88,6 +100,16 @@ function AccountRow({ row }: { row: OffhostAccountRow }) {
variant="outline"
className={cn("shrink-0", FRESHNESS_STYLE[row.freshness])}
>
{FRESHNESS_INDICATOR[row.freshness] ? (
<span
aria-hidden="true"
data-slot="offhost-freshness-indicator"
className={cn(
"size-2 shrink-0 rounded-full",
FRESHNESS_INDICATOR[row.freshness],
)}
/>
) : null}
{freshnessLabel(row.freshness, t)}
</Badge>
</div>
@@ -14,7 +14,7 @@ vi.mock("next/navigation", () => ({
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
+27 -17
View File
@@ -36,13 +36,19 @@ export function DashboardHeader({
const { t } = useTranslations();
const { user } = useAuth();
const mounted = useMounted();
// v1.36.x — the dashboard quick-add offers three kinds; a delegation admits
// two of them (a reading, a dose). Mood stays with the account whose record
// it is, and the customize shortcut points at a settings page sharing does
// not cover at all.
const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities();
// The quick-add offers three kinds, and each one is asked about its own
// section: a reading under `measurements`, a dose under `medications`, a
// mood entry under `mind`. The coarse `canAdd` this used to gate the whole
// menu on carries the grant's LEVEL and no scope, so it answered true for a
// grant that opens neither of the two sections the menu never asked about.
// The customize shortcut points at a settings page sharing does not cover at
// all.
const { canWriteDomain, inSharedRecord } = useRecordCapabilities();
const canAddMeasurement = canWriteDomain("measurements");
const canAddIntake = canWriteDomain("medications");
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
const canAddMood = canWriteDomain("mind");
const canAddAnything = canAddMeasurement || canAddIntake || canAddMood;
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -117,7 +123,7 @@ export function DashboardHeader({
</Link>
</Button>
)}
{canAdd && (
{canAddAnything && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{/* 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`. */}
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
{canAddMeasurement && (
<DropdownMenuItem onClick={() => onQuickEntry("measurement")}>
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
)}
{canAddMood && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
@@ -167,12 +175,14 @@ export function DashboardHeader({
the other two; the menu label is a self-contained
verb-phrase so it doesn't collide with the trigger or
the sibling rows (cf. quick-add-labels.test.ts). */}
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
{canAddIntake && (
<DropdownMenuItem
onClick={() => onQuickEntry("medicationIntake")}
>
<Pill className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMedicationIntake")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -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<NonNullable<QuickEntryDialog>> =
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<NonNullable<QuickEntryDialog>, ShareDomain>
> = {
@@ -69,11 +59,10 @@ const QUICK_ENTRY_DOMAIN: Readonly<
*/
export function admittedQuickEntry(
open: QuickEntryDialog,
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain">,
): 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;
}
/**
+1 -2
View File
@@ -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(() => {
@@ -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";
+39 -7
View File
@@ -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"
>
<CardContent className="space-y-2">
<button
type="button"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
<Body onOpen={onOpen} encounter={encounter}>
<div className="flex items-start gap-2">
<CalendarClock
className="text-foreground mt-0.5 size-5 shrink-0"
@@ -120,8 +123,37 @@ export function VisitCard({
))}
</div>
) : null}
</button>
</Body>
</CardContent>
</Card>
);
}
/**
* 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 <div className="w-full space-y-2">{children}</div>;
return (
<button
type="button"
data-slot="visit-card-open"
onClick={() => onOpen(encounter)}
className="focus-visible:ring-ring/50 w-full space-y-2 text-left focus-visible:ring-[3px] focus-visible:outline-none"
>
{children}
</button>
);
}
+8 -4
View File
@@ -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 }) {
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>
@@ -125,7 +129,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
<VisitCard
key={encounter.id}
encounter={encounter}
onOpen={openSheet}
onOpen={canManageVisits ? openSheet : undefined}
/>
))}
</div>
+4 -3
View File
@@ -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 && (
<Button
onClick={() => setNewOpen(true)}
className="min-h-11 sm:min-h-9"
@@ -476,7 +477,7 @@ export function IllnessView() {
description={t("illness.empty.body")}
ctaSize="lg"
action={
canAdd ? (
canAddEpisode ? (
<Button onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4" />
{t("illness.newEpisode")}
+4 -3
View File
@@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200;
*/
export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
const { user } = useAuth();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddLab = canWriteDomain("labs");
const canManageLabs = canManageDomain("labs");
const { t, locale } = useTranslations();
const router = useRouter();
@@ -378,7 +379,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
) : null}
{/* v1.36.x — entering a result is an admitted delegated write;
renaming or removing the marker is not. */}
{canAdd && (
{canAddLab && (
<Button
onClick={() => setAddOpen(true)}
// v1.18.10 (W10) — on the narrowest phones the h1 + Edit + Delete +
@@ -436,7 +437,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
title={t("labs.detail.emptyTitle")}
description={t("labs.detail.emptyDescription")}
action={
canAdd ? (
canAddLab ? (
<Button onClick={() => setAddOpen(true)}>
{t("labs.addResult")}
</Button>
+3 -2
View File
@@ -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 ? (
<Button onClick={onAddFirst}>{t("labs.addFirst")}</Button>
) : undefined
}
@@ -18,7 +18,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
@@ -11,7 +11,6 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
inSharedRecord: false,
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
+13 -22
View File
@@ -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<CaptureKind> = 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<Record<CaptureKind, ShareDomain>> = {
measurement: "measurements",
@@ -117,13 +111,10 @@ export function admittedCaptureKind(
* cannot tap the button that opens it.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
caps: Pick<RecordCapabilities, "canWriteDomain">,
kinds: ReadonlyArray<CaptureKind>,
): 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 {
+22 -5
View File
@@ -224,11 +224,28 @@ export const NAV_DESTINATIONS: ReadonlyArray<NavDestination> = [
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",
@@ -29,7 +29,7 @@ vi.mock("@/hooks/use-encounters", () => ({
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
@@ -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")}
</Button>
) : onAddFirst && canAdd ? (
) : onAddFirst && canAddMeasurement ? (
<Button onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("measurements.emptyAddFirst")}
@@ -36,8 +36,9 @@ export function MedicationIntakeActions({
// v1.36.x — marking a dose taken or skipped is the verb the delegation was
// written for. A read-only delegate sees the card and its schedule and no
// action row; the card is otherwise unchanged.
const { canAdd } = useRecordCapabilities();
if (!canAdd) return null;
const { canWriteDomain } = useRecordCapabilities();
const canRecordIntake = canWriteDomain("medications");
if (!canRecordIntake) return null;
return (
<div className="flex gap-2">
@@ -175,7 +175,7 @@ export function LedgerAddDialog({
// confirmation names the record, because "Taken" alone is the one
// confirmation a person acting for somebody else does not need. The
// ledger's "+ Eintrag" button that opens this dialog is gated on
// `canAdd`, so a delegate reaches it.
// `canWriteDomain("medications")`, so a delegate reaches it.
const toastOptions = intakeToastOptions({
recordName,
eventId: undefined,
@@ -173,7 +173,8 @@ export function DoseHistoryLedger({
// v1.36.x — a delegate may record a dose, which is what "add" and the
// inline "mark taken" do. Editing, deleting, pinning and un-skipping an
// existing event are the owner's, so the row menu is absent for them.
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// The record this ledger's writes land in, when it is not the caller's own.
// The two sibling intake paths in `use-medication-intake.ts` already drop
// Undo and name the record; this third one builds its own toast and missed
@@ -434,7 +435,7 @@ export function DoseHistoryLedger({
{t("medications.detail.verlauf.summaryEmpty")}
</p>
)}
{canAdd && (
{canAddIntake && (
<Button
variant="outline"
size="sm"
@@ -598,7 +599,8 @@ export function LedgerRowItem({
onUnpin: (eventId: string) => void;
}) {
const { t } = useTranslations();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
@@ -705,7 +707,7 @@ export function LedgerRowItem({
</div>
<div className="flex shrink-0 items-center gap-1">
{showTakenButton && canAdd && (
{showTakenButton && canAddIntake && (
<Button
variant="outline"
size="sm"
@@ -734,7 +736,7 @@ export function LedgerRowItem({
items in one menu, a delegate gets skip as its own control beside
take, which is the shape the card already uses. The owner keeps it
where it was. */}
{showTakenButton && canAdd && !canManageMedications && (
{showTakenButton && canAddIntake && !canManageMedications && (
<Button
variant="outline"
size="sm"
@@ -62,9 +62,10 @@ export function MedicationCardMenu({
// medication and opening its history editor are not. When nothing in the
// menu survives, the trigger goes with it rather than opening onto an empty
// sheet. The card itself is untouched: no wash, no badge, no colour change.
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const showSideEffect = onLogSideEffect != null && canAdd;
const showSideEffect = onLogSideEffect != null && canAddSideEffect;
if (!canManageMedications && !showSideEffect) return null;
return (
@@ -407,7 +407,8 @@ function MedicationTableRowItem({
const { intakeLoading, recordIntake } = useMedicationIntake({ medication });
// v1.36.x — marking a dose is admitted; a read-only delegate sees the row
// without its action pair.
const { canAdd } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
const canAddIntake = canWriteDomain("medications");
// SAME batched compliance source as the cards.
const { data: compliance } = useMedicationComplianceSummary(medication.id);
@@ -685,7 +686,7 @@ function MedicationTableRowItem({
</TableCell>
<TableCell className="text-sm">{stockCell}</TableCell>
<TableCell>
{medication.active && canAdd ? (
{medication.active && canAddIntake ? (
<div className="flex gap-1.5">
<Button
size="icon"
@@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string {
export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
const { t } = useTranslations();
const { canAdd, canManageDomain } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
const canAddSideEffect = canWriteDomain("medications");
const canManageMedications = canManageDomain("medications");
const fmt = useFormatters();
const queryClient = useQueryClient();
@@ -250,7 +251,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
)}
{/* v1.36.x — noting a side effect is an admitted delegated write.
Removing one that is already noted is not. */}
{canAdd && (
{canAddSideEffect && (
<Button
size="sm"
variant="outline"
@@ -157,6 +157,10 @@ function DoseRow({
<Card
className="hover:bg-muted/40 gap-0 transition-colors"
data-slot="vaccination-row"
// A second, stable marker for the state rather than for the row: the
// `role` is markup that could legitimately change, and a guard matching
// on it would pass the day a real `<button>` replaced the card.
data-slot-open={onEdit ? "vaccination-row-open" : undefined}
data-vaccination-id={record.id}
onClick={onEdit ? () => onEdit(record) : undefined}
role={onEdit ? "button" : undefined}
@@ -10,6 +10,13 @@
* On a successful create the caller may raise the booster-mint prompt; this
* sheet completes and closes regardless of what the person does with it. The
* mint is an offer, never a step.
*
* Capability note: the same posture as the visit sheet. No check here; Delete
* renders only in edit mode, and the one mount that can reach edit mode
* (`VaccinationsView`) asks `canManageDomain("profile")` before handing over a
* record, while its create path asks `canWriteDomain("profile")`. A second
* mount that skips either question reintroduces an ungated control with
* nothing failing.
*/
import { useState } from "react";
import { Trash2 } from "lucide-react";
@@ -18,6 +18,7 @@ import { EmptyState } from "@/components/ui/empty-state";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { Skeleton } from "@/components/ui/skeleton";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useTranslations } from "@/lib/i18n/context";
import { VaccinationList } from "./vaccination-list";
@@ -27,6 +28,19 @@ import { useVaccinations, type Vaccination } from "./use-vaccinations";
export function VaccinationsView() {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// `POST /api/vaccinations` answers at WRITE; the edit, the delete and the
// restore behind it are all MANAGE. The page is presentable to every
// `profile`-scoped grant, so without this a READ delegate was shown add,
// edit and delete, and every one of them was refused by a server that was
// right to refuse. Same split, same domain, same shape as the address book
// next door.
const canAddDose = canWriteDomain("profile");
const canManageProfile = canManageDomain("profile");
// Planning the booster mints a `MeasurementReminder`, which lives in the
// measurements section rather than this one. A grant that opened only the
// health background is refused there, so the offer is not made.
const canPlanBooster = canWriteDomain("measurements");
const { data, isLoading, isError, refetch } = useVaccinations();
const records = data?.vaccinations ?? [];
@@ -50,6 +64,17 @@ export function VaccinationsView() {
setSheetOpen(true);
};
const addButton = canAddDose ? (
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
) : null;
return (
<div className="space-y-6">
<PageHeader
@@ -59,16 +84,7 @@ export function VaccinationsView() {
</span>
}
description={t("vaccinations.subtitle")}
actions={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
}
actions={addButton}
/>
<VaccinationSheet
@@ -79,7 +95,8 @@ export function VaccinationsView() {
onCreated={(created) => {
// 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 ? (
<VaccinationList records={records} onEdit={openEdit} />
<VaccinationList
records={records}
onEdit={canManageProfile ? openEdit : undefined}
/>
) : (
<EmptyState
icon={<Syringe className="size-6" />}
title={t("vaccinations.empty.title")}
description={t("vaccinations.empty.description")}
action={
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
canAddDose ? (
<Button
className="min-h-11 sm:min-h-9"
data-slot="vaccination-add-empty"
onClick={openCreate}
>
<Plus className="size-4" />
{t("common.add")}
</Button>
) : undefined
}
/>
)}
@@ -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
+13 -10
View File
@@ -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
-22
View File
@@ -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.
*
-20
View File
@@ -110,23 +110,3 @@ export function summariseGlitchtipDelivery(
windowHours: GLITCHTIP_DELIVERY_WINDOW_HOURS,
};
}
export async function getGlitchtipDeliveryState(): Promise<GlitchtipDeliveryState> {
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 };
}
}
+6 -4
View File
@@ -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<Record<string, readonly string[]>> =
vaccination: [
"vaccination.booster-invalid",
"vaccination.booster-no-antigen",
"vaccination.booster-out-of-scope",
"vaccination.encounter-not-found",
"vaccination.identity-required",
"vaccination.invalid",
+1 -1
View File
@@ -874,7 +874,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.",
+11 -1
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"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,
},
},
+2 -1
View File
@@ -135,7 +135,8 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["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.",
"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: {
+5 -2
View File
@@ -19,6 +19,7 @@ import {
dataEnvelope,
errorEnvelope,
recordRefusal,
SHARING_NOT_PERMITTED_DESCRIPTION,
stdResponses,
} from "./shared";
@@ -615,7 +616,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"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<ZodOpenApiObject["paths"]> = {
},
"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 } },
},
},
+19
View File
@@ -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.
+5 -2
View File
@@ -364,14 +364,17 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["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: 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: {
+30 -10
View File
@@ -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".
+33
View File
@@ -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);
}
+9
View File
@@ -74,6 +74,15 @@ const LEVEL_RANK: Record<AccountAccessLevel, number> = {
* `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.
+10 -1
View File
@@ -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",
-24
View File
@@ -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.
*
@@ -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<InsightsBody> {
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<Response>;
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");
});
});
@@ -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)",
@@ -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",
);
});
});
@@ -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<Response> {
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);
});
});