diff --git a/CHANGELOG.md b/CHANGELOG.md index 08914f206..1be66997d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.38.13] — 2026-09-09 + +A guardian acting on a managed profile gets the add, edit and delete +controls the server has accepted since v1.37, decided per domain and +published by the server. Reported by @sreeramachandramurthy in #939. + +### Fixed + +- **Managed profiles: the controls the routes accept are shown again.** A + person acting on a managed profile, or on a record shared with them at the + manage level, saw no way to add or change anything on mood, wellbeing + check-ins, visits, allergies, family history, labs and more, although the + server accepted those writes. The client hard-wired `canManage` to false for + every shared record, a hold-back from the release that introduced managed + profiles that was never lifted. It is lifted per domain: a table records + which share domains accept delegated writes at `write` and at `manage`, + frozen by a structural test that reads every API route and fails on drift; + every entry in the account access list on `/api/auth/me` now carries + `writableDomains` and `manageableDomains` (additive, existing clients + ignore them); and every surface asks for its own domain. Documents cannot + be delegated at any level, so the documents page tells a guardian that only + the record owner can add documents instead of showing an empty slot. Module + switches per managed profile, editing a profile after creation and asking + for sex at creation are still open in #939 and follow as one piece. + ## [1.38.12] — 2026-09-09 A dependency release: the image and the lockfile stop carrying twelve diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ae5eff259..72915fbc3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.12 + version: 1.38.13 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -65685,6 +65685,32 @@ components: - type: "null" canWrite: type: boolean + writableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + manageableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents required: - accountId - username @@ -65695,6 +65721,8 @@ components: - recordKind - sections - canWrite + - writableDomains + - manageableDomains additionalProperties: false active: anyOf: @@ -65747,6 +65775,32 @@ components: - type: "null" canWrite: type: boolean + writableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + manageableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents required: - accountId - username @@ -65757,6 +65811,8 @@ components: - recordKind - sections - canWrite + - writableDomains + - manageableDomains additionalProperties: false - type: "null" recordKind: diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 44ac59a20..14f0343ad 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -198,6 +198,17 @@ export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScopeA11y.json", ); +/** + * v1.38.12 — the managed-record capabilities journey's own jar. Same scope + * delegate, its own session row, for the reason every switching spec has + * one: it moves the record selector, and two specs driving one row from two + * workers would test their interference rather than the controls. + */ +export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeCapabilities.json", +); + /** * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as @@ -739,6 +750,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); // v1.37.0 — one jar per switching spec. See the block comment on // FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9fd153d33..024f9199e 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -21,6 +21,7 @@ export { DELEGATE_STORAGE_STATE_PATH, SCOPE_DELEGATE_STORAGE_STATE_PATH, SCOPE_A11Y_STORAGE_STATE_PATH, + SCOPE_CAPABILITIES_STORAGE_STATE_PATH, FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts new file mode 100644 index 000000000..29650bf10 --- /dev/null +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -0,0 +1,140 @@ +/** + * v1.38.12 — the controls a guardian sees inside a managed profile (#939). + * + * A person acting on a managed profile holds a MANAGE grant, and the server + * has accepted their mood entries, screeners, visits, allergies and lab edits + * since v1.37.0. The interface withheld every one of those controls anyway, + * because the client answered `canManage: false` for every shared record. It + * answers per section now, from the two lists `GET /api/auth/me` publishes. + * + * Two pages, one each way: + * + * - Mood is a section whose create answers at MANAGE, so the add control + * is there and an entry can be added — asserted on the POST's status, not + * on the paint, because the paint is what lied before. + * - Documents is the section with no delegated write route, so the upload + * control is absent and the page says why, in a sentence, where the + * control would have been. + * + * Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are + * the same ones, for the same reasons written there. + */ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + E2E_LEVEL_RECORDS, + SCOPE_CAPABILITIES_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + await expect( + page.locator('[data-slot="account-switcher-menu"]'), + ).toBeVisible(); +} + +/** + * Run `act` and wait for the document it replaces to be gone. See the + * v1.37.0 spec for why waiting on the banner alone settles nothing. + */ +async function withDocumentReplacement( + page: Page, + act: () => Promise, +): Promise { + await page.evaluate(() => { + (window as Window & { __hlNavToken?: true }).__hlNavToken = true; + }); + await act(); + await page.waitForFunction( + () => + (window as Window & { __hlNavToken?: true }).__hlNavToken === undefined, + undefined, + { timeout: 30_000 }, + ); +} + +async function openRecord(page: Page, username: string) { + await page.goto("/"); + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); + await expect(entry).toHaveCount(1); + await withDocumentReplacement(page, () => entry.click()); + await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({ + timeout: 30_000, + }); +} + +async function leaveRecord(page: Page) { + await withDocumentReplacement(page, () => + page.locator('[data-slot="shared-record-banner-exit"]').click(), + ); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + { timeout: 30_000 }, + ); +} + +test.describe.serial("a guardian's controls inside a managed profile", () => { + // Its own session row: this journey moves the record selector, and the + // scoped-sharing journey drives the same account from another worker. + test.use({ storageState: SCOPE_CAPABILITIES_STORAGE_STATE_PATH }); + + const managed = E2E_LEVEL_RECORDS.find( + (record) => record.recordKind === "managed", + ); + if (!managed) throw new Error("managed record fixture is missing"); + + test("offers the mood add control and lands the entry", async ({ page }) => { + await openRecord(page, managed.username); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + + await page.goto("/mood"); + const add = page.locator('[data-slot="mood-add-entry"]'); + await expect(add).toBeVisible({ timeout: 30_000 }); + await add.click(); + + const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]'); + await expect(face).toBeVisible(); + await face.click(); + + const posted = page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/mood-entries(\?|$)/.test(response.url()), + { timeout: 30_000 }, + ); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const response = await posted; + // The route, not the paint: a control that renders and 403s is the exact + // failure this release ends. + expect(response.status()).toBe(201); + await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({ + timeout: 30_000, + }); + + await leaveRecord(page); + }); + + test("withholds the vault's upload control and says why", async ({ + page, + }) => { + await openRecord(page, managed.username); + + await page.goto("/documents"); + const note = page.locator('[data-slot="documents-owner-only"]'); + await expect(note).toBeVisible({ timeout: 30_000 }); + await expect(note).toHaveText( + "Documents can only be added by the record owner.", + ); + await expect(page.getByRole("button", { name: "Upload" })).toHaveCount(0); + await expect(page.locator('input[type="file"]')).toHaveCount(0); + + await leaveRecord(page); + }); +}); diff --git a/messages/de.json b/messages/de.json index 58a32857f..eef3089fe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9548,6 +9548,7 @@ "title": "Dokumente", "subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.", "pageUpload": "Hochladen", + "ownerOnlyUpload": "Dokumente kann nur die Inhaberin oder der Inhaber der Akte hinzufügen.", "kind": { "DOCTOR_REPORT": "Arztbericht", "DISCHARGE_LETTER": "Entlassungsbrief", diff --git a/messages/en.json b/messages/en.json index 3ba64450b..e75e342a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9548,6 +9548,7 @@ "title": "Documents", "subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.", "pageUpload": "Upload", + "ownerOnlyUpload": "Documents can only be added by the record owner.", "kind": { "DOCTOR_REPORT": "Doctor report", "DISCHARGE_LETTER": "Discharge letter", diff --git a/messages/es.json b/messages/es.json index 9515185a7..91f6187d8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9548,6 +9548,7 @@ "title": "Documentos", "subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.", "pageUpload": "Subir", + "ownerOnlyUpload": "Solo el titular del historial puede añadir documentos.", "kind": { "DOCTOR_REPORT": "Informe médico", "DISCHARGE_LETTER": "Informe de alta", diff --git a/messages/fr.json b/messages/fr.json index 8724f604f..23921c63d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9548,6 +9548,7 @@ "title": "Documents", "subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.", "pageUpload": "Téléverser", + "ownerOnlyUpload": "Seul le titulaire du dossier peut ajouter des documents.", "kind": { "DOCTOR_REPORT": "Compte rendu médical", "DISCHARGE_LETTER": "Lettre de sortie", diff --git a/messages/it.json b/messages/it.json index c2b0a981e..e17e31091 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9548,6 +9548,7 @@ "title": "Documenti", "subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.", "pageUpload": "Carica", + "ownerOnlyUpload": "Solo il titolare della cartella può aggiungere documenti.", "kind": { "DOCTOR_REPORT": "Referto medico", "DISCHARGE_LETTER": "Lettera di dimissione", diff --git a/messages/ko.json b/messages/ko.json index d82cc8fe9..66d228404 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9548,6 +9548,7 @@ "title": "문서", "subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.", "pageUpload": "업로드", + "ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.", "kind": { "DOCTOR_REPORT": "진료용 리포트", "DISCHARGE_LETTER": "퇴원 소견서", diff --git a/messages/pl.json b/messages/pl.json index 4ebd23e32..7ec4d5cbc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9548,6 +9548,7 @@ "title": "Dokumenty", "subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.", "pageUpload": "Prześlij", + "ownerOnlyUpload": "Dokumenty może dodawać tylko właściciel kartoteki.", "kind": { "DOCTOR_REPORT": "Raport lekarski", "DISCHARGE_LETTER": "Wypis ze szpitala", diff --git a/package.json b/package.json index ceb8f13f9..8321be82a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.12", + "version": "1.38.13", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/playwright.config.ts b/playwright.config.ts index 0fb132700..928caf004 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -117,6 +117,7 @@ export default defineConfig({ testIgnore: [ "v137-sharing-managed-profiles.spec.ts", "v137-sharing-managed-profiles-a11y.spec.ts", + "v138-managed-record-capabilities.spec.ts", // v1.37.0 — every spec that MOVES a session's record selector runs in // exactly one project. // diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbef6f00b..461af96d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,11 @@ overrides: postcss@<8.5.23: ^8.5.23 nanoid@<3.3.18: ^3.3.18 '@hono/node-server@<1.19.15': ^1.19.15 - hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 body-parser@<2.3.0: ^2.3.0 qs@<6.16.0: ^6.16.0 fast-uri@<3.1.6: ^3.1.6 flatted@<3.4.2: ^3.4.2 - js-yaml@>=4.0.0 <4.3.1: ^4.3.1 mysql2@<3.22.0: ^3.22.0 minimatch@<3.1.4: ^3.1.4 ip-address@<10.3.1: ^10.3.1 @@ -699,7 +697,7 @@ packages: resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4.12.25 + hono: ^4.13.5 '@hookform/resolvers@5.9.1': resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==} @@ -4464,8 +4462,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.13.3: - resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -4790,8 +4788,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -7411,7 +7409,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -7464,9 +7462,9 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.17(hono@4.13.3)': + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: - hono: 4.13.3 + hono: 4.13.7 '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)': dependencies: @@ -7677,7 +7675,7 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.3) + '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7687,7 +7685,7 @@ snapshots: eventsource-parser: 3.1.1 express: 5.2.1(supports-color@7.2.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) - hono: 4.13.3 + hono: 4.13.7 jose: 6.2.10 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -7701,7 +7699,7 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.3) + '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7711,7 +7709,7 @@ snapshots: eventsource-parser: 3.1.1 express: 5.2.1(supports-color@7.2.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) - hono: 4.13.3 + hono: 4.13.7 jose: 6.2.10 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -8245,13 +8243,13 @@ snapshots: '@electric-sql/pglite': 0.4.1 '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.17(hono@4.13.3) + '@hono/node-server': 1.19.17(hono@4.13.7) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.2 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.13.3 + hono: 4.13.7 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -10239,7 +10237,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -11215,7 +11213,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.13.3: {} + hono@4.13.7: {} html-escaper@2.0.2: {} @@ -11498,7 +11496,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dafb166e4..9284664c0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,6 @@ overrides: # because the advisory is fixed there too and 2.x is a major nothing asked # for. "@hono/node-server@<1.19.15": "^1.19.15" - "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" "body-parser@<2.3.0": "^2.3.0" # Array-limit bypass through bracket-key comma parsing, and a denial of @@ -36,9 +35,6 @@ overrides: # copy, so it sits on a path this app exposes. "fast-uri@<3.1.6": "^3.1.6" "flatted@<3.4.2": "^3.4.2" - # The advisory covers 4.x only; the bound keeps a hypothetical 3.x consumer - # off a major it never asked for. - "js-yaml@>=4.0.0 <4.3.1": "^4.3.1" # GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials. # Reached only through prisma's MySQL driver, which this app never opens; # pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the diff --git a/public/sw.js b/public/sw.js index cf6393427..bfb9d7a72 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.12"; + /* @sw-version-fallback */ "v1.38.13"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 58d85f597..dffe39795 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -106,6 +106,19 @@ const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [ "recordKind", ] as const; +/** + * v1.38.12 — fields that answer "what may I do in the record I am inside", + * which only the active-record consumer has a use for. The switcher and the + * navigation presentation say what was granted (`level`, `sections`); the + * controls read these two lists. Requiring an entry-site reader for them + * would be an artificial read written to satisfy this guard, so they are + * checked against the active-record consumer only. + */ +const ACTIVE_RECORD_ONLY_FIELDS = [ + "writableDomains", + "manageableDomains", +] as const; + /** * Fields with no client reader, each with the reason it is nonetheless * correct for the payload to carry it. An entry here is a claim; write one @@ -273,5 +286,12 @@ describe("account payload consumer guard", () => { ); expect(readerCount).toBeGreaterThan(0); + + for (const field of ACTIVE_RECORD_ONLY_FIELDS) { + expect( + activeSource, + `no active-record reader for account access field \`${field}\``, + ).toMatch(new RegExp(`active\\.${field}(?![\\w$])`)); + } }); }); diff --git a/src/__tests__/customize-shortcut-guard.test.ts b/src/__tests__/customize-shortcut-guard.test.ts index 9945974b4..9e4f93341 100644 --- a/src/__tests__/customize-shortcut-guard.test.ts +++ b/src/__tests__/customize-shortcut-guard.test.ts @@ -12,16 +12,24 @@ * it, so this states it. It is structural rather than rendered because these * pages early-return a loading gate under SSR — the markup a delegate would * see is not reachable without a browser, and the property is about the source - * either way: the link is inside a `canManage` conditional, or it is not. + * either way: the link is inside an `inSharedRecord` conditional, or it is + * not. + * + * v1.38.12 — the guard used to name the manage capability, which was true + * only in one's own record. It answers per section now and is true for a + * guardian, so a wrench behind it would send a guardian into the wall. The + * customise pages live under `/settings`, which is the CALLER's and never the + * record's, so the link is withheld on `inSharedRecord` — the one answer no + * grant level moves. * * ## What decides * * The AST. The `href` is found as a string literal on a JSX attribute and the * ancestor chain above it is walked for a `&&` whose left-hand side names - * `canManage`. A comment about gating cannot satisfy that, and a `canManage` - * used elsewhere in the same file cannot either — which a "does this file - * mention the symbol" matcher would have accepted, and which is exactly what - * `/mood` looked like the day it shipped ungated. + * `inSharedRecord`. A comment about gating cannot satisfy that, and an + * `inSharedRecord` used elsewhere in the same file cannot either — which a + * "does this file mention the symbol" matcher would have accepted, and which + * is exactly what `/mood` looked like the day it shipped ungated. */ import { readdirSync, readFileSync } from "node:fs"; import { join, sep } from "node:path"; @@ -34,8 +42,8 @@ const SRC = join(process.cwd(), "src"); /** The prefix every customise shortcut points at. */ const LAYOUT_PREFIX = "/settings/layout/"; -/** The capability a shared-record surface withholds these on. */ -const GUARD = "canManage"; +/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */ +const GUARD = "inSharedRecord"; /** * The surfaces that carry a layout shortcut and need no gate. @@ -66,16 +74,16 @@ function allSourceFiles(): string[] { .sort(); } -/** Does any ancestor of `node` gate it behind ` && …`? */ -function guardedByCanManage(node: ts.Node): boolean { +/** Does any ancestor of `node` gate it behind `! && …`? */ +function guardedByOwnRecord(node: ts.Node): boolean { for (let cur: ts.Node | undefined = node; cur; cur = cur.parent) { const parent: ts.Node | undefined = cur.parent; if ( parent && ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && - // Only the RIGHT side is the guarded branch. `canManage && x` guards `x`; - // `x && canManage` is `canManage` being the guarded thing. + // Only the RIGHT side is the guarded branch. `!inSharedRecord && x` + // guards `x`; `x && !inSharedRecord` is the answer being the guarded thing. parent.right === cur ) { const names = new Set(); @@ -87,7 +95,7 @@ function guardedByCanManage(node: ts.Node): boolean { if (names.has(GUARD)) return true; } // A conditional whose whenTrue branch holds the link, guarded on the - // capability: `canManage ? : null`, the shape `vorsorge-section` + // answer: `!inSharedRecord ? : null`, the shape `vorsorge-section` // uses. if ( parent && @@ -120,7 +128,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] { if ( ts.isStringLiteralLike(node) && node.text.startsWith(LAYOUT_PREFIX) && - !guardedByCanManage(node) + !guardedByOwnRecord(node) ) { found.push(node.text); } @@ -160,20 +168,20 @@ describe("the customise shortcut is withheld inside somebody else's record", () } expect( offenders, - `a customise link outside a \`${GUARD}\` branch sends a delegate to /settings, which a switch closes`, + `a customise link outside a \`!${GUARD}\` branch sends a delegate to /settings, which a switch closes`, ).toEqual([]); }); it("the matcher reads the branch, not the file", () => { - // The shape that shipped: the capability is resolved and used elsewhere in + // The shape that shipped: the answer is resolved and used elsewhere in // the module while the link itself sits outside any branch. A matcher - // asking "does this file name canManage" calls this clean. + // asking "does this file name inSharedRecord" calls this clean. const UNGATED = ` export function Page() { - const { canManage } = useRecordCapabilities(); + const { inSharedRecord } = useRecordCapabilities(); return ( <> - {canManage && )} - {/* v1.36.x — a mood entry is not one of the verbs a grant - admits, so it stays with the account whose record it is. A - delegate at any level gets no add path here. */} - {canManage && ( + {/* A mood entry is a MANAGE create: a WRITE delegate gets no add + path here, a guardian or a MANAGE delegate does. */} + {canAddMood && ( deleteMutation.mutate(r.id)} title={t("customMetrics.entry.deleteConfirmTitle")} description={t( diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index 9363e4511..73d0cdacf 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -38,7 +38,9 @@ export function CustomMetricList() { // Logging a value against a custom metric was admitted for a release and is // not any more: this paragraph named the gap, the release shipped over it, // and the write withdrew rather than the sentence staying true. - const { canManage } = useRecordCapabilities(); + // Every custom-metric write resolves the caller (`requireAuth`); no grant + // reaches it at any level. + const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); @@ -57,7 +59,7 @@ export function CustomMetricList() { router.push(`/custom-metrics/${saved.id}`); } - if (!canManage) return null; + if (inSharedRecord) return null; return (
({ })); vi.mock("@/hooks/use-record-capabilities", () => ({ - useRecordCapabilities: () => ({ canManage: true, canAdd: true }), + useRecordCapabilities: () => ({ + canManage: true, + canAdd: true, + canManageDomain: () => true, + }), })); const errorQuery = { diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index c268f4a20..b8c084508 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -93,7 +93,8 @@ export function CycleView() { // v1.36.x — no grant level admits a cycle write. Inside somebody else's // record the page stays readable and every path into the log-day sheet and // the profile settings is absent, rather than opening onto a refusal. - const { canManage } = useRecordCapabilities(); + const { canManageDomain } = useRecordCapabilities(); + const canManageCycle = canManageDomain("cycle"); // Deep-link support: `/cycle?tab=insights` opens the insights tab. The value // is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal @@ -188,7 +189,7 @@ export function CycleView() { // One action. The settings glyph that used to sit beside it was a // second door to the tab directly below, and no other page in the // app carries a gear in that corner. - canManage ? ( + canManageCycle ? ( ) : null} - {canManage && ( + {canManageDocuments && ( deleteMutation.mutate(marker.id)} title={t("labs.biomarker.deleteConfirmTitle")} description={t("labs.biomarker.deleteConfirmDescription")} diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index dbcfce3ed..97e0dcd3d 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200; */ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { user } = useAuth(); - const { canAdd, canManage } = useRecordCapabilities(); + const { canAdd, canManageDomain } = useRecordCapabilities(); + const canManageLabs = canManageDomain("labs"); const { t, locale } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); @@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { full marker editor behind the pencil, so the separate range control was dropped). */} deleteMarker.mutate()} title={t("labs.biomarker.deleteConfirmTitle")} description={t("labs.biomarker.deleteConfirmDescription")} @@ -340,7 +342,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" iconClassName="h-4 w-4" /> - {canManage && ( + {canManageLabs && ( @@ -864,7 +866,8 @@ function VorsorgeCard({ }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { canManage } = useRecordCapabilities(); + const { canManageDomain } = useRecordCapabilities(); + const canManageMeasurements = canManageDomain("measurements"); // Issue #490 — day-boundary zone for the relative "today / yesterday" // bucket must match the zone `fmt.date` renders in (mirror → Berlin). // The next-due phrase reads off the SAME zone as the last-done date below @@ -938,7 +941,7 @@ function VorsorgeCard({ // Single med-style kebab: Edit + Delete. The Delete item is the shared // confirm-on-delete control rendered as a full-width menu row. - const headerActions = canManage ? ( + const headerActions = canManageMeasurements ? ( )} deleteMutation.mutate(m.id)} title={t("measurements.deleteConfirmTitle")} @@ -1535,6 +1537,7 @@ export function MeasurementList({ {/* v1.15.13 — page-scoped multi-select action bar. */} (""); const [busy, setBusy] = useState(false); @@ -443,7 +445,7 @@ function RetargetControl({ } }; - if (!canManage || items.length === 0) return null; + if (inSharedRecord || items.length === 0) return null; return (
= new Set([ + // Schedule times are a MANAGE route, but the tab also carries the reminder + // and notification settings, which resolve the caller. "zeitplan", - "bestand", + // Per-medication tokens: a credential surface, never delegated. "api", + // Phase config, export and the token-adjacent rows resolve the caller. "erweitert", ]); +/** + * v1.38.12 — tabs whose every write is a MANAGE route under `medications`, + * so a grant that reaches those routes is shown the tab. The supply ledger + * is one; the three above are not, because each mixes in a route that + * resolves the caller and would 403 under a switch. + */ +const MANAGE_TABS: ReadonlySet = new Set(["bestand"]); + /** * Legacy slugs that point at a dissolved tab. The Erinnerung tab folded * into Zeitplan in v1.15.20; old deep-links keep landing on the surface @@ -242,7 +253,8 @@ export function MedicationDetailTabs({ }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { canManage } = useRecordCapabilities(); + const { canManageDomain, inSharedRecord } = useRecordCapabilities(); + const canManageMedications = canManageDomain("medications"); const router = useRouter(); const searchParams = useSearchParams(); @@ -308,9 +320,16 @@ export function MedicationDetailTabs({ (slug !== "injektion" || isInjectable) && (slug !== "zeitplan" || !asNeeded) && (slug !== "wirkung" || hasEfficacyTarget) && - (canManage || !OWNER_ONLY_TABS.has(slug)), + (!inSharedRecord || !OWNER_ONLY_TABS.has(slug)) && + (canManageMedications || !MANAGE_TABS.has(slug)), ), - [isInjectable, asNeeded, hasEfficacyTarget, canManage], + [ + isInjectable, + asNeeded, + hasEfficacyTarget, + inSharedRecord, + canManageMedications, + ], ); const requestedRaw = searchParams?.get("tab"); @@ -908,7 +927,7 @@ export function MedicationDetailTabs({ (`PUT /api/medications/[id]` is not delegable), so the deep link is gated exactly like the hero button it stands in for. */} void; }) { const { t } = useTranslations(); - const { canAdd, canManage } = useRecordCapabilities(); + const { canAdd, canManageDomain } = useRecordCapabilities(); + const canManageMedications = canManageDomain("medications"); const fmt = useFormatters(); const actionable = isSlotActionable(row); @@ -733,7 +734,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 && !canManage && ( + {showTakenButton && canAdd && !canManageMedications && (
+ {/* `/settings` is not a shared-record destination, so the cross-link + is withheld under a switch rather than opening onto the "not part + of what was shared" panel. */} + {!inSharedRecord && ( +
+ + {lowStockDays !== null + ? t("medications.detail.bestand.lowStockLinkOn", { + days: lowStockDays, + }) + : t("medications.detail.bestand.lowStockLinkOff")} + +
+ )} {/* v1.16.11 — packaging economics surfaced where the stock lives: @@ -443,6 +450,7 @@ export function InventorySection({ {t("medications.detail.bestand.adjustButton")} void deleteItem(item)} title={t("medications.detail.bestand.deleteTitle")} description={t("medications.detail.bestand.deleteDescription")} diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 1e376d0be..ebc9f3418 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string { export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const { t } = useTranslations(); - const { canAdd, canManage } = useRecordCapabilities(); + const { canAdd, canManageDomain } = useRecordCapabilities(); + const canManageMedications = canManageDomain("medications"); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -346,7 +347,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {

)} - {canManage && ( + {canManageMedications && ( - ) : onAddFirst && canManage ? ( + ) : onAddFirst && canManageMind ? ( deleteMutation.mutate(row.id)} title={t("records.allergies.deleteConfirmTitle")} description={t("records.allergies.deleteConfirmDescription")} diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index ba007a21b..1b865bc8a 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -91,6 +91,7 @@ export function FamilyHistoryManager() { deleteMutation.mutate(row.id)} title={t("records.family.deleteConfirmTitle")} description={t("records.family.deleteConfirmDescription")} diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 347184c99..ab4b97b6f 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -37,6 +37,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; import { ApiError } from "@/lib/api/api-fetch"; import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import type { GrantList } from "@/lib/queries/use-account-grants"; import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles"; @@ -219,6 +220,8 @@ function entry(partial: Partial = {}): AccountAccessEntry { recordKind: "managed", sections: null, canWrite: true, + writableDomains: delegatedDomains("manage", null, "write"), + manageableDomains: delegatedDomains("manage", null, "manage"), ...partial, }; } diff --git a/src/hooks/__tests__/use-auth-account-access-state.test.ts b/src/hooks/__tests__/use-auth-account-access-state.test.ts index cabb5bad1..cc048368b 100644 --- a/src/hooks/__tests__/use-auth-account-access-state.test.ts +++ b/src/hooks/__tests__/use-auth-account-access-state.test.ts @@ -37,6 +37,8 @@ const canonicalEntry = { recordKind: "shared", sections: ["labs"], canWrite: false, + writableDomains: [], + manageableDomains: [], }; beforeEach(() => { diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index 4a7609b06..ef791c475 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -14,6 +14,8 @@ * nothing" goes red. * - make the `!active` branch return `canAdd: 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. */ import { describe, expect, it } from "vitest"; @@ -22,6 +24,8 @@ import { resolveRecordCapabilities, } from "@/hooks/use-record-capabilities"; import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; const READ_ONLY: AccountAccessEntry = { accountId: "acct-owner", @@ -33,13 +37,20 @@ const READ_ONLY: AccountAccessEntry = { sections: null, recordKind: "shared", canWrite: false, + writableDomains: [], + manageableDomains: [], }; +// The lists are what the server publishes for each level over the whole +// record: the same helper `resolveAccountAccess` runs, so the fixture is the +// payload and not a guess at it. const WRITABLE: AccountAccessEntry = { ...READ_ONLY, access: "write", level: "write", canWrite: true, + writableDomains: delegatedDomains("write", null, "write"), + manageableDomains: [], }; const MANAGING: AccountAccessEntry = { @@ -47,6 +58,8 @@ const MANAGING: AccountAccessEntry = { access: "write", level: "manage", canWrite: true, + writableDomains: delegatedDomains("manage", null, "write"), + manageableDomains: delegatedDomains("manage", null, "manage"), }; const SCOPED: AccountAccessEntry = { @@ -59,7 +72,8 @@ describe("resolveRecordCapabilities", () => { // `null` is not "no access", it is "this is mine". Both an unswitched // session and a payload from a server that has never heard of sharing // land here, which is why the absent case must be the permissive one. - expect(resolveRecordCapabilities(null)).toEqual({ + const own = resolveRecordCapabilities(null); + expect(own).toMatchObject({ inSharedRecord: false, canWrite: false, canAdd: true, @@ -71,13 +85,21 @@ describe("resolveRecordCapabilities", () => { sections: null, recordKind: "self", }); - expect(resolveRecordCapabilities(undefined)).toEqual( - resolveRecordCapabilities(null), - ); + for (const domain of SHARE_DOMAINS) { + expect(own.canWriteDomain(domain), domain).toBe(true); + expect(own.canManageDomain(domain), domain).toBe(true); + } + expect(resolveRecordCapabilities(undefined)).toMatchObject({ + inSharedRecord: false, + canAdd: true, + canManage: true, + recordKind: "self", + }); }); it("refuses a malformed published block instead of treating it as own record", () => { - expect(resolveRecordCapabilities(null, true)).toEqual({ + const refused = resolveRecordCapabilities(null, true); + expect(refused).toMatchObject({ inSharedRecord: true, canWrite: false, canAdd: false, @@ -87,10 +109,15 @@ describe("resolveRecordCapabilities", () => { recordKind: "shared", accessRefused: true, }); + for (const domain of SHARE_DOMAINS) { + expect(refused.canWriteDomain(domain), domain).toBe(false); + expect(refused.canManageDomain(domain), domain).toBe(false); + } }); it("a read-only delegate adds nothing", () => { - expect(resolveRecordCapabilities(READ_ONLY)).toEqual({ + const reader = resolveRecordCapabilities(READ_ONLY); + expect(reader).toMatchObject({ inSharedRecord: true, canWrite: false, canAdd: false, @@ -99,12 +126,17 @@ describe("resolveRecordCapabilities", () => { sections: null, recordKind: "shared", }); + for (const domain of SHARE_DOMAINS) { + expect(reader.canWriteDomain(domain), domain).toBe(false); + expect(reader.canManageDomain(domain), domain).toBe(false); + } }); it("a delegate may not change what is already there", () => { // The asymmetry the whole design rests on: a WRITE grant adds, and that // is all it does. Not even to an entry the delegate made a minute ago. - expect(resolveRecordCapabilities(WRITABLE)).toEqual({ + const writer = resolveRecordCapabilities(WRITABLE); + expect(writer).toMatchObject({ inSharedRecord: true, canWrite: true, canAdd: true, @@ -113,6 +145,14 @@ describe("resolveRecordCapabilities", () => { sections: null, recordKind: "shared", }); + // Writable where a WRITE route exists, manageable nowhere. + expect(writer.canWriteDomain("measurements")).toBe(true); + expect(writer.canWriteDomain("labs")).toBe(true); + expect(writer.canWriteDomain("mind")).toBe(false); + expect(writer.canWriteDomain("documents")).toBe(false); + for (const domain of SHARE_DOMAINS) { + expect(writer.canManageDomain(domain), domain).toBe(false); + } }); it("carries the level and the sections through untouched", () => { @@ -128,22 +168,45 @@ describe("resolveRecordCapabilities", () => { expect(resolveRecordCapabilities(MANAGING).level).toBe("manage"); }); - it("does not hand a manage grant the affordances its routes cannot answer yet", () => { - // The deliberate gap, pinned so it cannot close by accident. MANAGE will - // bring the edit and delete controls back, and it does so in the release - // where the routes behind them answer — not in the one that merely - // publishes the level. A `canManage: true` here today would paint - // controls that 403, which is the exact failure these booleans exist to - // prevent. - expect(resolveRecordCapabilities(MANAGING)).toEqual({ + it("hands a manage grant exactly the sections its routes answer for", () => { + // The v1.37.0 hold-back answered `canManage: false` here until the routes + // behind the edit and delete controls existed. They exist; the answer is + // the server's per-section list, and the coarse boolean is "is that list + // non-empty". + const manager = resolveRecordCapabilities(MANAGING); + expect(manager).toMatchObject({ inSharedRecord: true, canWrite: true, canAdd: true, - canManage: false, + canManage: true, level: "manage", sections: null, recordKind: "shared", }); + expect(manager.canManageDomain("mind")).toBe(true); + expect(manager.canManageDomain("profile")).toBe(true); + expect(manager.canManageDomain("labs")).toBe(true); + expect(manager.canWriteDomain("mind")).toBe(true); + }); + + it("never lets a shared record manage the vault", () => { + // The vault has no delegated write route, so the server's list never + // names it — and the hook binds the list rather than the level. Answering + // from `level === "manage"` would paint the upload control that 403s. + expect( + resolveRecordCapabilities(MANAGING).canManageDomain("documents"), + ).toBe(false); + expect( + resolveRecordCapabilities(MANAGING).canWriteDomain("documents"), + ).toBe(false); + const claimsEverything: AccountAccessEntry = { + ...MANAGING, + manageableDomains: [], + }; + // An empty list from the server is an empty answer, whatever the level. + const held = resolveRecordCapabilities(claimsEverything); + expect(held.canManage).toBe(false); + expect(held.canManageDomain("mind")).toBe(false); }); it("binds the server's boolean rather than the grant's label", () => { @@ -157,6 +220,19 @@ describe("resolveRecordCapabilities", () => { canWrite: false, }; expect(resolveRecordCapabilities(disagreeing).canAdd).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 = { + ...WRITABLE, + sections: ["labs"], + writableDomains: ["labs"], + }; + expect(resolveRecordCapabilities(scopedWriter).canWriteDomain("labs")).toBe( + true, + ); + expect( + resolveRecordCapabilities(scopedWriter).canWriteDomain("measurements"), + ).toBe(false); }); }); @@ -242,8 +318,10 @@ describe("an unprovable record context withholds every control", () => { it("leaves every existing arm untouched when the two agree", () => { // The fourth argument defaults to false, so every case above this block // means exactly what it meant before the fence existed. - expect(resolveRecordCapabilities(READ_ONLY)).toEqual( - resolveRecordCapabilities(READ_ONLY, false, false, false), - ); + expect(resolveRecordCapabilities(READ_ONLY)).toMatchObject({ + ...resolveRecordCapabilities(READ_ONLY, false, false, false), + canWriteDomain: expect.any(Function), + canManageDomain: expect.any(Function), + }); }); }); diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index ddfdbcf90..524c075a8 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -6,6 +6,10 @@ import { accountLabel, type AccountAccessEntry, } from "@/lib/sharing/account-access-view"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +const NEVER = () => false; +const ALWAYS = () => true; /** * What the person at the keyboard may do to the record on screen. @@ -20,7 +24,7 @@ import { * still taught them the product was broken. This hook is what every mutation * affordance asks before it renders. * - * ## The two answers, and why there are two + * ## The answers, and why there are several * * A grant at WRITE admits a short, closed list of verbs: entering readings, * lab results, an illness entry, a biomarker, a side effect, a medication, and @@ -32,34 +36,44 @@ import { * reason one release later: its only form sits on `/custom-metrics/{id}`, * which a switch closes too. * `sharing-surface-guard.test.ts` carries that argument - * in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and - * bulk-acting stay with the owner, including on an entry the delegate added - * themselves a minute ago, and so does every create the list does not name - * (a mood entry, a screener, a cycle day). Two booleans carry that: + * in full and is the list that binds; this paragraph has to agree with it. + * Editing, deleting, restoring, purging, importing and bulk-acting are MANAGE + * verbs, and so is every create the list does not name (a mood entry, a + * screener, a cycle day). Which sections answer at which level is the + * 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. - * - `canManage` — may this person change what is already there, or add - * something the delegation does not cover. True only in one's own record. + * - `canWriteDomain(domain)` — is there a delegated write in this section + * the grant satisfies. + * - `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 + * manage. The banner and the navigation ask it; a control never should, + * 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`. Everything else - * asks `canManage`. Neither is a permission decision made here: `canWrite` - * arrives resolved from the server on `accountAccess.active` and is bound, - * never recomputed (`account-access-view.ts:11-15`). What this file decides is - * only which class of affordance a resolved level covers, and that mapping is - * a property of the release, not of the caller. + * A surface that offers an admitted create asks `canAdd`. 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 + * `!inSharedRecord`: no grant reaches it at any level. None of these is a + * permission decision made here: `canWrite` and the two lists arrive resolved + * from the server on `accountAccess.active` and are bound, never recomputed + * (`account-access-view.ts`). What this file decides is only which class of + * affordance a resolved answer covers. * - * ## The two facts this hook carries but does not yet act on + * ## Why the lists are per section * - * v1.37.0 gives a grant a section set and a third level, and both arrive here - * (`sections`, `level`) as resolved values beside the booleans. Nothing in - * this file turns either into a capability, and that is the state of the - * release rather than an omission: the nav narrowing that reads `sections` - * and the return of the edit and delete affordances that reads `level` are - * the shared-session UX work, which lands after the routes that answer for a - * MANAGE grant exist. Publishing the facts here first is what lets those be - * a rendering change rather than a second access decision — and a control - * that appeared before its route answered would be the failure the two - * booleans above were written to end. + * v1.37.0 published `level` and `sections` and answered `canManage: false` + * for every shared record, a hold-back for the release where the level + * arrived before the routes behind it. The routes landed and the hold-back + * stayed, so a guardian of a managed profile saw no add, edit or delete + * control on Mood, screeners, visits, allergies and labs while the server + * accepted every one of those writes (#939). A single boolean could not have + * closed that without opening the vault, whose routes accept none; the lists + * close it section by section, and a section with no delegated route stays + * closed by the same mechanism. * * ## Absent, not disabled * @@ -82,17 +96,35 @@ export interface RecordCapabilities { canWrite: boolean; /** May the caller add an entry of a kind the delegation admits. */ canAdd: boolean; - /** May the caller change what exists, or add what the delegation excludes. */ + /** + * 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, + * true when `manageableDomains` is non-empty: the coarse switch the banner + * and the navigation read. A control asks {@link canManageDomain} for its + * own section instead, because the answer differs per section. + */ canManage: boolean; + /** + * v1.38.12 — is there a delegated write in `domain` this grant satisfies. + * Own record: always. Shared record: membership in + * `accountAccess.active.writableDomains`. Refused, pending or unproven + * context: never. + */ + canWriteDomain: (domain: ShareDomain) => boolean; + /** + * v1.38.12 — may the caller edit, delete or bulk-act in `domain`, or add + * what a WRITE grant does not cover there. Own record: always. Shared + * record: membership in `accountAccess.active.manageableDomains`. Refused, + * pending or unproven context: never. + */ + canManageDomain: (domain: ShareDomain) => boolean; /** * v1.37.0 — the resolved level of the grant on screen, or `null` in one's * own record (which is not a grant and has no level). * - * Bound from `accountAccess.active.level`, never derived. `canManage` is - * deliberately NOT computed from it in this release: the edit and delete - * affordances come back for `"manage"` when the routes that answer them - * land, and a control that appears before its route answers is the exact - * failure the two booleans above exist to prevent. + * Bound from `accountAccess.active.level`, never derived, and not what the + * controls read: the two domain lists are, because a level says what was + * granted and the lists say which routes answer for it. */ level: AccountAccessEntry["level"] | null; /** @@ -136,6 +168,8 @@ export function resolveRecordCapabilities( canWrite: false, canAdd: false, canManage: false, + canWriteDomain: NEVER, + canManageDomain: NEVER, level: null, sections: [], recordKind: "shared", @@ -147,6 +181,8 @@ export function resolveRecordCapabilities( canWrite: false, canAdd: true, canManage: true, + canWriteDomain: ALWAYS, + canManageDomain: ALWAYS, // One's own record has no grant, so it has no level, and it is open in // full. Null on both counts rather than a fabricated "manage" over all // eight sections: the caller is not a delegate, and a consumer that had @@ -156,11 +192,19 @@ export function resolveRecordCapabilities( recordKind: "self", }; } + // Bound, not derived: the two lists are the server's intersection of the + // grant with the routes that exist. Membership is the whole of the client's + // part, and `canManage` is only the question "is either list non-empty" + // asked of the manage one. + const writable = new Set(active.writableDomains); + const manageable = new Set(active.manageableDomains); return { inSharedRecord: true, canWrite: active.canWrite, canAdd: active.canWrite, - canManage: false, + canManage: manageable.size > 0, + canWriteDomain: (domain) => writable.has(domain), + canManageDomain: (domain) => manageable.has(domain), level: active.level, sections: active.sections, recordKind: active.recordKind, diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx index 9e94ce003..482a4038f 100644 --- a/src/lib/insights/__tests__/coach-launch-context.test.tsx +++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx @@ -35,6 +35,8 @@ const OWNER = { sections: null, recordKind: "shared" as const, canWrite: true, + writableDomains: [], + manageableDomains: [], }; const mockAccessRef: { value: AccountAccess } = { diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-schema.test.ts index a702767ad..16170236b 100644 --- a/src/lib/sharing/__tests__/account-access-schema.test.ts +++ b/src/lib/sharing/__tests__/account-access-schema.test.ts @@ -12,6 +12,8 @@ const sharedEntry = { recordKind: "shared", sections: ["labs"], canWrite: false, + writableDomains: [], + manageableDomains: [], }; function accessBlock(overrides: Record = {}) { @@ -45,6 +47,8 @@ describe("parseAccountAccess", () => { level: "manage", sections: null, canWrite: true, + writableDomains: ["measurements", "mind"], + manageableDomains: ["mind"], }; const parsed = parseAccountAccess( @@ -56,9 +60,28 @@ describe("parseAccountAccess", () => { access: "write", sections: null, canWrite: true, + writableDomains: ["measurements", "mind"], + manageableDomains: ["mind"], }); }); + it("keeps the delegated write lists when they are consistent with the grant", () => { + const writer = { + ...sharedEntry, + access: "write", + level: "write", + sections: ["labs", "measurements"], + canWrite: true, + writableDomains: ["measurements", "labs"], + manageableDomains: [], + }; + const parsed = parseAccountAccess( + accessBlock({ accounts: [writer], active: writer }), + ); + expect(parsed?.active?.writableDomains).toEqual(["measurements", "labs"]); + expect(parsed?.active?.manageableDomains).toEqual([]); + }); + it.each([ [ "legacy access and resolved level", @@ -79,6 +102,28 @@ describe("parseAccountAccess", () => { // field. A parity matcher blind to it would let the banner and the // switcher name the same owner differently. ["full name", { fullName: "Different name" }, {}], + // v1.38.12 — the two lists are read by every control, so the two views + // must agree on them like on every other field. + [ + "writable domains", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["labs"], + }, + { + accounts: [ + { + ...sharedEntry, + access: "write", + level: "write", + canWrite: true, + writableDomains: [], + }, + ], + }, + ], ])( "fails closed when active disagrees with its canonical entry on %s", (_field, activeOverrides, blockOverrides) => { @@ -105,6 +150,51 @@ describe("parseAccountAccess", () => { "manage with read legacy field", { access: "read", level: "manage", sections: null, canWrite: true }, ], + // v1.38.12 — structural invariants of the two lists. Which sections + // qualify is the server's table and is not re-decided here; what IS + // checked is that the lists cannot claim more than the grant. + ["read grant with a writable domain", { writableDomains: ["labs"] }], + [ + "write grant with a manageable domain", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["labs"], + manageableDomains: ["labs"], + }, + ], + [ + "manageable domain that is not writable", + { + access: "write", + level: "manage", + sections: null, + canWrite: true, + writableDomains: ["labs"], + manageableDomains: ["mind"], + }, + ], + [ + "writable domain outside the grant's sections", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["measurements"], + }, + ], + [ + "duplicated writable domain", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["labs", "labs"], + }, + ], + ["unknown writable domain", { writableDomains: ["other"] }], + ["missing writable domains", { writableDomains: undefined }], ])("fails closed for %s entries", (_name, entryOverrides) => { const entry = { ...sharedEntry, ...entryOverrides }; diff --git a/src/lib/sharing/__tests__/account-access.test.ts b/src/lib/sharing/__tests__/account-access.test.ts index 3fc48b80a..4ed387f98 100644 --- a/src/lib/sharing/__tests__/account-access.test.ts +++ b/src/lib/sharing/__tests__/account-access.test.ts @@ -10,20 +10,23 @@ vi.mock("@/lib/db", () => ({ import { prisma } from "@/lib/db"; import { resolveAccountAccess } from "../account-access"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import { decodeLegacyWholeRecordAccess } from "../account-access-view"; import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads"; function activeGrant({ access, managedProfileAt = null, + scopeJson = null, }: { access: "READ" | "WRITE" | "MANAGE"; managedProfileAt?: Date | null; + scopeJson?: string[] | null; }) { return { id: `grant-${access.toLowerCase()}`, access, - scopeJson: null, + scopeJson, acceptedAt: new Date("2026-08-01T00:00:00.000Z"), revokedAt: null, expiresAt: null, @@ -69,11 +72,47 @@ describe("resolveAccountAccess", () => { level: "manage", sections: null, recordKind: "managed", + // v1.38.12 — the routes' answer for a whole-record MANAGE grant: every + // section with a delegated write route, and never the vault. + writableDomains: delegatedDomains("manage", null, "write"), + manageableDomains: delegatedDomains("manage", null, "manage"), }), ); + expect(access.active?.manageableDomains).toContain("mind"); + expect(access.active?.manageableDomains).not.toContain("documents"); expect(access.recordKind).toBe("managed"); }); + it("publishes a WRITE grant's writable sections within its scope and no manageable ones", async () => { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "WRITE", scopeJson: ["mind", "labs"] }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: "record-owner" }, + }); + + // `mind` is opened by the scope but has no WRITE route, so it is absent; + // `labs` has one. Consent order, not scope order. + expect(access.active?.writableDomains).toEqual(["labs"]); + expect(access.active?.manageableDomains).toEqual([]); + }); + + it("publishes two empty lists for a READ grant", async () => { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "READ" }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: "record-owner" }, + }); + + expect(access.active?.writableDomains).toEqual([]); + expect(access.active?.manageableDomains).toEqual([]); + }); + it("resolves an ordinary shared record on the server", async () => { vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ activeGrant({ access: "READ" }), diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts index 779afc84b..76e5cb060 100644 --- a/src/lib/sharing/account-access-schema.ts +++ b/src/lib/sharing/account-access-schema.ts @@ -6,6 +6,20 @@ import { SHARE_DOMAINS } from "./scope"; const accountAccessLevelSchema = z.enum(["read", "write", "manage"]); const accountRecordKindSchema = z.enum(["shared", "managed"]); const shareSectionsSchema = z.array(z.enum(SHARE_DOMAINS)).nullable(); +const shareDomainListSchema = z.array(z.enum(SHARE_DOMAINS)); + +function hasRepeat(domains: readonly string[]): boolean { + return new Set(domains).size !== domains.length; +} + +function isSubset( + inner: readonly string[], + outer: readonly string[] | null, +): boolean { + if (outer === null) return true; + const set = new Set(outer); + return inner.every((domain) => set.has(domain)); +} /** The browser's resolved account-access payload, shared with OpenAPI. */ export const accountAccessEntrySchema = z @@ -22,6 +36,11 @@ export const accountAccessEntrySchema = z recordKind: accountRecordKindSchema, sections: shareSectionsSchema, canWrite: z.boolean(), + // v1.38.12 — the sections the grant can add to / change, resolved + // server-side. Structural invariants only are checked here; which + // sections qualify is the server's table and is not re-decided. + writableDomains: shareDomainListSchema, + manageableDomains: shareDomainListSchema, }) .superRefine((entry, ctx) => { const uniqueSections = new Set(entry.sections ?? []); @@ -56,6 +75,48 @@ export const accountAccessEntrySchema = z path: ["access"], }); } + + if ( + hasRepeat(entry.writableDomains) || + hasRepeat(entry.manageableDomains) + ) { + ctx.addIssue({ + code: "custom", + message: "Delegated write domains must not repeat a domain", + path: ["writableDomains"], + }); + } + if ( + !isSubset(entry.writableDomains, entry.sections) || + !isSubset(entry.manageableDomains, entry.sections) + ) { + ctx.addIssue({ + code: "custom", + message: "Delegated write domains must lie within the grant's sections", + path: ["writableDomains"], + }); + } + if (!isSubset(entry.manageableDomains, entry.writableDomains)) { + ctx.addIssue({ + code: "custom", + message: "Manageable domains must be writable domains", + path: ["manageableDomains"], + }); + } + if (entry.level === "read" && entry.writableDomains.length > 0) { + ctx.addIssue({ + code: "custom", + message: "A read grant has no writable domains", + path: ["writableDomains"], + }); + } + if (!isManage && entry.manageableDomains.length > 0) { + ctx.addIssue({ + code: "custom", + message: "Only a manage grant has manageable domains", + path: ["manageableDomains"], + }); + } }); export const accountAccessBlockSchema = z.object({ @@ -80,7 +141,12 @@ function matchesCanonicalAccountAccessEntry( (section, index) => section === canonicalSections[index], ); + const sameList = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((domain, index) => domain === b[index]); + return ( + sameList(active.writableDomains, canonical.writableDomains) && + sameList(active.manageableDomains, canonical.manageableDomains) && active.accountId === canonical.accountId && active.username === canonical.username && active.displayName === canonical.displayName && diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 23c360bf9..672484ac7 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -103,6 +103,24 @@ export interface AccountAccessEntry { * 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. + * + * A list rather than a boolean because the answer differs per section, and + * the client's per-control question is "this section", never "any". + */ + writableDomains: ShareDomain[]; + /** + * v1.38.12 — the sections this grant can change: edit, delete, restore, + * bulk-act, and the creates a WRITE grant does not cover. A subset of + * {@link AccountAccessEntry.writableDomains} by construction, and empty + * below MANAGE. + */ + manageableDomains: ShareDomain[]; } /** The `accountAccess` block on `GET /api/auth/me`. */ diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 0ce051611..c46885ab9 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -42,6 +42,7 @@ import type { AccountAccessEntry, AccountAccessLevel, } from "@/lib/sharing/account-access-view"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import { resolveGrantSections } from "@/lib/sharing/grant-view"; import { grantAllows, isGrantActive } from "@/lib/sharing/grants"; import type { AccountGrantAccess } from "@/generated/prisma/client"; @@ -126,6 +127,7 @@ export async function resolveAccountAccess(auth: { // clients that have not learned the canonical level. `level` names the // complete three-level contract. const level = LEVEL[grant.access]; + const sections = resolveGrantSections(grant.scopeJson); return { accountId: grant.grantor.id, username: grant.grantor.username, @@ -135,8 +137,14 @@ export async function resolveAccountAccess(auth: { level, recordKind: grant.grantor.managedProfileAt === null ? "shared" : "managed", - sections: resolveGrantSections(grant.scopeJson), + sections, canWrite: grantAllows(grant, "write", now), + // v1.38.12 — per-section write answers, from the same level and scope + // the two fields above publish, intersected with the routes that + // exist. The lists are what the controls read; the level and the + // sections stay published so the chrome can say what was granted. + writableDomains: delegatedDomains(level, sections, "write"), + manageableDomains: delegatedDomains(level, sections, "manage"), }; }); diff --git a/src/lib/sharing/domain-write-support.ts b/src/lib/sharing/domain-write-support.ts new file mode 100644 index 000000000..ad182b434 --- /dev/null +++ b/src/lib/sharing/domain-write-support.ts @@ -0,0 +1,101 @@ +/** + * Which sections of a record accept a delegated write, and at which level. + * + * `requireRecordAuth(need, domain)` is declared per route, so the server has + * always known which sections a WRITE or a MANAGE grant can reach. The client + * did not: `resolveRecordCapabilities` answered `canManage: false` for every + * shared record from v1.37.0 on, a hold-back written for the release where the + * level was published before the routes behind it existed. The routes exist + * now, and the hold-back kept hiding the add, edit and delete controls on a + * managed profile whose every write the server accepts (#939). + * + * Flipping the boolean would have overshot in the other direction: the vault + * has no delegated write route at all, so a manage grant that painted the + * upload control there would paint a control that 403s. The answer is per + * section and it is the server's: this file is the one table, the account + * payload publishes the intersection of a grant with it, and the client reads + * the lists. + * + * The values are a property of the routes and nothing else, which is why they + * are frozen rather than trusted: `domain-write-support-guard.test.ts` scans + * every `route.ts` under `src/app/api` for `requireRecordAuth("write" | + * "manage", "")` and fails when this table and the routes disagree in + * either direction. Editing a value here without a route to justify it, or + * adding a delegated write route without editing here, is a red test. + * + * `record` is deliberately absent. It is not a section a grant can be narrowed + * to (`scope.ts`), and a route declaring it reads across sections; nothing a + * per-section control asks is answered by it. + */ +import type { AccountAccessLevel } from "@/lib/sharing/account-access-view"; +import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; + +/** Whether any route in the section declares the need. */ +export interface DomainWriteSupport { + /** At least one route declares `requireRecordAuth("write", domain)`. */ + readonly write: boolean; + /** At least one route declares `requireRecordAuth("manage", domain)`. */ + readonly manage: boolean; +} + +/** + * The table. Derived from the route files on 2026-09-09 and frozen by the + * structural guard; the reasons are in the routes, not here. + */ +export const DOMAIN_WRITE_SUPPORT: Readonly< + Record +> = { + measurements: { write: true, manage: true }, + medications: { write: true, manage: true }, + labs: { write: true, manage: true }, + profile: { write: true, manage: true }, + illness: { write: true, manage: true }, + // A mood entry and a screener are created at MANAGE, not at WRITE. + mind: { write: false, manage: true }, + // Every cycle write is a MANAGE route. + cycle: { write: false, manage: true }, + // The vault is read-only under every grant; upload, filing, sharing and the + // AI verbs stay with the owner. + documents: { write: false, manage: false }, +}; + +/** The two needs a delegated write can declare. */ +export type DelegatedWriteNeed = "write" | "manage"; + +const LEVEL_RANK: Record = { + read: 0, + write: 1, + manage: 2, +}; + +/** + * The sections a grant can write to, or manage, given its level and scope. + * + * `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. + * + * 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. + * That is the `write ⊂ manage` ordering `grantAllows` enforces, stated as + * lists — a section a manage grant can manage is a section it can write to, + * so `manageable ⊆ writable` holds for every grant. + * + * For `need === "manage"` only a MANAGE grant answers, and only where a route + * declares the need. The result keeps the consent screen's order. + */ +export function delegatedDomains( + level: AccountAccessLevel, + sections: readonly ShareDomain[] | null, + need: DelegatedWriteNeed, +): ShareDomain[] { + if (LEVEL_RANK[level] < LEVEL_RANK[need]) return []; + const opened = + sections === null ? new Set(SHARE_DOMAINS) : new Set(sections); + return SHARE_DOMAINS.filter((domain) => { + if (!opened.has(domain)) return false; + const support = DOMAIN_WRITE_SUPPORT[domain]; + if (need === "manage") return support.manage; + return support.write || (level === "manage" && support.manage); + }); +} diff --git a/tests/integration/managed-record-capabilities.test.ts b/tests/integration/managed-record-capabilities.test.ts new file mode 100644 index 000000000..66458e9ff --- /dev/null +++ b/tests/integration/managed-record-capabilities.test.ts @@ -0,0 +1,251 @@ +/** + * v1.38.12 — what a delegate is told they may do, and what the routes then do. + * + * `GET /api/auth/me` publishes two lists on the active entry, + * `writableDomains` and `manageableDomains`, and every add / edit / delete + * control in a shared record reads them. The lists are worth nothing unless + * the routes agree with them, so this file drives both ends over the real + * route handlers, the real grant table and real Postgres: + * + * 1. a guardian acting on a managed profile reads the payload and gets + * every section with a delegated route as manageable, and never the + * vault; + * 2. the same guardian posts a mood entry and an allergy — the two writes + * #939 was reported on — and both land on the PROFILE's record; + * 3. the same guardian is refused a vault write, which is what the empty + * answer for `documents` promised; + * 4. a WRITE delegate on an adult's record reads writable sections and no + * manageable ones, and is refused the mood entry the list withheld. + * + * The lists are compared against `delegatedDomains`, the helper the resolver + * itself runs, so a change to the table shows up here as a change in what the + * routes must then honour, not as a fixture to update. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +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), +})); + +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `caps-${suffix}`, + email: `caps-${suffix}@example.test`, + displayName: `Caps ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + onboardingCompletedAt: new Date(), + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +interface ActiveEntry { + accountId: string; + level: "read" | "write" | "manage"; + recordKind: "shared" | "managed"; + writableDomains: ShareDomain[]; + manageableDomains: ShareDomain[]; +} + +async function readActive(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { accountAccess: { active: ActiveEntry | null } }; + }; + const active = body.data.accountAccess.active; + if (active === null) throw new Error("expected a switched payload"); + return active; +} + +function jsonRequest(url: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${url}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function postMood() { + const { POST } = await import("@/app/api/mood-entries/route"); + return POST( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: new Date().toISOString(), + }), + ); +} + +async function postAllergy() { + const { POST } = await import("@/app/api/allergies/route"); + return POST( + jsonRequest("/api/allergies", { + substance: "Penicillin", + category: "MEDICATION", + severity: "SEVERE", + }), + ); +} + +async function postDocumentsBulk() { + const { POST } = await import("@/app/api/documents/inbound/bulk/route"); + return POST( + jsonRequest("/api/documents/inbound/bulk", { + ids: [], + action: "setKind", + kind: "OTHER", + }), + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + counter = 0; +}); + +describe("a guardian acting on a managed profile", () => { + async function guardianInside() { + const guardian = await makeUser("guardian"); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const session = await signIn(guardian.id); + await switchSessionTo(session.id, profile.id); + return { guardian, profile }; + } + + it("is told every section with a delegated route is manageable, and the vault is not", async () => { + const { profile } = await guardianInside(); + const active = await readActive(); + + expect(active.accountId).toBe(profile.id); + expect(active.level).toBe("manage"); + expect(active.recordKind).toBe("managed"); + expect(active.writableDomains).toEqual( + delegatedDomains("manage", null, "write"), + ); + expect(active.manageableDomains).toEqual( + delegatedDomains("manage", null, "manage"), + ); + // The sections #939 named, by name, so the list is not only self-consistent. + for (const domain of ["mind", "profile", "labs", "measurements"] as const) { + expect(active.manageableDomains, domain).toContain(domain); + } + expect(active.manageableDomains).not.toContain("documents"); + expect(active.writableDomains).not.toContain("documents"); + }); + + it("lands a mood entry and an allergy on the profile's record", async () => { + const { guardian, profile } = await guardianInside(); + + const mood = await postMood(); + expect(mood.status).toBe(201); + const moodRows = await getPrismaClient().moodEntry.findMany({ + where: { userId: profile.id }, + }); + expect(moodRows).toHaveLength(1); + expect( + await getPrismaClient().moodEntry.count({ + where: { userId: guardian.id }, + }), + ).toBe(0); + + const allergy = await postAllergy(); + expect(allergy.status).toBe(201); + const allergyRows = await getPrismaClient().allergy.findMany({ + where: { userId: profile.id }, + }); + expect(allergyRows).toHaveLength(1); + expect(allergyRows[0]?.substance).toBe("Penicillin"); + }); + + it("is refused a vault write, as the empty documents answer promised", async () => { + await guardianInside(); + const res = await postDocumentsBulk(); + expect(res.status).toBe(403); + }); +}); + +describe("a WRITE delegate on an adult's record", () => { + it("reads writable sections without manageable ones, and is refused the mood entry", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "WRITE", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const session = await signIn(delegate.id); + await switchSessionTo(session.id, owner.id); + + const active = await readActive(); + expect(active.level).toBe("write"); + expect(active.writableDomains).toEqual( + delegatedDomains("write", null, "write"), + ); + expect(active.writableDomains).toContain("measurements"); + expect(active.writableDomains).not.toContain("mind"); + expect(active.manageableDomains).toEqual([]); + + // The route agrees with the list: a mood entry is a MANAGE create. + const mood = await postMood(); + expect(mood.status).toBe(403); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: owner.id } }), + ).toBe(0); + }); +}); diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts index 3eca07f02..204e2e4de 100644 --- a/tests/integration/sharing-account-access.test.ts +++ b/tests/integration/sharing-account-access.test.ts @@ -81,6 +81,8 @@ interface AccountAccessEntry { recordKind: "shared" | "managed"; sections: string[] | null; canWrite: boolean; + writableDomains: string[]; + manageableDomains: string[]; } interface AccountAccessBlock { @@ -183,6 +185,8 @@ describe("accountAccess — what the payload publishes", () => { recordKind: "shared", sections: null, canWrite: false, + writableDomains: [], + manageableDomains: [], }, ]); expect(accountAccess.canSwitch).toBe(true); @@ -342,6 +346,8 @@ describe("accountAccess — the active record", () => { recordKind: "shared", sections: null, canWrite: false, + writableDomains: [], + manageableDomains: [], }); // The active entry is one of the listed ones, by construction. A banner // that had to join the two could render unnamed when they disagreed.