Merge pull request #942 from MBombeck/fix/managed-record-capabilities

v1.38.13 — a guardian sees the controls the routes accept
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-09 22:03:41 +02:00
commit 1d372df3a0
91 files changed
+1724 -317

No files matched your search

+25
View File
@@ -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
+57 -1
View File
@@ -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:
+12
View File
@@ -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<void> {
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
+1
View File
@@ -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,
@@ -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<void>,
): Promise<void> {
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);
});
});
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -9548,6 +9548,7 @@
"title": "문서",
"subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.",
"pageUpload": "업로드",
"ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.",
"kind": {
"DOCTOR_REPORT": "진료용 리포트",
"DISCHARGE_LETTER": "퇴원 소견서",
+1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1
View File
@@ -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.
//
+17 -19
View File
@@ -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
-4
View File
@@ -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
+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.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/*`
@@ -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$])`));
}
});
});
+34 -26
View File
@@ -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 `<GUARD> && …`? */
function guardedByCanManage(node: ts.Node): boolean {
/** Does any ancestor of `node` gate it behind `!<GUARD> && …`? */
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<string>();
@@ -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 ? <Link/> : null`, the shape `vorsorge-section`
// answer: `!inSharedRecord ? <Link/> : 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 && <Button data-slot="add" />}
{!inSharedRecord && <Button data-slot="add" />}
<Link href="/settings/layout/mood">Customise</Link>
</>
);
@@ -186,16 +194,16 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
const GATED = `
export function Page() {
const { canManage } = useRecordCapabilities();
return canManage && <Link href="/settings/layout/mood">Customise</Link>;
const { inSharedRecord } = useRecordCapabilities();
return !inSharedRecord && <Link href="/settings/layout/mood">Customise</Link>;
}
`;
expect(unguardedShortcuts(GATED, "g.tsx")).toEqual([]);
const TERNARY = `
export function Page() {
const { canManage } = useRecordCapabilities();
const wrench = canManage ? (
const { inSharedRecord } = useRecordCapabilities();
const wrench = !inSharedRecord ? (
<Link href="/settings/layout/vorsorge">Customise</Link>
) : null;
return wrench;
@@ -203,12 +211,12 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
`;
expect(unguardedShortcuts(TERNARY, "t.tsx")).toEqual([]);
// And the inverted shape is not a gate: `href && canManage` puts the
// capability in the guarded position, not the link.
// And the inverted shape is not a gate: `href && !inSharedRecord` puts
// the answer in the guarded position, not the link.
const INVERTED = `
export function Page() {
const { canManage } = useRecordCapabilities();
return <Link href={"/settings/layout/labs"}>{something && canManage}</Link>;
const { inSharedRecord } = useRecordCapabilities();
return <Link href={"/settings/layout/labs"}>{something && !inSharedRecord}</Link>;
}
`;
expect(unguardedShortcuts(INVERTED, "i.tsx")).toEqual([
@@ -0,0 +1,158 @@
/**
* v1.38.12 — the delegated-write table is the routes, frozen.
*
* `src/lib/sharing/domain-write-support.ts` says, per section, whether any
* route accepts a delegated `"write"` and whether any accepts a delegated
* `"manage"`. The account payload publishes a grant's intersection with that
* table, and every add / edit / delete control in a shared record reads the
* result. So the table is an access-shaped fact about the route tree, and a
* table that drifted from the tree would either paint a control that 403s
* (table says yes, no route) or hide one the server accepts (route exists,
* table says no) — the two failures #939 was made of.
*
* This guard re-derives the table from the route files and fails on a
* disagreement in EITHER direction. It scans every `route.ts` under
* `src/app/api` for `requireRecordAuth("write" | "manage", "<domain>")`,
* whitespace-tolerant across the line break a formatter may put after the
* opening parenthesis (the matcher lesson from
* `bearer-scope-enforcement-guard.test.ts`: a literal that demanded one line
* matched nothing and was green for it). An empty match set is a failure, not
* a clean sweep.
*
* `record` is skipped on purpose: it is not a member of `SHARE_DOMAINS`, and a
* route declaring it reads across sections rather than writing to one. A
* domain outside the vocabulary other than `record` fails the run outright,
* because the table is keyed on the vocabulary and would silently lack it.
*
* 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`
* to `"write"` → red, in the other direction;
* - point `API_ROOT` at an empty directory → the non-zero floor goes red.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
DOMAIN_WRITE_SUPPORT,
delegatedDomains,
} from "@/lib/sharing/domain-write-support";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import { walkSourceFiles } from "./helpers/source-files";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/**
* Whitespace-tolerant on both sides of the comma and after the parenthesis.
* `[a-z-]+` rather than the vocabulary, so a route naming a section this
* file has never heard of is reported instead of ignored.
*/
const DELEGATED_WRITE =
/requireRecordAuth\(\s*"(write|manage)"\s*,\s*"([a-z-]+)"/g;
interface Observed {
write: Set<string>;
manage: Set<string>;
matches: number;
unknown: string[];
}
function observe(): Observed {
const out: Observed = {
write: new Set(),
manage: new Set(),
matches: 0,
unknown: [],
};
const known = new Set<string>(SHARE_DOMAINS);
for (const rel of walkSourceFiles(API_ROOT, { floor: 100 })) {
if (!rel.endsWith("route.ts")) continue;
const source = readFileSync(join(API_ROOT, rel), "utf8");
for (const m of source.matchAll(DELEGATED_WRITE)) {
out.matches += 1;
const need = m[1] as "write" | "manage";
const domain = m[2];
if (domain === "record") continue;
if (!known.has(domain)) {
out.unknown.push(`${rel}: ${need} ${domain}`);
continue;
}
out[need].add(domain);
}
}
return out;
}
describe("the delegated-write table agrees with the routes", () => {
const observed = observe();
it("scanned a non-trivial route tree", () => {
// Below this, the matcher is broken rather than the tree small: the
// measurements, medications, labs and profile families alone carry more.
expect(observed.matches).toBeGreaterThan(50);
expect(observed.write.size).toBeGreaterThan(0);
expect(observed.manage.size).toBeGreaterThan(0);
});
it("names no section outside the vocabulary", () => {
expect(observed.unknown).toEqual([]);
});
it("agrees with the routes, section by section, in both directions", () => {
const derived = Object.fromEntries(
SHARE_DOMAINS.map((domain) => [
domain,
{
write: observed.write.has(domain),
manage: observed.manage.has(domain),
},
]),
) as Record<ShareDomain, { write: boolean; manage: boolean }>;
expect(DOMAIN_WRITE_SUPPORT).toEqual(derived);
});
it("keys the table on the whole vocabulary and nothing else", () => {
expect(Object.keys(DOMAIN_WRITE_SUPPORT).sort()).toEqual(
[...SHARE_DOMAINS].sort(),
);
});
});
describe("delegatedDomains", () => {
it("answers nothing for a read grant", () => {
expect(delegatedDomains("read", null, "write")).toEqual([]);
expect(delegatedDomains("read", null, "manage")).toEqual([]);
});
it("gives a write grant its write sections and no manage sections", () => {
const writable = delegatedDomains("write", null, "write");
for (const domain of writable) {
expect(DOMAIN_WRITE_SUPPORT[domain].write, domain).toBe(true);
}
expect(writable).not.toContain("mind");
expect(writable).not.toContain("documents");
expect(delegatedDomains("write", null, "manage")).toEqual([]);
});
it("gives a manage grant every section with a delegated route, and never the vault", () => {
const writable = delegatedDomains("manage", null, "write");
const manageable = delegatedDomains("manage", null, "manage");
expect(manageable).toContain("mind");
expect(manageable).toContain("profile");
expect(manageable).not.toContain("documents");
expect(writable).not.toContain("documents");
// manageable ⊆ writable: a section a grant can manage is one it can add to.
for (const domain of manageable) expect(writable).toContain(domain);
});
it("narrows to the grant's sections, keeping consent order", () => {
expect(
delegatedDomains("write", ["labs", "measurements"], "write"),
).toEqual(["measurements", "labs"]);
expect(delegatedDomains("write", ["documents"], "write")).toEqual([]);
expect(delegatedDomains("write", [], "write")).toEqual([]);
});
});
@@ -162,12 +162,14 @@ describe("what the canonical readers make of the same payload", () => {
expect(capabilities.recordKind).toBe(fixture.recordKind);
expect(capabilities.inSharedRecord).toBe(true);
// Resolved server-side and rendered, never re-derived: `canAdd` follows
// `canWrite`, and MANAGE does not become `canManage` on the client — a
// delegate's management reach is the server's answer, one request at a
// time.
// `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.
expect(capabilities.canWrite).toBe(fixture.canWrite);
expect(capabilities.canAdd).toBe(fixture.canWrite);
expect(capabilities.canManage).toBe(false);
const manageable = active?.manageableDomains ?? [];
expect(capabilities.canManage).toBe(manageable.length > 0);
expect(manageable.length > 0).toBe(fixture.level === "manage");
},
);
+4 -4
View File
@@ -44,7 +44,7 @@ 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, canManage, inSharedRecord } = useRecordCapabilities();
const { canAdd, inSharedRecord } = useRecordCapabilities();
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -69,7 +69,7 @@ export default function LabsPage() {
isLoading,
labsEnabled: enabled,
mounted,
canManage,
ownRecord: !inSharedRecord,
}),
);
@@ -111,7 +111,7 @@ export default function LabsPage() {
point, left of the primary Add and linking to the Labs settings
page (view, sort order, biomarker CRUD + reorder). Mirrors the
medication page header's wrench glyph + slot + 44px tap floor. */}
{canManage && (
{!inSharedRecord && (
<Button
asChild
variant="ghost"
@@ -133,7 +133,7 @@ export default function LabsPage() {
mode (vision, or local OCR opted-in for text-only providers), so
the surface stays simple for everyone else. When scanning is
unavailable, Add opens the manual form directly. */}
{ocrCapability.data?.available && canManage ? (
{ocrCapability.data?.available && !inSharedRecord ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
@@ -59,6 +59,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
const OWN_RECORD: AccountAccess = {
+2 -2
View File
@@ -169,7 +169,7 @@ 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, canManage, inSharedRecord } = useRecordCapabilities();
const { canAdd, inSharedRecord } = useRecordCapabilities();
// 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).
@@ -433,7 +433,7 @@ export default function MedicationsPageClient() {
Same glyph, slot (left of the add button) and responsive
44-px mobile tap floor as the dashboard and insights
headers. */}
{canManage && (
{!inSharedRecord && (
<Button
asChild
variant="ghost"
+8 -6
View File
@@ -21,7 +21,9 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPageClient() {
const { user, isAuthenticated, isLoading } = useAuth();
const { canManage, inSharedRecord } = useRecordCapabilities();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
const mounted = useMounted();
const router = useRouter();
const queryClient = useQueryClient();
@@ -89,7 +91,7 @@ export default function MoodPageClient() {
the "not part of what was shared" panel: `/settings` is not a
shared-record destination and the shell answers before the
page does. */}
{canManage && (
{!inSharedRecord && (
<Button
asChild
variant="ghost"
@@ -105,13 +107,13 @@ export default function MoodPageClient() {
</Link>
</Button>
)}
{/* 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 && (
<Button
onClick={() => setDialogOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="mood-add-entry"
>
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
@@ -16,14 +16,15 @@
* Mutation checks, run:
* - `useRecordCapabilities` returning `canAdd: true` unconditionally → the
* read-only legs for the intake row and the card menu go red.
* - `DeleteButton` dropping its `canManage` bail → "no row delete inside
* somebody else's record" goes 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.
* - `TodayHero` passing `onDismiss` / `onAction` unconditionally → the rail
* legs go red, printing the dismiss control and the check-in buttons.
* - `VorsorgeDashboardCard` dropping its `canManage` bail → the mark-done
* leg goes red.
* - `EpisodeDocumentsCard` dropping its `canManage` bail → the link +
* upload leg goes red.
* - `VorsorgeDashboardCard` dropping its `canManageDomain` bail → the
* mark-done leg goes red.
* - `EpisodeDocumentsCard` dropping its `canManageDomain` bail → the link +
* upload leg goes red, on the MANAGE arm.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -31,6 +32,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import type { AccountAccess } from "@/lib/sharing/account-access-view";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const OWNER = {
accountId: "acct-owner",
@@ -42,6 +44,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
const OWN_RECORD: AccountAccess = {
@@ -56,7 +60,30 @@ 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,
writableDomains: delegatedDomains("write", null, "write"),
manageableDomains: [],
},
canSwitch: true,
};
/**
* v1.38.12 — a MANAGE grant, published as the server publishes it: the
* sections with a delegated route, and never the vault.
*/
const MANAGING: AccountAccess = {
accounts: [OWNER],
active: {
...OWNER,
access: "write",
level: "manage",
canWrite: true,
writableDomains: delegatedDomains("manage", null, "write"),
manageableDomains: delegatedDomains("manage", null, "manage"),
},
canSwitch: true,
};
@@ -141,25 +168,60 @@ function render(
describe("row delete", () => {
const node = (
<DeleteButton onConfirm={() => {}} title="Delete?" description="Gone." />
<DeleteButton
domain="labs"
onConfirm={() => {}}
title="Delete?"
description="Gone."
/>
);
it("renders in the caller's own record", () => {
expect(render(OWN_RECORD, node)).toContain("<button");
});
it("is absent inside somebody else's record, at both levels", () => {
it("is absent inside somebody else's record below MANAGE", () => {
// Not disabled. A greyed bin still claims the row is the delegate's to
// remove, and it is not — at either grant level, including for a row the
// delegate entered themselves.
expect(render(READ_ONLY, node)).toBe("");
expect(render(WRITABLE, node)).toBe("");
});
it("follows the section for a guardian: labs yes, the vault and owner-only rows no", () => {
// The v1.37.0 hold-back answered "no" for every section; #939 is what
// that cost. The answer is per section now, and the server's.
expect(render(MANAGING, node)).toContain("<button");
expect(
render(
MANAGING,
<DeleteButton
domain="documents"
onConfirm={() => {}}
title="Delete?"
description="Gone."
/>,
),
).toBe("");
// `null`: the row's delete route resolves the caller (custom metrics).
expect(
render(
MANAGING,
<DeleteButton
domain={null}
onConfirm={() => {}}
title="Delete?"
description="Gone."
/>,
),
).toBe("");
});
});
describe("bulk selection bar", () => {
const node = (
<SelectionActionBar
domain="mind"
count={3}
onClear={() => {}}
onConfirmDelete={() => {}}
@@ -175,9 +237,15 @@ describe("bulk selection bar", () => {
);
});
it("is absent inside somebody else's record", () => {
it("is absent inside somebody else's record below MANAGE", () => {
expect(render(WRITABLE, node)).toBe("");
});
it("renders for a guardian in a section whose bulk route answers", () => {
expect(render(MANAGING, node)).toContain(
'data-slot="selection-action-bar"',
);
});
});
describe("marking a dose", () => {
@@ -273,12 +341,13 @@ describe("the Today rail's mutating affordances", () => {
expect(html).toContain("Keep it");
});
it("withholds both inside somebody else's record, at either level", () => {
it("withholds both inside somebody else's record, at every level", () => {
// Dismissing an observation and answering a coach check-in are neither of
// them an admitted create, and both write through routes that resolve the
// CALLER — so a delegate tapping either would file it against their own
// record if the route allowed it, and gets a 403 because it does not.
for (const access of [READ_ONLY, WRITABLE]) {
// record if the route allowed it, and gets a 403 because it does not. A
// MANAGE grant changes nothing here: no section reaches a caller route.
for (const access of [READ_ONLY, WRITABLE, MANAGING]) {
const html = render(access, node);
expect(html).not.toContain('data-slot="priority-card-dismiss"');
expect(html).not.toContain("Keep it");
@@ -326,7 +395,7 @@ describe("the Vorsorge summary's mark-done", () => {
expect(html).toContain("Done");
});
it("is absent inside somebody else's record, at either level", () => {
it("is absent inside somebody else's record below MANAGE", () => {
// `/checkups` already withheld this exact action; the dashboard summary
// offering it was the inconsistency that showed nobody had asked.
for (const access of [READ_ONLY, WRITABLE]) {
@@ -335,6 +404,12 @@ describe("the Vorsorge summary's mark-done", () => {
expect(html).not.toContain("Done");
}
});
it("is offered to a guardian: the satisfy route answers at MANAGE under measurements", () => {
const html = render(MANAGING, node, seed);
expect(html).toContain("Dental check-up");
expect(html).toContain("Done");
});
});
/* -------------------------------------------------------------------------- */
@@ -350,12 +425,13 @@ describe("linking a document to an illness episode", () => {
expect(html).toContain("Upload");
});
it("offers neither inside somebody else's record, at either level", () => {
it("offers neither inside somebody else's record, at every level", () => {
// Both write through `POST /api/documents/inbound/bulk`, which the vault
// gates on the same answer throughout — the episode side of the same link
// had no gate at all, and the upload deep-linked to a page whose own
// upload control is already withheld.
for (const access of [READ_ONLY, WRITABLE]) {
// upload control is already withheld. The vault has no delegated write
// route, so a MANAGE grant is refused the same way.
for (const access of [READ_ONLY, WRITABLE, MANAGING]) {
const html = render(access, node);
expect(html).not.toContain(">Link<");
expect(html).not.toContain("Upload");
@@ -374,24 +450,45 @@ 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 };
// 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",
),
};
it("offers everything in the caller's own record", () => {
expect(
visibleCaptureKinds({ canAdd: true, canManage: true }, [...ALL]),
).toEqual(["measurement", "medication", "mood"]);
expect(visibleCaptureKinds(OWNER_CAPS, [...ALL])).toEqual([
"measurement",
"medication",
"mood",
]);
});
it("offers a delegate only what the delegation admits", () => {
// A reading and a dose are admitted verbs. A mood entry is not, and the
// server refuses it under a switch.
expect(
visibleCaptureKinds({ canAdd: true, canManage: false }, [...ALL]),
).toEqual(["measurement", "medication"]);
// A reading and a dose are admitted verbs. A mood entry is a MANAGE
// create, and the server refuses it under a WRITE grant.
expect(visibleCaptureKinds(WRITER_CAPS, [...ALL])).toEqual([
"measurement",
"medication",
]);
});
it("offers a guardian the mood entry too, because its route answers", () => {
expect(visibleCaptureKinds(GUARDIAN_CAPS, [...ALL])).toEqual([
"measurement",
"medication",
"mood",
]);
});
it("offers a read-only delegate nothing", () => {
expect(
visibleCaptureKinds({ canAdd: false, canManage: false }, [...ALL]),
).toEqual([]);
expect(visibleCaptureKinds(READER_CAPS, [...ALL])).toEqual([]);
});
});
@@ -480,7 +577,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, canManage: false };
const DELEGATE = { canAdd: true, canManageDomain: () => false };
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
@@ -488,15 +585,23 @@ 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");
});
it("withdraws every quick-entry sheet from a read-only delegate", () => {
const READER = { canAdd: false, canManage: false };
const READER = { canAdd: false, canManageDomain: () => false };
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, canManage: true };
const OWNER_CAPS = { canAdd: true, canManageDomain: () => true };
for (const sheet of ["measurement", "mood", "medicationIntake"] as const) {
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
}
@@ -13,6 +13,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: true,
writableDomains: [],
manageableDomains: [],
};
const OWN_RECORD: AccountAccess = {
@@ -100,8 +100,8 @@ export function ChartOverlayControls({
// reasoning that keeps the mood tag layout off the delegable list. So a
// delegate reads the owner's chart in its stored shape and is not offered a
// dial that would silently fail.
const { canManage } = useRecordCapabilities();
if (!canManage) return null;
const { inSharedRecord } = useRecordCapabilities();
if (inSharedRecord) return null;
return (
<DropdownMenu>
@@ -196,6 +196,7 @@ export function CustomMetricDetail({
<>
{/* Controls, left → right: Delete · Edit · Show-all-values · Add. */}
<DeleteButton
domain={null}
onConfirm={() => deleteMetric.mutate()}
title={t("customMetrics.deleteConfirmTitle")}
description={t("customMetrics.deleteConfirmDescription")}
@@ -261,6 +261,7 @@ export function CustomMetricHistoryList({
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
domain={null}
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("customMetrics.entry.deleteConfirmTitle")}
description={t(
@@ -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<HTMLDivElement | null>(null);
@@ -57,7 +59,7 @@ export function CustomMetricList() {
router.push(`/custom-metrics/${saved.id}`);
}
if (!canManage) return null;
if (inSharedRecord) return null;
return (
<section
@@ -12,7 +12,11 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canManageDomain: () => true,
}),
}));
const errorQuery = {
+7 -6
View File
@@ -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 ? (
<Button
onClick={() => openSheet(today)}
className="min-h-11 sm:min-h-9"
@@ -275,7 +276,7 @@ export function CycleView() {
) : null}
{/* Log CTA — first period when no cycle exists yet, next period
when the count paused on an overdue cycle. */}
{canManage &&
{canManageCycle &&
!loading &&
!calendarError &&
verdict?.dayOfCycle == null ? (
@@ -333,7 +334,7 @@ export function CycleView() {
>
{t("cycle.tabs.insights")}
</TabsTrigger>
{canManage && (
{canManageCycle && (
<TabsTrigger
value="settings"
className="text-xs sm:flex-1 sm:text-sm"
@@ -385,7 +386,7 @@ export function CycleView() {
null)
: null
}
onSelectDay={canManage ? openSheet : undefined}
onSelectDay={canManageCycle ? openSheet : undefined}
onVisibleMonthChange={setVisibleMonth}
/>
</>
@@ -453,7 +454,7 @@ export function CycleView() {
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin motion-reduce:animate-none" />
</div>
) : profileQuery.data && canManage ? (
) : profileQuery.data && canManageCycle ? (
<CycleSettings
key={profileQuery.data.updatedAt}
profile={profileQuery.data}
+5 -4
View File
@@ -114,12 +114,13 @@ export function TodayHero({
const dismissItem = usePriorityItemDismiss();
// The rail's two mutating affordances — dismissing an observation and
// answering a coach check-in — are neither of them an admitted create, so
// they ask `canManage` and are absent at both grant levels. Both write
// they ask for the caller's own record and are absent under every grant. Both write
// through routes that resolve the CALLER (`POST /api/daily/digest/dismiss`,
// `PATCH /api/coach/plans/[id]`), which under a switch means a 403 rather
// than a stray row; the control still goes, because an affordance the
// server is on record refusing should not be on the page.
const { canManage } = useRecordCapabilities();
const { inSharedRecord } = useRecordCapabilities();
const ownRecord = !inSharedRecord;
// The coach check-in card's keep / let-go intents carry the plan id after the
// ":" (a closed two-intent allowlist); adjust is an href handled by the card
@@ -237,9 +238,9 @@ export function TodayHero({
<PriorityCard
key={`${item.kind}-${i}`}
item={item}
onAction={canManage ? handleAction : undefined}
onAction={ownRecord ? handleAction : undefined}
onDismiss={
canManage ? (itemKey) => dismissItem.mutate(itemKey) : undefined
ownRecord ? (itemKey) => dismissItem.mutate(itemKey) : undefined
}
actionsPending={
item.kind === "coach_checkin" &&
@@ -40,7 +40,9 @@ export function DashboardHeader({
// 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, canManage } = useRecordCapabilities();
const { canAdd, canManageDomain, inSharedRecord } = useRecordCapabilities();
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
// The pre-hero greeting derivation, kept hydration-safe: `user` comes
// from the auth query, which can resolve before this boundary
@@ -98,7 +100,7 @@ export function DashboardHeader({
a `min-h-11 min-w-11` mobile floor so it meets the 44 px
touch-target contract the add button also honours, shrinking
back to the 40 px icon footprint on sm+. */}
{canManage && (
{!inSharedRecord && (
<Button
asChild
variant="ghost"
@@ -154,7 +156,7 @@ export function DashboardHeader({
<Activity className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMeasurement")}
</DropdownMenuItem>
{canManage && (
{canAddMood && (
<DropdownMenuItem onClick={() => onQuickEntry("mood")}>
<Waves className="mr-2 h-4 w-4" aria-hidden="true" />
{t("dashboard.quickAddMood")}
@@ -29,6 +29,7 @@ import {
useRecordCapabilities,
type RecordCapabilities,
} from "@/hooks/use-record-capabilities";
import type { ShareDomain } from "@/lib/sharing/scope";
export type QuickEntryDialog =
"measurement" | "mood" | "medicationIntake" | null;
@@ -43,6 +44,15 @@ export type QuickEntryDialog =
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
new Set(["measurement", "medicationIntake"]);
/** v1.38.12 — the section each sheet writes to; see the capture picker. */
const QUICK_ENTRY_DOMAIN: Readonly<
Record<NonNullable<QuickEntryDialog>, ShareDomain>
> = {
measurement: "measurements",
medicationIntake: "medications",
mood: "mind",
};
/**
* The sheet that may actually be on screen.
*
@@ -59,9 +69,10 @@ const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
*/
export function admittedQuickEntry(
open: QuickEntryDialog,
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
): QuickEntryDialog {
if (open === null || caps.canManage) return open;
if (open === null) return open;
if (caps.canManageDomain(QUICK_ENTRY_DOMAIN[open])) return open;
return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null;
}
+15 -7
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
* Shared single-row delete button + confirm dialog for the data-
@@ -30,11 +31,14 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
* v1.36.x — this is the one place a row delete is spelled across the app
* (measurements, lab results, biomarkers, allergies, family history, custom
* metrics, medication inventory), so the delegation rule is enforced here
* rather than at twelve call sites: deleting is the owner's alone, at every
* grant level, so inside somebody else's record the trigger is absent. Not
* disabled — a greyed bin still claims the row is the delegate's to remove.
* A caller that ever needs a delete a delegate MAY perform would have to say
* so explicitly, and today there is none.
* rather than at twelve call sites. Not disabled when withheld — a greyed bin
* still claims the row is the delegate's to remove.
*
* v1.38.12 — deleting is a MANAGE verb, and whether a MANAGE grant reaches it
* depends on the section the row belongs to (`domain-write-support.ts`), so
* the caller names the section and this component asks `canManageDomain` for
* it. `null` says the row's delete route resolves the caller rather than the
* record (custom metrics), which no grant reaches at any level.
*/
export function DeleteButton({
onConfirm,
@@ -45,7 +49,10 @@ export function DeleteButton({
triggerTitle,
className = "",
iconClassName = "h-3.5 w-3.5",
domain,
}: {
/** The section the delete route answers under, or `null` for owner-only. */
domain: ShareDomain | null;
onConfirm: () => void;
title: string;
description: string;
@@ -57,8 +64,9 @@ export function DeleteButton({
iconClassName?: string;
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
if (!canManage) return null;
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
if (!allowed) return null;
return (
<AlertDialog>
<AlertDialogTrigger asChild>
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/alert-dialog";
import { useTranslations } from "@/lib/i18n/context";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import type { ShareDomain } from "@/lib/sharing/scope";
/**
* Selection action bar for the data-management lists (measurements +
@@ -31,10 +32,13 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
* The confirm copy is passed in because the title/body differ per surface
* (measurements vs mood) and the count is interpolated by the caller.
*
* v1.36.x — bulk delete is the owner's alone at every grant level, so the bar
* is absent inside somebody else's record. The row checkboxes that feed it are
* dropped at their lists for the same reason; this guard is the backstop, so a
* list that forgets cannot leave a delete button floating over the page.
* v1.36.x — bulk delete is a MANAGE verb, so the bar is absent inside somebody
* else's record below that level. The row checkboxes that feed it are dropped
* at their lists for the same reason; this guard is the backstop, so a list
* that forgets cannot leave a delete button floating over the page.
*
* v1.38.12 — the caller names the section its bulk route answers under, and
* the bar asks `canManageDomain` for it; `null` marks an owner-only route.
*/
export function SelectionActionBar({
count,
@@ -43,7 +47,10 @@ export function SelectionActionBar({
isDeleting,
confirmTitle,
confirmBody,
domain,
}: {
/** The section the bulk-delete route answers under, or `null` for owner-only. */
domain: ShareDomain | null;
count: number;
onClear: () => void;
onConfirmDelete: () => void;
@@ -52,9 +59,10 @@ export function SelectionActionBar({
confirmBody: string;
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const allowed = domain === null ? !inSharedRecord : canManageDomain(domain);
if (count <= 0 || !canManage) return null;
if (count <= 0 || !allowed) return null;
return (
<div
@@ -373,7 +373,8 @@ export function DocumentDetailSheet({
(fact) => fact.factType === "OBSERVATION" && fact.status === "APPROVED",
).length ?? 0;
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageDocuments = canManageDomain("documents");
const labsModuleEnabled = user?.modules?.labs !== false;
const episodes = useIllnessEpisodes(true);
@@ -387,14 +388,14 @@ export function DocumentDetailSheet({
// document reads behind this sheet are delegable; the AI verbs above them are
// not, and both of these routes stay on the caller's own authentication. A
// delegate would fire two queries that must 403 in order to compute an
// affordance every branch below already withholds on `canManage`.
// affordance every branch below already withholds on `canManageDocuments`.
const capability = useDocumentAiCapability(
canManage && open && documentId !== null,
canManageDocuments && open && documentId !== null,
);
// When auto-read is ON, reading happens automatically on upload — the manual
// per-document AI action row is redundant and collapses away.
const autoRead = useDocumentsAutoAiRead(
canManage && open && documentId !== null,
canManageDocuments && open && documentId !== null,
);
const suggest = useSuggestDetails();
const summary = useDocumentSummary();
@@ -709,7 +710,7 @@ export function DocumentDetailSheet({
owner's alone. Inside somebody else's record the sheet reads
the document and offers the download, and the leading slot
simply has nothing in it. */}
{canManage ? (
{canManageDocuments ? (
<Button
variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
@@ -751,7 +752,7 @@ export function DocumentDetailSheet({
</span>
</Button>
) : null}
{canManage && (
{canManageDocuments && (
<Button
variant="outline"
onClick={() => setShareOpen(true)}
@@ -829,7 +830,7 @@ export function DocumentDetailSheet({
// The stored summary is read; generating one writes to the
// owner's record, so a delegate reads what is there and has no
// generate control.
canGenerate={canManage}
canGenerate={canManageDocuments}
summaryState={doc.summaryState}
generatedAtLabel={
doc.summaryGeneratedAt
@@ -842,7 +843,7 @@ export function DocumentDetailSheet({
onGenerate={generateStoredSummary}
/>
{canManage && (
{canManageDocuments && (
<DocumentAiSection
aiEnabled={aiEnabled}
autoReadEnabled={autoReadEnabled}
@@ -906,7 +907,7 @@ export function DocumentDetailSheet({
maxLength={200}
placeholder={t("documents.detail.titlePlaceholder")}
/>
) : !canManage ? (
) : !canManageDocuments ? (
<p
id="document-title-input"
className={cn(
@@ -952,7 +953,7 @@ export function DocumentDetailSheet({
without accepting a change is the honest rendering. */}
<Select
value={doc.kind}
disabled={!canManage}
disabled={!canManageDocuments}
onValueChange={(value) =>
patch.mutate({ kind: value as InboundDocumentKindValue })
}
@@ -975,7 +976,7 @@ export function DocumentDetailSheet({
</Label>
<DateField
id="document-date-field"
disabled={!canManage}
disabled={!canManageDocuments}
value={doc.documentDate ?? ""}
onChange={(value) =>
patch.mutate({
@@ -1006,7 +1007,7 @@ export function DocumentDetailSheet({
{t("documents.detail.noConditions")}
</span>
) : null}
{canManage && (episodes.data?.length ?? 0) > 0 ? (
{canManageDocuments && (episodes.data?.length ?? 0) > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
@@ -1058,7 +1059,7 @@ export function DocumentDetailSheet({
alreadyLinked={
doc.encounterLinks.length > 0 && pickedEncounterId === null
}
disabled={!canManage}
disabled={!canManageDocuments}
value={pickedEncounterId}
onChange={setSuggestedEncounter}
/>
@@ -1069,7 +1070,7 @@ export function DocumentDetailSheet({
<VaccinationDocumentSuggestion
anchor={doc.reportDate ?? doc.documentDate}
documentId={doc.id}
disabled={!canManage}
disabled={!canManageDocuments}
/>
) : null}
@@ -1111,7 +1112,7 @@ export function DocumentDetailSheet({
values" over its stored text — never a re-upload. */}
<DocumentFactsSection
doc={doc}
canManage={canManage}
canManage={canManageDocuments}
aiEnabled={aiEnabled}
labsModuleEnabled={labsModuleEnabled}
/>
+24 -10
View File
@@ -241,8 +241,10 @@ export function DocumentsView() {
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
// about it are none of them delegated verbs. Inside somebody else's record
// the vault reads and nothing more: no upload path, no selection, no bulk
// bar, no corpus backfill.
const { canManage, inSharedRecord, sections } = useRecordCapabilities();
// bar, no corpus backfill. `canManageDomain("documents")` says so from the
// server's table, and answers false under every grant today.
const { canManageDomain, inSharedRecord, sections } = useRecordCapabilities();
const canManageDocuments = canManageDomain("documents");
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -424,7 +426,9 @@ export function DocumentsView() {
},
[episodeIdFilter, t],
);
const { dropActive } = usePageFileDrop(canManage ? enqueueFiles : undefined);
const { dropActive } = usePageFileDrop(
canManageDocuments ? enqueueFiles : undefined,
);
const episodes = useIllnessEpisodes(
true,
@@ -439,7 +443,7 @@ export function DocumentsView() {
const contentIndex = usage.data?.contentIndex;
const canIndexContent = contentIndex?.enabled ?? false;
const showIndexAll =
canManage &&
canManageDocuments &&
canIndexContent &&
contentIndex !== undefined &&
contentIndex.totalCount > 0 &&
@@ -872,7 +876,7 @@ export function DocumentsView() {
title={t("documents.title")}
description={t("documents.subtitle")}
actions={
canManage ? (
canManageDocuments ? (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
@@ -884,13 +888,23 @@ export function DocumentsView() {
}
/>
{canManage && (
{canManageDocuments && (
<UploadZone
usage={usage.data}
inputRef={uploadInputRef}
onFiles={enqueueFiles}
/>
)}
{inSharedRecord && !canManageDocuments && (
// Where the upload zone would be: the one empty space a person acting
// for somebody else would otherwise search a control in.
<p
className="text-muted-foreground text-sm"
data-slot="documents-owner-only"
>
{t("documents.ownerOnlyUpload")}
</p>
)}
<DocumentFilterBar
searchValue={searchDraft}
@@ -932,7 +946,7 @@ export function DocumentsView() {
description={t("documents.empty.description")}
ctaSize="lg"
action={
canManage ? (
canManageDocuments ? (
<Button
className="min-h-11 sm:min-h-9"
onClick={() => uploadInputRef.current?.click()}
@@ -963,9 +977,9 @@ export function DocumentsView() {
isFetchingNextPage={list.isFetchingNextPage}
onLoadMore={() => void list.fetchNextPage()}
selectedIds={selectedIds}
onToggleSelected={canManage ? toggleSelected : undefined}
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
onOpen={openDetail}
onDelete={canManage ? (id) => deleteBulk([id]) : undefined}
onDelete={canManageDocuments ? (id) => deleteBulk([id]) : undefined}
highlightId={upload.highlightId}
onPrefetch={prefetchDetail}
/>
@@ -979,7 +993,7 @@ export function DocumentsView() {
contentIndexEnabled={usage.data?.contentIndex.enabled}
/>
{canManage && selectedIds.size > 0 ? (
{canManageDocuments && selectedIds.size > 0 ? (
<DocumentBulkBar
selectedCount={selectedIds.size}
episodes={(episodes.data ?? []).map((e) => ({
@@ -49,10 +49,11 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
const { user } = useAuth();
// Linking a document to an episode writes through
// `POST /api/documents/inbound/bulk`, the same endpoint the vault gates on
// `canManage` throughout. The episode side of the same link had no gate at
// `canManageDocuments` throughout. The episode side of the same link had no gate at
// all, so the vault refused what this card offered. Reading the linked
// documents stays: the list rows and "show all" are pure navigation.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageDocuments = canManageDomain("documents");
const [pickerOpen, setPickerOpen] = useState(false);
const moduleEnabled = user?.modules?.inboundDocuments === true;
@@ -146,7 +147,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
)}
<div className="flex flex-wrap items-center gap-1.5">
{canManage ? (
{canManageDocuments ? (
<>
<Button
variant="outline"
@@ -158,7 +159,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
</Button>
{/* The upload affordance is a deep link into the vault with
this episode pre-filtered. The vault's own upload control
is `canManage`-gated, so an ungated link here would send a
is `canManageDocuments`-gated, so an ungated link here would send a
delegate to a page with nothing to press. */}
<Button asChild variant="outline" size="sm">
<Link href={vaultHref}>
@@ -184,7 +185,7 @@ export function EpisodeDocumentsCard({ episodeId }: { episodeId: string }) {
</CardContent>
</Card>
{canManage ? (
{canManageDocuments ? (
<DocumentLinkPicker
episodeId={episodeId}
open={pickerOpen}
@@ -24,7 +24,12 @@ vi.mock("@/hooks/use-auth", () => ({
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canWrite: true }),
useRecordCapabilities: () => ({
canManage: true,
canWrite: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
+4 -2
View File
@@ -30,7 +30,9 @@ import { VisitCard } from "./visit-card";
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
// `POST /api/encounters` answers at WRITE under the profile section.
const canAddVisit = canWriteDomain("profile");
const list = useEncounters(enabled);
// `null` closed, `"new"` create, a row edits it.
@@ -45,7 +47,7 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const past = list.data?.past ?? [];
const empty = upcoming.length === 0 && past.length === 0;
const addButton = canManage ? (
const addButton = canAddVisit ? (
<Button
type="button"
className="min-h-11"
@@ -36,7 +36,9 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
const format = useFormatters();
const { user } = useAuth();
const { canManage } = useRecordCapabilities();
const { canWriteDomain } = useRecordCapabilities();
// The pre-linked visit is a `POST /api/encounters`, which answers at WRITE.
const canAddVisit = canWriteDomain("profile");
const [sheetOpen, setSheetOpen] = useState(false);
const [session, setSession] = useState(0);
@@ -114,7 +116,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
</ul>
)}
{canManage ? (
{canAddVisit ? (
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
@@ -135,7 +137,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
</CardContent>
</Card>
{canManage ? (
{canAddVisit ? (
<EncounterSheet
key={session}
open={sheetOpen}
@@ -94,7 +94,8 @@ export function IllnessDayTimeline({
}) {
const { t } = useTranslations();
// v1.36.x — logging a day is not a delegated verb.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageIllness = canManageDomain("illness");
const { data, isLoading, isError } = useIllnessDayLogList(episodeId, "desc");
const dayLogs = data?.dayLogs ?? [];
@@ -119,7 +120,7 @@ export function IllnessDayTimeline({
<p className="text-muted-foreground text-sm">
{t("illness.timeline.empty")}
</p>
{canManage && (
{canManageIllness && (
<Button
variant="outline"
className="min-h-11 sm:min-h-9"
@@ -41,7 +41,8 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
const { t } = useTranslations();
// v1.36.x — logging a day, editing and resolving an episode are the
// owner's; a delegate reads the episode.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageIllness = canManageDomain("illness");
const fmt = useFormatters();
const {
data: episode,
@@ -98,7 +99,7 @@ export function IllnessEpisodeDetail({ episodeId }: { episodeId: string }) {
) : undefined
}
actions={
canManage ? (
canManageIllness ? (
<>
<Button
onClick={() => setLogOpen(true)}
+9 -7
View File
@@ -74,7 +74,8 @@ function EpisodeCard({
view,
}: EpisodeCardProps) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageIllness = canManageDomain("illness");
const fmt = useFormatters();
const active = episode.resolvedAt === null;
const isChronic = episode.lifecycle === "CHRONIC_ONGOING";
@@ -109,7 +110,7 @@ function EpisodeCard({
</span>
</div>
</div>
{canManage && (
{canManageIllness && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="outline"
@@ -145,7 +146,7 @@ function EpisodeCard({
<span className="block truncate">{episode.label}</span>
</Link>
</CardTitle>
{canManage && (
{canManageIllness && (
<CardAction>
<EpisodeMenu
episode={episode}
@@ -211,7 +212,7 @@ function EpisodeCard({
flow, promoted from a busy inline cluster. The card navigates to
the detail surface on its title link; this is the one explicit
button. */}
{canManage && (
{canManageIllness && (
<div className="mt-auto pt-0">
<Button
className="min-h-11 w-full sm:min-h-9"
@@ -319,7 +320,7 @@ 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, canManage } = useRecordCapabilities();
const { canAdd, inSharedRecord } = useRecordCapabilities();
const {
data: episodes,
isLoading,
@@ -391,8 +392,9 @@ export function IllnessView() {
actions={
<>
{/* v1.18.6 (MOD-01) — wrench left of the primary Add, linking to the
Illness settings page (view + reorder). */}
{canManage && (
Illness settings page (view + reorder). `/settings` is the
caller's, not the record's, so no grant reaches it. */}
{!inSharedRecord && (
<Button
asChild
variant="ghost"
@@ -9,7 +9,7 @@ describe("shouldProbeOcrCapability", () => {
isLoading: false,
labsEnabled: true,
mounted: true,
canManage: true,
ownRecord: true,
};
expect(shouldProbeOcrCapability(ownerControl)).toBe(true);
@@ -17,7 +17,7 @@ describe("shouldProbeOcrCapability", () => {
shouldProbeOcrCapability({ ...ownerControl, isAuthenticated: false }),
).toBe(false);
expect(
shouldProbeOcrCapability({ ...ownerControl, canManage: false }),
shouldProbeOcrCapability({ ...ownerControl, ownRecord: false }),
).toBe(false);
expect(
shouldProbeOcrCapability({ ...ownerControl, labsEnabled: false }),
@@ -151,6 +151,7 @@ export function BiomarkerManager() {
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
domain="labs"
onConfirm={() => deleteMutation.mutate(marker.id)}
title={t("labs.biomarker.deleteConfirmTitle")}
description={t("labs.biomarker.deleteConfirmDescription")}
+4 -2
View File
@@ -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). */}
<DeleteButton
domain="labs"
onConfirm={() => 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 && (
<Button
variant="ghost"
size="icon"
+4 -2
View File
@@ -77,7 +77,8 @@ function parseDecimal(raw: string): number | null {
*/
export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageLabs = canManageDomain("labs");
const queryClient = useQueryClient();
// Client-side column sort over the in-memory reading feed.
@@ -379,7 +380,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
{canManage && (
{canManageLabs && (
<Button
size="icon"
variant="ghost"
@@ -393,6 +394,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
</Button>
)}
<DeleteButton
domain="labs"
onConfirm={() => deleteMutation.mutate(r.id)}
title={t("labs.deleteConfirmTitle")}
description={t("labs.deleteConfirmDescription")}
+4 -3
View File
@@ -47,15 +47,16 @@ export function shouldProbeOcrCapability({
isLoading,
labsEnabled,
mounted,
canManage,
ownRecord,
}: {
isAuthenticated: boolean;
isLoading: boolean;
labsEnabled: boolean;
mounted: boolean;
canManage: boolean;
/** In the caller's own record; the probe and the commit resolve the caller, never a grant. */
ownRecord: boolean;
}): boolean {
return isAuthenticated && !isLoading && labsEnabled && mounted && canManage;
return isAuthenticated && !isLoading && labsEnabled && mounted && ownRecord;
}
/** Capability probe — refetched when the scan dialog opens. */
@@ -127,6 +127,7 @@ vi.mock("@/components/gamification/achievement-unlock-notifier", () => ({
}));
import { AuthShell } from "../auth-shell";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
const OWNER_READ: AccountAccessEntry = {
accountId: "acct-owner",
@@ -138,12 +139,16 @@ const OWNER_READ: AccountAccessEntry = {
sections: null,
recordKind: "shared" as const,
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
const OWNER_WRITE: AccountAccessEntry = {
...OWNER_READ,
access: "write",
level: "write",
canWrite: true,
writableDomains: [],
manageableDomains: [],
};
const MANAGED_GUARDIAN: AccountAccessEntry = {
accountId: "managed-record",
@@ -155,6 +160,8 @@ const MANAGED_GUARDIAN: AccountAccessEntry = {
sections: null,
recordKind: "managed" as const,
canWrite: true,
writableDomains: delegatedDomains("manage", null, "write"),
manageableDomains: delegatedDomains("manage", null, "manage"),
};
function render(
@@ -20,6 +20,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
@@ -13,6 +13,8 @@ vi.mock("@/hooks/use-record-capabilities", () => ({
canWrite: false,
canAdd: true,
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
@@ -62,6 +62,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
function render(access: AccountAccess, node: React.ReactNode): string {
+11 -8
View File
@@ -29,7 +29,11 @@ import {
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { CapturePicker } from "@/components/layout/capture-picker";
import {
CAPTURE_KIND_ORDER,
CapturePicker,
visibleCaptureKinds,
} from "@/components/layout/capture-picker";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
@@ -105,13 +109,12 @@ export function BottomNav() {
// v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from
// the same resolved payload, so the two bars cannot disagree about what a
// delegate is offered.
const {
inSharedRecord: sharedRecord,
sections,
canAdd,
canManage,
} = useRecordCapabilities();
const canCapture = canAdd || canManage;
const capabilities = useRecordCapabilities();
const { inSharedRecord: sharedRecord, sections } = capabilities;
// The capture button opens the picker, so it shows exactly when the picker
// would offer at least one kind — the same per-section rule, asked once.
const canCapture =
visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER).length > 0;
// v1.17.1 (F-1) — the More hub is the model-computed hub: every visible
// feature destination that isn't a primary slot, plus the shared utility
+16 -3
View File
@@ -19,6 +19,7 @@ import {
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
import type { ShareDomain } from "@/lib/sharing/scope";
import {
useRecordCapabilities,
type RecordCapabilities,
@@ -63,8 +64,20 @@ const DELEGABLE_CAPTURE_KINDS: ReadonlySet<CaptureKind> = new Set([
"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.
*/
const CAPTURE_KIND_DOMAIN: Readonly<Record<CaptureKind, ShareDomain>> = {
measurement: "measurements",
medication: "medications",
mood: "mind",
};
/** The order the chooser lists them in. */
const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
export const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
"measurement",
"medication",
"mood",
@@ -104,11 +117,11 @@ export function admittedCaptureKind(
* cannot tap the button that opens it.
*/
export function visibleCaptureKinds(
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
caps: Pick<RecordCapabilities, "canAdd" | "canManageDomain">,
kinds: ReadonlyArray<CaptureKind>,
): CaptureKind[] {
return kinds.filter((kind) => {
if (caps.canManage) return true;
if (caps.canManageDomain(CAPTURE_KIND_DOMAIN[kind])) return true;
return caps.canAdd && DELEGABLE_CAPTURE_KINDS.has(kind);
});
}
@@ -27,7 +27,11 @@ vi.mock("@/hooks/use-encounters", () => ({
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true, canAdd: true }),
useRecordCapabilities: () => ({
canManage: true,
canAdd: true,
canManageDomain: () => true,
}),
}));
vi.mock("next/navigation", () => ({
@@ -79,7 +79,8 @@ export function VorsorgeDashboardCard() {
// (`POST /api/measurement-reminders/[id]/satisfy` resolves the caller), and
// the dashboard summary offering what the dedicated page withholds was the
// tell that this row had never been asked.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageMeasurements = canManageDomain("measurements");
const { data: reminders, isError, refetch } = useMeasurementReminders();
// A query's `isLoading` is not a hydration-safe branch: TanStack reports the
@@ -197,7 +198,7 @@ export function VorsorgeDashboardCard() {
})}
</Badge>
</div>
{canManage ? (
{canManageMeasurements ? (
<Button
type="button"
size="default"
@@ -223,7 +223,8 @@ export function VorsorgeSection({
variant?: "settings" | "page";
}) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const canManageMeasurements = canManageDomain("measurements");
const router = useRouter();
const {
data: reminders,
@@ -382,7 +383,7 @@ export function VorsorgeSection({
// and no grant level admits creating, editing or satisfying one. Inside
// somebody else's record the schedule stays readable and every control that
// would change it is absent.
const addButton = canManage ? (
const addButton = canManageMeasurements ? (
<Button
type="button"
className="min-h-11 shrink-0 sm:min-h-9"
@@ -396,8 +397,9 @@ export function VorsorgeSection({
// v1.18.6 (MOD-01) — the wrench links to the Vorsorge settings page (view,
// reorder, per-reminder enable toggles), left of the Add button — the
// canonical medication-page pattern. The old kebab "Anpassen" sheet is
// gone (MOD-07); its toggles moved to the settings page.
const wrenchButton = canManage ? (
// gone (MOD-07); its toggles moved to the settings page. `/settings` is the
// caller's, not the record's, so no grant reaches it.
const wrenchButton = !inSharedRecord ? (
<Button
asChild
variant="ghost"
@@ -716,7 +718,7 @@ export function VorsorgeSection({
title={t("measurementReminders.empty.title")}
description={t("measurementReminders.empty.description")}
action={
canManage ? (
canManageMeasurements ? (
<Button type="button" onClick={openCreate}>
{t("common.add")}
</Button>
@@ -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 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -1009,7 +1012,7 @@ function VorsorgeCard({
// `resolve.ts` states the same boundary server-side: an appointment reminder
// is free-text by construction.
const fileVisitButton =
canManage && !isLinked ? (
canManageMeasurements && !isLinked ? (
<Button
type="button"
variant="outline"
@@ -1028,7 +1031,7 @@ function VorsorgeCard({
// on the card. Outline against the filled primary: the same pairing, and
// the same rule as everything else in this bar — one constant look, never
// tinted by how overdue the checkup is.
const postponeButton = canManage ? (
const postponeButton = canManageMeasurements ? (
<Button
type="button"
variant="outline"
@@ -1042,7 +1045,7 @@ function VorsorgeCard({
</Button>
) : null;
const primaryButton = canManage ? (
const primaryButton = canManageMeasurements ? (
<Button
type="button"
className="min-h-11 flex-1"
@@ -1272,7 +1275,7 @@ function VorsorgeCard({
ungated copy, and which branch a person sees is a per-browser
preference that survives the switch — so the identical action
was offered or withheld depending on a view toggle. */}
{canManage ? (
{canManageMeasurements ? (
<Button
type="button"
size="sm"
@@ -1292,7 +1295,7 @@ function VorsorgeCard({
{/* Icon-only in the dense list, labelled in the cards branch —
the same action either way, never one branch offering what
the other withholds. */}
{canManage ? (
{canManageMeasurements ? (
<Button
type="button"
variant="outline"
@@ -1309,7 +1312,7 @@ function VorsorgeCard({
) : null}
{/* Free-text reminders only: a typed / screening reminder has no
practice visit behind it. */}
{canManage && !isLinked ? (
{canManageMeasurements && !isLinked ? (
<Button
type="button"
variant="outline"
@@ -1304,6 +1304,7 @@ export function MeasurementList({
</Button>
)}
<DeleteButton
domain="measurements"
onConfirm={() =>
deleteMutation.mutate(m.id)
}
@@ -1504,6 +1505,7 @@ export function MeasurementList({
</Button>
)}
<DeleteButton
domain="measurements"
iconClassName="h-4 w-4"
onConfirm={() => deleteMutation.mutate(m.id)}
title={t("measurements.deleteConfirmTitle")}
@@ -1535,6 +1537,7 @@ export function MeasurementList({
{/* v1.15.13 — page-scoped multi-select action bar. */}
<SelectionActionBar
domain="measurements"
count={selectedOnPage}
onClear={clearSelection}
onConfirmDelete={onConfirmBulkDelete}
@@ -393,7 +393,9 @@ function RetargetControl({
// owner chose — not an admitted create, and `PUT .../efficacy/target`
// resolves the caller, so it is refused at both grant levels. The Wirkung
// tab itself stays readable; only the dial goes.
const { canManage } = useRecordCapabilities();
// `PUT /api/medications/[id]/efficacy/target` resolves the caller, not the
// record: no grant reaches it, so the editor belongs to the owner alone.
const { inSharedRecord } = useRecordCapabilities();
const [value, setValue] = useState<string>("");
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 (
<div
@@ -185,12 +185,23 @@ type TabSlug = (typeof TAB_SLUGS)[number];
* none of it at any level.
*/
const OWNER_ONLY_TABS: ReadonlySet<TabSlug> = 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<TabSlug> = 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. */}
<MedicationWizardDialog
open={editOpen && canManage}
open={editOpen && canManageMedications}
onOpenChange={setEditOpen}
mode="edit"
initial={payload}
@@ -598,7 +598,8 @@ export function LedgerRowItem({
onUnpin: (eventId: string) => 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 && (
<Button
variant="outline"
size="sm"
@@ -754,7 +755,7 @@ export function LedgerRowItem({
{t("medications.detail.verlauf.markSkipped")}
</Button>
)}
{canManage && (
{canManageMedications && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -63,7 +63,8 @@ export function IntakeHistoryEditable({
}: IntakeHistoryEditableProps) {
const { t } = useTranslations();
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -135,7 +136,7 @@ export function IntakeHistoryEditable({
return (
<>
<div className="space-y-3" data-slot="intake-history-editable-body">
{canManage && selected.size > 0 && (
{canManageMedications && selected.size > 0 && (
<div
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
role="region"
@@ -592,8 +592,9 @@ function IntakeRowActions({
t: ReturnType<typeof useTranslations>["t"];
}) {
// v1.36.x — editing or deleting a recorded dose is the owner's.
const { canManage } = useRecordCapabilities();
if (!canManage) return null;
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
if (!canManageMedications) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -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, canManage } = useRecordCapabilities();
const { canAdd, canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
const showSideEffect = onLogSideEffect != null && canAdd;
if (!canManage && !showSideEffect) return null;
if (!canManageMedications && !showSideEffect) return null;
return (
<DropdownMenu>
@@ -87,7 +88,7 @@ export function MedicationCardMenu({
className="w-56"
data-slot="medication-card-menu"
>
{canManage && (
{canManageMedications && (
<>
<DropdownMenuItem onClick={onEdit}>
<Pencil className="mr-2 h-4 w-4" />
@@ -101,7 +102,7 @@ export function MedicationCardMenu({
)}
{showSideEffect && onLogSideEffect && (
<>
{canManage && <DropdownMenuSeparator />}
{canManageMedications && <DropdownMenuSeparator />}
<DropdownMenuItem onClick={onLogSideEffect}>
<Stethoscope className="mr-2 h-4 w-4" />
{t("medications.glp1LogSideEffect")}
@@ -36,7 +36,8 @@ export function IntakeHistoryPreview({
}: IntakeHistoryPreviewProps) {
const { t } = useTranslations();
// v1.36.x — importing dose history is not a delegated verb.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
return (
<>
@@ -45,7 +46,7 @@ export function IntakeHistoryPreview({
title={t("medications.detail.intake.title")}
dataSlot="medication-detail-intake-history-section"
headerExtras={
canManage ? (
canManageMedications ? (
<Button
variant="outline"
size="sm"
@@ -53,6 +53,7 @@ import { Loader2, PackageOpen, Plus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DeleteButton } from "@/components/data-list/delete-button";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsGroup } from "@/components/medications/settings-group";
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
@@ -112,6 +113,7 @@ export function InventorySection({
deliveryForm?: string;
}) {
const { t, locale } = useTranslations();
const { inSharedRecord } = useRecordCapabilities();
const dateFormatPref = useDateFormatPreference();
const queryClient = useQueryClient();
const [addOpen, setAddOpen] = useState(false);
@@ -301,19 +303,24 @@ export function InventorySection({
{/* v1.16.11 — cross-link to the low-stock alert setting: the
threshold lives in Settings → Notifications, but the question
"when will it warn me?" comes up here, where the stock lives. */}
<div className="py-2">
<Link
href="/settings/notifications#low-stock"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="inventory-low-stock-link"
>
{lowStockDays !== null
? t("medications.detail.bestand.lowStockLinkOn", {
days: lowStockDays,
})
: t("medications.detail.bestand.lowStockLinkOff")}
</Link>
</div>
{/* `/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 && (
<div className="py-2">
<Link
href="/settings/notifications#low-stock"
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
data-slot="inventory-low-stock-link"
>
{lowStockDays !== null
? t("medications.detail.bestand.lowStockLinkOn", {
days: lowStockDays,
})
: t("medications.detail.bestand.lowStockLinkOff")}
</Link>
</div>
)}
</SettingsGroup>
{/* v1.16.11 — packaging economics surfaced where the stock lives:
@@ -443,6 +450,7 @@ export function InventorySection({
{t("medications.detail.bestand.adjustButton")}
</Button>
<DeleteButton
domain="medications"
onConfirm={() => void deleteItem(item)}
title={t("medications.detail.bestand.deleteTitle")}
description={t("medications.detail.bestand.deleteDescription")}
@@ -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) {
</p>
)}
</div>
{canManage && (
{canManageMedications && (
<Button
variant="ghost"
size="icon"
@@ -75,7 +75,8 @@ export function InstrumentCard({
// v1.36.x — a screener is answered by the person it is about. No grant
// level admits it, so inside somebody else's record the card reads as
// history and carries no way to start one.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageMind = canManageDomain("mind");
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -178,7 +179,7 @@ export function InstrumentCard({
{/* Bottom-pinned single primary action — the med-/Vorsorge card slot.
Nothing sits below it now: the attribution moved to the header's
info control, so the Start button stands alone with clean space. */}
{canManage && (
{canManageMind && (
<div className="mt-auto pt-0">
<Button type="button" className="min-h-11 w-full" onClick={onStart}>
{t("mentalHealth.start")}
@@ -37,7 +37,8 @@ export function InstrumentDetail({
const { t } = useTranslations();
const { date: formatDate } = useFormatters();
// v1.36.x — see the card: starting a screener is not a delegated verb.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageMind = canManageDomain("mind");
// Issue #490 — day-boundary zone for the relative "today / yesterday"
// bucket must match the zone `formatDate` renders in (mirror → Berlin).
const displayTz = useDisplayTimezone();
@@ -76,7 +77,7 @@ export function InstrumentDetail({
{/* The Start action stays reachable here too — the detail is a calm
reading surface, not a dead end. */}
{canManage && (
{canManageMind && (
<Button
type="button"
className="min-h-11 w-full"
+11 -7
View File
@@ -177,7 +177,8 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
const { isAuthenticated } = useAuth();
// v1.36.x — mood is not a delegated verb. Inside somebody else's record the
// list stays readable and every control that would change it is absent.
const { canManage } = useRecordCapabilities();
const { canManageDomain } = useRecordCapabilities();
const canManageMind = canManageDomain("mind");
const queryClient = useQueryClient();
// URL-owned filter state (the documents-vault pattern): mood, source and
// date range parse from `?mood&source&from&to` and every change writes back
@@ -721,7 +722,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
>
{t("mood.emptyResetFilter")}
</Button>
) : onAddFirst && canManage ? (
) : onAddFirst && canManageMind ? (
<Button size="sm" onClick={onAddFirst}>
<Plus className="h-4 w-4" />
{t("mood.emptyAddFirst")}
@@ -747,7 +748,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
<TableRow>
<TableHead className="w-10 pl-4">
{/* v1.15.13 — select-all-on-page header checkbox. */}
{canManage && (
{canManageMind && (
<Checkbox
checked={
selectAll === "all"
@@ -800,7 +801,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
data-state={isSelected ? "selected" : undefined}
>
<TableCell className="pl-4">
{canManage && (
{canManageMind && (
<Checkbox
checked={isSelected}
onCheckedChange={() => onToggleRow(entry.id)}
@@ -844,7 +845,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</TableCell>
<TableCell className="pr-4 text-right">
<div className="flex items-center justify-end gap-1">
{canManage && (
{canManageMind && (
<Button
variant="ghost"
size="icon"
@@ -856,6 +857,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</Button>
)}
<DeleteButton
domain="mind"
onConfirm={() => deleteMutation.mutate(entry.id)}
title={t("mood.deleteConfirmTitle")}
description={t("mood.deleteConfirmDescription")}
@@ -898,7 +900,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
stays the single control and owns the 44px hit area
via an `after` hit-slop (clicks on a pseudo-element
hit-test against its host button). */}
{canManage && (
{canManageMind && (
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
@@ -945,7 +947,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
and delete actions meet WCAG 2.5.5 on touch
devices. The desktop table keeps its denser
h-8 w-8 since pointer targets allow it. */}
{canManage && (
{canManageMind && (
<Button
variant="ghost"
size="icon"
@@ -957,6 +959,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
</Button>
)}
<DeleteButton
domain="mind"
onConfirm={() => deleteMutation.mutate(entry.id)}
iconClassName="h-4 w-4"
title={t("mood.deleteConfirmTitle")}
@@ -972,6 +975,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{/* v1.15.13 — page-scoped multi-select action bar. */}
<SelectionActionBar
domain="mind"
count={selectedOnPage}
onClear={clearSelection}
onConfirmDelete={onConfirmBulkDelete}
@@ -21,7 +21,11 @@ vi.mock("@/hooks/use-practitioners", () => ({
}));
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => ({ canManage: true }),
useRecordCapabilities: () => ({
canManage: true,
canWriteDomain: () => true,
canManageDomain: () => true,
}),
}));
vi.mock("@/hooks/use-debounced-value", () => ({
@@ -44,7 +44,10 @@ import { PractitionerSheet } from "./practitioner-sheet";
export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canManage } = useRecordCapabilities();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
// `POST /api/practitioners` answers at WRITE; editing and deleting at MANAGE.
const canAddPractitioner = canWriteDomain("profile");
const canManageProfile = canManageDomain("profile");
const [term, setTerm] = useState("");
const debounced = useDebouncedValue(term, 250);
const list = usePractitioners(debounced, enabled);
@@ -87,7 +90,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
}, [rows]);
const showGroupHeadings = groups.some(([specialty]) => specialty !== null);
const addButton = canManage ? (
const addButton = canAddPractitioner ? (
<Button
type="button"
className="min-h-11"
@@ -186,7 +189,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
</p>
) : null}
</div>
{canManage ? (
{canManageProfile ? (
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
@@ -106,6 +106,7 @@ export function AllergyManager() {
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
domain="profile"
onConfirm={() => deleteMutation.mutate(row.id)}
title={t("records.allergies.deleteConfirmTitle")}
description={t("records.allergies.deleteConfirmDescription")}
@@ -91,6 +91,7 @@ export function FamilyHistoryManager() {
<Pencil className="h-4 w-4" />
</Button>
<DeleteButton
domain="profile"
onConfirm={() => deleteMutation.mutate(row.id)}
title={t("records.family.deleteConfirmTitle")}
description={t("records.family.deleteConfirmDescription")}
@@ -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> = {}): AccountAccessEntry {
recordKind: "managed",
sections: null,
canWrite: true,
writableDomains: delegatedDomains("manage", null, "write"),
manageableDomains: delegatedDomains("manage", null, "manage"),
...partial,
};
}
@@ -37,6 +37,8 @@ const canonicalEntry = {
recordKind: "shared",
sections: ["labs"],
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
beforeEach(() => {
@@ -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),
});
});
});
+75 -31
View File
@@ -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<ShareDomain>(active.writableDomains);
const manageable = new Set<ShareDomain>(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,
@@ -35,6 +35,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: true,
writableDomains: [],
manageableDomains: [],
};
const mockAccessRef: { value: AccountAccess } = {
@@ -12,6 +12,8 @@ const sharedEntry = {
recordKind: "shared",
sections: ["labs"],
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
function accessBlock(overrides: Record<string, unknown> = {}) {
@@ -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 };
@@ -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" }),
+66
View File
@@ -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 &&
+18
View File
@@ -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`. */
+9 -1
View File
@@ -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"),
};
});
+101
View File
@@ -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", "<domain>")` 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<ShareDomain, DomainWriteSupport>
> = {
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<AccountAccessLevel, number> = {
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<ShareDomain>(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);
});
}
@@ -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<ActiveEntry> {
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);
});
});
@@ -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.