From 54f0030ead642eece32a8b4b12182c403c91193b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 19:42:26 +0200 Subject: [PATCH 1/8] feat(sharing): freeze which sections accept delegated writes Add the one table of which record sections have a route declaring requireRecordAuth("write") or ("manage"), derived from the route files, and a structural guard that re-derives it from src/app/api on every run and fails on a disagreement in either direction. The matcher tolerates the line break a formatter puts after the opening parenthesis and asserts a non-zero match count. delegatedDomains() turns a grant's level and scope into the sections it can add to and the sections it can change, keeping write within manage. --- .../domain-write-support-guard.test.ts | 158 ++++++++++++++++++ src/lib/sharing/domain-write-support.ts | 101 +++++++++++ 2 files changed, 259 insertions(+) create mode 100644 src/__tests__/domain-write-support-guard.test.ts create mode 100644 src/lib/sharing/domain-write-support.ts diff --git a/src/__tests__/domain-write-support-guard.test.ts b/src/__tests__/domain-write-support-guard.test.ts new file mode 100644 index 000000000..76ed8c0cb --- /dev/null +++ b/src/__tests__/domain-write-support-guard.test.ts @@ -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", "")`, + * 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; + manage: Set; + matches: number; + unknown: string[]; +} + +function observe(): Observed { + const out: Observed = { + write: new Set(), + manage: new Set(), + matches: 0, + unknown: [], + }; + const known = new Set(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; + 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([]); + }); +}); diff --git a/src/lib/sharing/domain-write-support.ts b/src/lib/sharing/domain-write-support.ts new file mode 100644 index 000000000..ad182b434 --- /dev/null +++ b/src/lib/sharing/domain-write-support.ts @@ -0,0 +1,101 @@ +/** + * Which sections of a record accept a delegated write, and at which level. + * + * `requireRecordAuth(need, domain)` is declared per route, so the server has + * always known which sections a WRITE or a MANAGE grant can reach. The client + * did not: `resolveRecordCapabilities` answered `canManage: false` for every + * shared record from v1.37.0 on, a hold-back written for the release where the + * level was published before the routes behind it existed. The routes exist + * now, and the hold-back kept hiding the add, edit and delete controls on a + * managed profile whose every write the server accepts (#939). + * + * Flipping the boolean would have overshot in the other direction: the vault + * has no delegated write route at all, so a manage grant that painted the + * upload control there would paint a control that 403s. The answer is per + * section and it is the server's: this file is the one table, the account + * payload publishes the intersection of a grant with it, and the client reads + * the lists. + * + * The values are a property of the routes and nothing else, which is why they + * are frozen rather than trusted: `domain-write-support-guard.test.ts` scans + * every `route.ts` under `src/app/api` for `requireRecordAuth("write" | + * "manage", "")` and fails when this table and the routes disagree in + * either direction. Editing a value here without a route to justify it, or + * adding a delegated write route without editing here, is a red test. + * + * `record` is deliberately absent. It is not a section a grant can be narrowed + * to (`scope.ts`), and a route declaring it reads across sections; nothing a + * per-section control asks is answered by it. + */ +import type { AccountAccessLevel } from "@/lib/sharing/account-access-view"; +import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope"; + +/** Whether any route in the section declares the need. */ +export interface DomainWriteSupport { + /** At least one route declares `requireRecordAuth("write", domain)`. */ + readonly write: boolean; + /** At least one route declares `requireRecordAuth("manage", domain)`. */ + readonly manage: boolean; +} + +/** + * The table. Derived from the route files on 2026-09-09 and frozen by the + * structural guard; the reasons are in the routes, not here. + */ +export const DOMAIN_WRITE_SUPPORT: Readonly< + Record +> = { + measurements: { write: true, manage: true }, + medications: { write: true, manage: true }, + labs: { write: true, manage: true }, + profile: { write: true, manage: true }, + illness: { write: true, manage: true }, + // A mood entry and a screener are created at MANAGE, not at WRITE. + mind: { write: false, manage: true }, + // Every cycle write is a MANAGE route. + cycle: { write: false, manage: true }, + // The vault is read-only under every grant; upload, filing, sharing and the + // AI verbs stay with the owner. + documents: { write: false, manage: false }, +}; + +/** The two needs a delegated write can declare. */ +export type DelegatedWriteNeed = "write" | "manage"; + +const LEVEL_RANK: Record = { + read: 0, + write: 1, + manage: 2, +}; + +/** + * The sections a grant can write to, or manage, given its level and scope. + * + * `sections === null` is the entire record. A MANAGE grant always carries it + * (`grants.ts` refuses a scope on a MANAGE invitation), a WRITE grant may. + * + * For `need === "write"` the answer is "is there a non-read route in this + * section the grant satisfies": a WRITE grant reaches the section's `"write"` + * routes, a MANAGE grant reaches those and its `"manage"` routes as well. + * That is the `write ⊂ manage` ordering `grantAllows` enforces, stated as + * lists — a section a manage grant can manage is a section it can write to, + * so `manageable ⊆ writable` holds for every grant. + * + * For `need === "manage"` only a MANAGE grant answers, and only where a route + * declares the need. The result keeps the consent screen's order. + */ +export function delegatedDomains( + level: AccountAccessLevel, + sections: readonly ShareDomain[] | null, + need: DelegatedWriteNeed, +): ShareDomain[] { + if (LEVEL_RANK[level] < LEVEL_RANK[need]) return []; + const opened = + sections === null ? new Set(SHARE_DOMAINS) : new Set(sections); + return SHARE_DOMAINS.filter((domain) => { + if (!opened.has(domain)) return false; + const support = DOMAIN_WRITE_SUPPORT[domain]; + if (need === "manage") return support.manage; + return support.write || (level === "manage" && support.manage); + }); +} From b5c392636f0cc22f735fde5cae3202ec5469b05e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 19:42:26 +0200 Subject: [PATCH 2/8] feat(sharing): publish writable and manageable domains on the account payload Every accountAccess entry on GET /api/auth/me carries writableDomains and manageableDomains: the sections the grant opens, intersected with the sections whose routes accept a delegated write at that level. A READ grant gets two empty lists, a WRITE grant gets writable sections only, a MANAGE grant gets both, and the vault appears in neither. The client parser checks the structural invariants (no repeats, within the grant's sections, manageable within writable, nothing below the level) and requires the two published views of the active record to agree on the lists. Regenerate the OpenAPI document and extend the payload consumer guard with the active-record readers. --- docs/api/openapi.yaml | 56 ++++++++++++ .../account-payload-consumer-guard.test.ts | 20 +++++ .../take-all-due-affordance.test.tsx | 2 + .../__tests__/chart-overlay-controls.test.tsx | 2 + .../auth-shell-shared-record-doors.test.tsx | 7 ++ .../__tests__/shared-record-chrome.test.tsx | 2 + .../managed-profile-affordances.test.tsx | 3 + .../use-auth-account-access-state.test.ts | 2 + .../__tests__/coach-launch-context.test.tsx | 2 + .../__tests__/account-access-schema.test.ts | 90 +++++++++++++++++++ .../sharing/__tests__/account-access.test.ts | 41 ++++++++- src/lib/sharing/account-access-schema.ts | 66 ++++++++++++++ src/lib/sharing/account-access-view.ts | 18 ++++ src/lib/sharing/account-access.ts | 10 ++- 14 files changed, 319 insertions(+), 2 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 218517c87..65d354fcb 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -65685,6 +65685,32 @@ components: - type: "null" canWrite: type: boolean + writableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + manageableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents required: - accountId - username @@ -65695,6 +65721,8 @@ components: - recordKind - sections - canWrite + - writableDomains + - manageableDomains additionalProperties: false active: anyOf: @@ -65747,6 +65775,32 @@ components: - type: "null" canWrite: type: boolean + writableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents + manageableDomains: + type: array + items: + type: string + enum: + - measurements + - medications + - labs + - profile + - illness + - mind + - cycle + - documents required: - accountId - username @@ -65757,6 +65811,8 @@ components: - recordKind - sections - canWrite + - writableDomains + - manageableDomains additionalProperties: false - type: "null" recordKind: diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 58d85f597..dffe39795 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -106,6 +106,19 @@ const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [ "recordKind", ] as const; +/** + * v1.38.12 — fields that answer "what may I do in the record I am inside", + * which only the active-record consumer has a use for. The switcher and the + * navigation presentation say what was granted (`level`, `sections`); the + * controls read these two lists. Requiring an entry-site reader for them + * would be an artificial read written to satisfy this guard, so they are + * checked against the active-record consumer only. + */ +const ACTIVE_RECORD_ONLY_FIELDS = [ + "writableDomains", + "manageableDomains", +] as const; + /** * Fields with no client reader, each with the reason it is nonetheless * correct for the payload to carry it. An entry here is a claim; write one @@ -273,5 +286,12 @@ describe("account payload consumer guard", () => { ); expect(readerCount).toBeGreaterThan(0); + + for (const field of ACTIVE_RECORD_ONLY_FIELDS) { + expect( + activeSource, + `no active-record reader for account access field \`${field}\``, + ).toMatch(new RegExp(`active\\.${field}(?![\\w$])`)); + } }); }); diff --git a/src/app/medications/__tests__/take-all-due-affordance.test.tsx b/src/app/medications/__tests__/take-all-due-affordance.test.tsx index 0a9407b01..822cff34a 100644 --- a/src/app/medications/__tests__/take-all-due-affordance.test.tsx +++ b/src/app/medications/__tests__/take-all-due-affordance.test.tsx @@ -59,6 +59,8 @@ const OWNER = { sections: null, recordKind: "shared" as const, canWrite: false, + writableDomains: [], + manageableDomains: [], }; const OWN_RECORD: AccountAccess = { diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index 62d6d7af9..000c158f5 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -13,6 +13,8 @@ const OWNER = { sections: null, recordKind: "shared" as const, canWrite: true, + writableDomains: [], + manageableDomains: [], }; const OWN_RECORD: AccountAccess = { diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index 7757c8e32..0b84ddf2e 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -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( diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index cfed8bb74..2e79ef83b 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -62,6 +62,8 @@ const OWNER = { sections: null, recordKind: "shared" as const, canWrite: false, + writableDomains: [], + manageableDomains: [], }; function render(access: AccountAccess, node: React.ReactNode): string { diff --git a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx index 347184c99..ab4b97b6f 100644 --- a/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx +++ b/src/components/settings/access/__tests__/managed-profile-affordances.test.tsx @@ -37,6 +37,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { I18nProvider } from "@/lib/i18n/context"; import { ApiError } from "@/lib/api/api-fetch"; import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import type { GrantList } from "@/lib/queries/use-account-grants"; import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles"; @@ -219,6 +220,8 @@ function entry(partial: Partial = {}): AccountAccessEntry { recordKind: "managed", sections: null, canWrite: true, + writableDomains: delegatedDomains("manage", null, "write"), + manageableDomains: delegatedDomains("manage", null, "manage"), ...partial, }; } diff --git a/src/hooks/__tests__/use-auth-account-access-state.test.ts b/src/hooks/__tests__/use-auth-account-access-state.test.ts index cabb5bad1..cc048368b 100644 --- a/src/hooks/__tests__/use-auth-account-access-state.test.ts +++ b/src/hooks/__tests__/use-auth-account-access-state.test.ts @@ -37,6 +37,8 @@ const canonicalEntry = { recordKind: "shared", sections: ["labs"], canWrite: false, + writableDomains: [], + manageableDomains: [], }; beforeEach(() => { diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx index 9e94ce003..482a4038f 100644 --- a/src/lib/insights/__tests__/coach-launch-context.test.tsx +++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx @@ -35,6 +35,8 @@ const OWNER = { sections: null, recordKind: "shared" as const, canWrite: true, + writableDomains: [], + manageableDomains: [], }; const mockAccessRef: { value: AccountAccess } = { diff --git a/src/lib/sharing/__tests__/account-access-schema.test.ts b/src/lib/sharing/__tests__/account-access-schema.test.ts index a702767ad..16170236b 100644 --- a/src/lib/sharing/__tests__/account-access-schema.test.ts +++ b/src/lib/sharing/__tests__/account-access-schema.test.ts @@ -12,6 +12,8 @@ const sharedEntry = { recordKind: "shared", sections: ["labs"], canWrite: false, + writableDomains: [], + manageableDomains: [], }; function accessBlock(overrides: Record = {}) { @@ -45,6 +47,8 @@ describe("parseAccountAccess", () => { level: "manage", sections: null, canWrite: true, + writableDomains: ["measurements", "mind"], + manageableDomains: ["mind"], }; const parsed = parseAccountAccess( @@ -56,9 +60,28 @@ describe("parseAccountAccess", () => { access: "write", sections: null, canWrite: true, + writableDomains: ["measurements", "mind"], + manageableDomains: ["mind"], }); }); + it("keeps the delegated write lists when they are consistent with the grant", () => { + const writer = { + ...sharedEntry, + access: "write", + level: "write", + sections: ["labs", "measurements"], + canWrite: true, + writableDomains: ["measurements", "labs"], + manageableDomains: [], + }; + const parsed = parseAccountAccess( + accessBlock({ accounts: [writer], active: writer }), + ); + expect(parsed?.active?.writableDomains).toEqual(["measurements", "labs"]); + expect(parsed?.active?.manageableDomains).toEqual([]); + }); + it.each([ [ "legacy access and resolved level", @@ -79,6 +102,28 @@ describe("parseAccountAccess", () => { // field. A parity matcher blind to it would let the banner and the // switcher name the same owner differently. ["full name", { fullName: "Different name" }, {}], + // v1.38.12 — the two lists are read by every control, so the two views + // must agree on them like on every other field. + [ + "writable domains", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["labs"], + }, + { + accounts: [ + { + ...sharedEntry, + access: "write", + level: "write", + canWrite: true, + writableDomains: [], + }, + ], + }, + ], ])( "fails closed when active disagrees with its canonical entry on %s", (_field, activeOverrides, blockOverrides) => { @@ -105,6 +150,51 @@ describe("parseAccountAccess", () => { "manage with read legacy field", { access: "read", level: "manage", sections: null, canWrite: true }, ], + // v1.38.12 — structural invariants of the two lists. Which sections + // qualify is the server's table and is not re-decided here; what IS + // checked is that the lists cannot claim more than the grant. + ["read grant with a writable domain", { writableDomains: ["labs"] }], + [ + "write grant with a manageable domain", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["labs"], + manageableDomains: ["labs"], + }, + ], + [ + "manageable domain that is not writable", + { + access: "write", + level: "manage", + sections: null, + canWrite: true, + writableDomains: ["labs"], + manageableDomains: ["mind"], + }, + ], + [ + "writable domain outside the grant's sections", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["measurements"], + }, + ], + [ + "duplicated writable domain", + { + access: "write", + level: "write", + canWrite: true, + writableDomains: ["labs", "labs"], + }, + ], + ["unknown writable domain", { writableDomains: ["other"] }], + ["missing writable domains", { writableDomains: undefined }], ])("fails closed for %s entries", (_name, entryOverrides) => { const entry = { ...sharedEntry, ...entryOverrides }; diff --git a/src/lib/sharing/__tests__/account-access.test.ts b/src/lib/sharing/__tests__/account-access.test.ts index 3fc48b80a..4ed387f98 100644 --- a/src/lib/sharing/__tests__/account-access.test.ts +++ b/src/lib/sharing/__tests__/account-access.test.ts @@ -10,20 +10,23 @@ vi.mock("@/lib/db", () => ({ import { prisma } from "@/lib/db"; import { resolveAccountAccess } from "../account-access"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import { decodeLegacyWholeRecordAccess } from "../account-access-view"; import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads"; function activeGrant({ access, managedProfileAt = null, + scopeJson = null, }: { access: "READ" | "WRITE" | "MANAGE"; managedProfileAt?: Date | null; + scopeJson?: string[] | null; }) { return { id: `grant-${access.toLowerCase()}`, access, - scopeJson: null, + scopeJson, acceptedAt: new Date("2026-08-01T00:00:00.000Z"), revokedAt: null, expiresAt: null, @@ -69,11 +72,47 @@ describe("resolveAccountAccess", () => { level: "manage", sections: null, recordKind: "managed", + // v1.38.12 — the routes' answer for a whole-record MANAGE grant: every + // section with a delegated write route, and never the vault. + writableDomains: delegatedDomains("manage", null, "write"), + manageableDomains: delegatedDomains("manage", null, "manage"), }), ); + expect(access.active?.manageableDomains).toContain("mind"); + expect(access.active?.manageableDomains).not.toContain("documents"); expect(access.recordKind).toBe("managed"); }); + it("publishes a WRITE grant's writable sections within its scope and no manageable ones", async () => { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "WRITE", scopeJson: ["mind", "labs"] }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: "record-owner" }, + }); + + // `mind` is opened by the scope but has no WRITE route, so it is absent; + // `labs` has one. Consent order, not scope order. + expect(access.active?.writableDomains).toEqual(["labs"]); + expect(access.active?.manageableDomains).toEqual([]); + }); + + it("publishes two empty lists for a READ grant", async () => { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "READ" }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: "record-owner" }, + }); + + expect(access.active?.writableDomains).toEqual([]); + expect(access.active?.manageableDomains).toEqual([]); + }); + it("resolves an ordinary shared record on the server", async () => { vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ activeGrant({ access: "READ" }), diff --git a/src/lib/sharing/account-access-schema.ts b/src/lib/sharing/account-access-schema.ts index 779afc84b..76e5cb060 100644 --- a/src/lib/sharing/account-access-schema.ts +++ b/src/lib/sharing/account-access-schema.ts @@ -6,6 +6,20 @@ import { SHARE_DOMAINS } from "./scope"; const accountAccessLevelSchema = z.enum(["read", "write", "manage"]); const accountRecordKindSchema = z.enum(["shared", "managed"]); const shareSectionsSchema = z.array(z.enum(SHARE_DOMAINS)).nullable(); +const shareDomainListSchema = z.array(z.enum(SHARE_DOMAINS)); + +function hasRepeat(domains: readonly string[]): boolean { + return new Set(domains).size !== domains.length; +} + +function isSubset( + inner: readonly string[], + outer: readonly string[] | null, +): boolean { + if (outer === null) return true; + const set = new Set(outer); + return inner.every((domain) => set.has(domain)); +} /** The browser's resolved account-access payload, shared with OpenAPI. */ export const accountAccessEntrySchema = z @@ -22,6 +36,11 @@ export const accountAccessEntrySchema = z recordKind: accountRecordKindSchema, sections: shareSectionsSchema, canWrite: z.boolean(), + // v1.38.12 — the sections the grant can add to / change, resolved + // server-side. Structural invariants only are checked here; which + // sections qualify is the server's table and is not re-decided. + writableDomains: shareDomainListSchema, + manageableDomains: shareDomainListSchema, }) .superRefine((entry, ctx) => { const uniqueSections = new Set(entry.sections ?? []); @@ -56,6 +75,48 @@ export const accountAccessEntrySchema = z path: ["access"], }); } + + if ( + hasRepeat(entry.writableDomains) || + hasRepeat(entry.manageableDomains) + ) { + ctx.addIssue({ + code: "custom", + message: "Delegated write domains must not repeat a domain", + path: ["writableDomains"], + }); + } + if ( + !isSubset(entry.writableDomains, entry.sections) || + !isSubset(entry.manageableDomains, entry.sections) + ) { + ctx.addIssue({ + code: "custom", + message: "Delegated write domains must lie within the grant's sections", + path: ["writableDomains"], + }); + } + if (!isSubset(entry.manageableDomains, entry.writableDomains)) { + ctx.addIssue({ + code: "custom", + message: "Manageable domains must be writable domains", + path: ["manageableDomains"], + }); + } + if (entry.level === "read" && entry.writableDomains.length > 0) { + ctx.addIssue({ + code: "custom", + message: "A read grant has no writable domains", + path: ["writableDomains"], + }); + } + if (!isManage && entry.manageableDomains.length > 0) { + ctx.addIssue({ + code: "custom", + message: "Only a manage grant has manageable domains", + path: ["manageableDomains"], + }); + } }); export const accountAccessBlockSchema = z.object({ @@ -80,7 +141,12 @@ function matchesCanonicalAccountAccessEntry( (section, index) => section === canonicalSections[index], ); + const sameList = (a: readonly string[], b: readonly string[]) => + a.length === b.length && a.every((domain, index) => domain === b[index]); + return ( + sameList(active.writableDomains, canonical.writableDomains) && + sameList(active.manageableDomains, canonical.manageableDomains) && active.accountId === canonical.accountId && active.username === canonical.username && active.displayName === canonical.displayName && diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 23c360bf9..672484ac7 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -103,6 +103,24 @@ export interface AccountAccessEntry { * than smuggled into this boolean. */ canWrite: boolean; + /** + * v1.38.12 — the sections this grant can add to, resolved server-side from + * the grant's level and scope against the delegated write routes that + * exist (`domain-write-support.ts`). In the consent screen's order. Empty + * for a READ grant, and empty for any section whose routes accept no + * delegated write at all — the vault, today. + * + * A list rather than a boolean because the answer differs per section, and + * the client's per-control question is "this section", never "any". + */ + writableDomains: ShareDomain[]; + /** + * v1.38.12 — the sections this grant can change: edit, delete, restore, + * bulk-act, and the creates a WRITE grant does not cover. A subset of + * {@link AccountAccessEntry.writableDomains} by construction, and empty + * below MANAGE. + */ + manageableDomains: ShareDomain[]; } /** The `accountAccess` block on `GET /api/auth/me`. */ diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 0ce051611..c46885ab9 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -42,6 +42,7 @@ import type { AccountAccessEntry, AccountAccessLevel, } from "@/lib/sharing/account-access-view"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; import { resolveGrantSections } from "@/lib/sharing/grant-view"; import { grantAllows, isGrantActive } from "@/lib/sharing/grants"; import type { AccountGrantAccess } from "@/generated/prisma/client"; @@ -126,6 +127,7 @@ export async function resolveAccountAccess(auth: { // clients that have not learned the canonical level. `level` names the // complete three-level contract. const level = LEVEL[grant.access]; + const sections = resolveGrantSections(grant.scopeJson); return { accountId: grant.grantor.id, username: grant.grantor.username, @@ -135,8 +137,14 @@ export async function resolveAccountAccess(auth: { level, recordKind: grant.grantor.managedProfileAt === null ? "shared" : "managed", - sections: resolveGrantSections(grant.scopeJson), + sections, canWrite: grantAllows(grant, "write", now), + // v1.38.12 — per-section write answers, from the same level and scope + // the two fields above publish, intersected with the routes that + // exist. The lists are what the controls read; the level and the + // sections stay published so the chrome can say what was granted. + writableDomains: delegatedDomains(level, sections, "write"), + manageableDomains: delegatedDomains(level, sections, "manage"), }; }); From e95612e3d35c01b6f7f6d5a348e255abfefb1301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 19:42:27 +0200 Subject: [PATCH 3/8] fix(sharing): show a guardian the controls the routes accept (#939) useRecordCapabilities answers per section now. canWriteDomain and canManageDomain read the two lists the account payload publishes; in one's own record they are always true, and under a refused, pending or unproven context always false. canManage becomes the coarse switch, true when any section is manageable, and is what the banner and the navigation read. Every add, edit and delete control names its own section: labs, mind, profile, illness, cycle, medications, measurements, documents. Creates that a WRITE route answers (visits, practitioners) ask canWriteDomain. The generic delete button, the selection bar, the capture picker, the dashboard quick entry and the bottom nav take the section from their callers. Controls whose routes resolve the caller rather than the record (settings shortcuts, chart overlay preferences, custom metrics, the Today rail, OCR, efficacy targets, credential tabs) ask for the caller's own record instead, and the customise-shortcut guard now requires that shape. The vault has no delegated write route, so its controls stay withheld under every grant, and the documents page says so in one sentence where the upload control would be, in all seven locales. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../customize-shortcut-guard.test.ts | 60 ++++--- .../sharing-legacy-client-contract.test.ts | 10 +- src/app/labs/page.tsx | 8 +- src/app/medications/page-client.tsx | 4 +- src/app/mood/page-client.tsx | 14 +- .../delegated-write-affordances.test.tsx | 167 ++++++++++++++---- .../charts/chart-overlay-controls.tsx | 4 +- .../custom-metrics/custom-metric-detail.tsx | 1 + .../custom-metric-history-list.tsx | 1 + .../custom-metrics/custom-metric-list.tsx | 6 +- .../__tests__/cycle-view-error-state.test.tsx | 6 +- src/components/cycle/cycle-view.tsx | 13 +- src/components/daily/today-hero.tsx | 9 +- src/components/dashboard/dashboard-header.tsx | 8 +- .../dashboard/quick-entry-sheets.tsx | 15 +- src/components/data-list/delete-button.tsx | 22 ++- .../data-list/selection-action-bar.tsx | 20 ++- .../documents/document-detail-sheet.tsx | 31 ++-- src/components/documents/documents-view.tsx | 34 ++-- .../documents/episode-documents-card.tsx | 11 +- .../__tests__/visits-section.test.tsx | 7 +- src/components/encounters/visits-section.tsx | 6 +- .../illness/episode-visits-card.tsx | 8 +- .../illness/illness-day-timeline.tsx | 5 +- .../illness/illness-episode-detail.tsx | 5 +- src/components/illness/illness-view.tsx | 16 +- .../__tests__/ocr-capability-gate.test.ts | 4 +- src/components/labs/biomarker-manager.tsx | 1 + src/components/labs/lab-biomarker-detail.tsx | 6 +- src/components/labs/lab-history-list.tsx | 6 +- src/components/labs/use-ocr-extract.ts | 7 +- .../__tests__/capture-picker-discard.test.tsx | 2 + .../layout/__tests__/capture-picker.test.tsx | 2 + src/components/layout/bottom-nav.tsx | 19 +- src/components/layout/capture-picker.tsx | 19 +- ...rsorge-dashboard-card-error-state.test.tsx | 6 +- .../vorsorge-dashboard-card.tsx | 5 +- .../vorsorge-section.tsx | 29 +-- .../measurements/measurement-list.tsx | 3 + .../detail/efficacy/efficacy-tab.tsx | 6 +- .../detail/medication-detail-tabs.tsx | 29 ++- .../medications/dose-history-ledger.tsx | 7 +- .../medications/intake-history-editable.tsx | 5 +- .../medications/intake-history-list-v2.tsx | 5 +- .../medications/medication-card-menu.tsx | 9 +- .../sections/intake-history-preview.tsx | 5 +- .../sections/inventory-section.tsx | 34 ++-- .../medications/side-effects-section.tsx | 5 +- .../mental-health/instrument-card.tsx | 5 +- .../mental-health/instrument-detail.tsx | 5 +- src/components/mood/mood-list.tsx | 18 +- .../__tests__/practitioner-list.test.tsx | 6 +- .../practitioners/practitioner-list.tsx | 9 +- src/components/records/allergy-manager.tsx | 1 + .../records/family-history-manager.tsx | 1 + .../__tests__/use-record-capabilities.test.ts | 116 ++++++++++-- src/hooks/use-record-capabilities.ts | 106 +++++++---- 64 files changed, 690 insertions(+), 289 deletions(-) diff --git a/messages/de.json b/messages/de.json index 58a32857f..eef3089fe 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9548,6 +9548,7 @@ "title": "Dokumente", "subtitle": "Jeder Brief, Befund und Scan an einem durchsuchbaren Ort — verschlüsselt gespeichert.", "pageUpload": "Hochladen", + "ownerOnlyUpload": "Dokumente kann nur die Inhaberin oder der Inhaber der Akte hinzufügen.", "kind": { "DOCTOR_REPORT": "Arztbericht", "DISCHARGE_LETTER": "Entlassungsbrief", diff --git a/messages/en.json b/messages/en.json index 3ba64450b..e75e342a8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9548,6 +9548,7 @@ "title": "Documents", "subtitle": "Every letter, report, and scan in one searchable place — encrypted at rest.", "pageUpload": "Upload", + "ownerOnlyUpload": "Documents can only be added by the record owner.", "kind": { "DOCTOR_REPORT": "Doctor report", "DISCHARGE_LETTER": "Discharge letter", diff --git a/messages/es.json b/messages/es.json index 9515185a7..91f6187d8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9548,6 +9548,7 @@ "title": "Documentos", "subtitle": "Cada carta, informe y escaneo en un solo lugar con búsqueda — cifrado en reposo.", "pageUpload": "Subir", + "ownerOnlyUpload": "Solo el titular del historial puede añadir documentos.", "kind": { "DOCTOR_REPORT": "Informe médico", "DISCHARGE_LETTER": "Informe de alta", diff --git a/messages/fr.json b/messages/fr.json index 8724f604f..23921c63d 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9548,6 +9548,7 @@ "title": "Documents", "subtitle": "Chaque courrier, compte rendu et scan au même endroit, consultable — chiffré au repos.", "pageUpload": "Téléverser", + "ownerOnlyUpload": "Seul le titulaire du dossier peut ajouter des documents.", "kind": { "DOCTOR_REPORT": "Compte rendu médical", "DISCHARGE_LETTER": "Lettre de sortie", diff --git a/messages/it.json b/messages/it.json index c2b0a981e..e17e31091 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9548,6 +9548,7 @@ "title": "Documenti", "subtitle": "Ogni lettera, referto e scansione in un unico posto ricercabile — cifrato a riposo.", "pageUpload": "Carica", + "ownerOnlyUpload": "Solo il titolare della cartella può aggiungere documenti.", "kind": { "DOCTOR_REPORT": "Referto medico", "DISCHARGE_LETTER": "Lettera di dimissione", diff --git a/messages/ko.json b/messages/ko.json index d82cc8fe9..66d228404 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -9548,6 +9548,7 @@ "title": "문서", "subtitle": "모든 소견서·리포트·영상을 한곳에서 검색해요 — 저장할 때 암호화돼요.", "pageUpload": "업로드", + "ownerOnlyUpload": "문서는 기록 소유자만 추가할 수 있습니다.", "kind": { "DOCTOR_REPORT": "진료용 리포트", "DISCHARGE_LETTER": "퇴원 소견서", diff --git a/messages/pl.json b/messages/pl.json index 4ebd23e32..7ec4d5cbc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9548,6 +9548,7 @@ "title": "Dokumenty", "subtitle": "Każde pismo, wynik i skan w jednym przeszukiwalnym miejscu — zaszyfrowane w spoczynku.", "pageUpload": "Prześlij", + "ownerOnlyUpload": "Dokumenty może dodawać tylko właściciel kartoteki.", "kind": { "DOCTOR_REPORT": "Raport lekarski", "DISCHARGE_LETTER": "Wypis ze szpitala", diff --git a/src/__tests__/customize-shortcut-guard.test.ts b/src/__tests__/customize-shortcut-guard.test.ts index 9945974b4..9e4f93341 100644 --- a/src/__tests__/customize-shortcut-guard.test.ts +++ b/src/__tests__/customize-shortcut-guard.test.ts @@ -12,16 +12,24 @@ * it, so this states it. It is structural rather than rendered because these * pages early-return a loading gate under SSR — the markup a delegate would * see is not reachable without a browser, and the property is about the source - * either way: the link is inside a `canManage` conditional, or it is not. + * either way: the link is inside an `inSharedRecord` conditional, or it is + * not. + * + * v1.38.12 — the guard used to name the manage capability, which was true + * only in one's own record. It answers per section now and is true for a + * guardian, so a wrench behind it would send a guardian into the wall. The + * customise pages live under `/settings`, which is the CALLER's and never the + * record's, so the link is withheld on `inSharedRecord` — the one answer no + * grant level moves. * * ## What decides * * The AST. The `href` is found as a string literal on a JSX attribute and the * ancestor chain above it is walked for a `&&` whose left-hand side names - * `canManage`. A comment about gating cannot satisfy that, and a `canManage` - * used elsewhere in the same file cannot either — which a "does this file - * mention the symbol" matcher would have accepted, and which is exactly what - * `/mood` looked like the day it shipped ungated. + * `inSharedRecord`. A comment about gating cannot satisfy that, and an + * `inSharedRecord` used elsewhere in the same file cannot either — which a + * "does this file mention the symbol" matcher would have accepted, and which + * is exactly what `/mood` looked like the day it shipped ungated. */ import { readdirSync, readFileSync } from "node:fs"; import { join, sep } from "node:path"; @@ -34,8 +42,8 @@ const SRC = join(process.cwd(), "src"); /** The prefix every customise shortcut points at. */ const LAYOUT_PREFIX = "/settings/layout/"; -/** The capability a shared-record surface withholds these on. */ -const GUARD = "canManage"; +/** The answer a shared-record surface withholds these on: `!inSharedRecord`. */ +const GUARD = "inSharedRecord"; /** * The surfaces that carry a layout shortcut and need no gate. @@ -66,16 +74,16 @@ function allSourceFiles(): string[] { .sort(); } -/** Does any ancestor of `node` gate it behind ` && …`? */ -function guardedByCanManage(node: ts.Node): boolean { +/** Does any ancestor of `node` gate it behind `! && …`? */ +function guardedByOwnRecord(node: ts.Node): boolean { for (let cur: ts.Node | undefined = node; cur; cur = cur.parent) { const parent: ts.Node | undefined = cur.parent; if ( parent && ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && - // Only the RIGHT side is the guarded branch. `canManage && x` guards `x`; - // `x && canManage` is `canManage` being the guarded thing. + // Only the RIGHT side is the guarded branch. `!inSharedRecord && x` + // guards `x`; `x && !inSharedRecord` is the answer being the guarded thing. parent.right === cur ) { const names = new Set(); @@ -87,7 +95,7 @@ function guardedByCanManage(node: ts.Node): boolean { if (names.has(GUARD)) return true; } // A conditional whose whenTrue branch holds the link, guarded on the - // capability: `canManage ? : null`, the shape `vorsorge-section` + // answer: `!inSharedRecord ? : null`, the shape `vorsorge-section` // uses. if ( parent && @@ -120,7 +128,7 @@ function unguardedShortcuts(source: string, fileName: string): string[] { if ( ts.isStringLiteralLike(node) && node.text.startsWith(LAYOUT_PREFIX) && - !guardedByCanManage(node) + !guardedByOwnRecord(node) ) { found.push(node.text); } @@ -160,20 +168,20 @@ describe("the customise shortcut is withheld inside somebody else's record", () } expect( offenders, - `a customise link outside a \`${GUARD}\` branch sends a delegate to /settings, which a switch closes`, + `a customise link outside a \`!${GUARD}\` branch sends a delegate to /settings, which a switch closes`, ).toEqual([]); }); it("the matcher reads the branch, not the file", () => { - // The shape that shipped: the capability is resolved and used elsewhere in + // The shape that shipped: the answer is resolved and used elsewhere in // the module while the link itself sits outside any branch. A matcher - // asking "does this file name canManage" calls this clean. + // asking "does this file name inSharedRecord" calls this clean. const UNGATED = ` export function Page() { - const { canManage } = useRecordCapabilities(); + const { inSharedRecord } = useRecordCapabilities(); return ( <> - {canManage && )} - {/* v1.36.x — a mood entry is not one of the verbs a grant - admits, so it stays with the account whose record it is. A - delegate at any level gets no add path here. */} - {canManage && ( + {/* A mood entry is a MANAGE create: a WRITE delegate gets no add + path here, a guardian or a MANAGE delegate does. */} + {canAddMood && ( deleteMutation.mutate(r.id)} title={t("customMetrics.entry.deleteConfirmTitle")} description={t( diff --git a/src/components/custom-metrics/custom-metric-list.tsx b/src/components/custom-metrics/custom-metric-list.tsx index 9363e4511..73d0cdacf 100644 --- a/src/components/custom-metrics/custom-metric-list.tsx +++ b/src/components/custom-metrics/custom-metric-list.tsx @@ -38,7 +38,9 @@ export function CustomMetricList() { // Logging a value against a custom metric was admitted for a release and is // not any more: this paragraph named the gap, the release shipped over it, // and the write withdrew rather than the sentence staying true. - const { canManage } = useRecordCapabilities(); + // Every custom-metric write resolves the caller (`requireAuth`); no grant + // reaches it at any level. + const { inSharedRecord } = useRecordCapabilities(); const router = useRouter(); const [addOpen, setAddOpen] = useState(false); const [addFooterEl, setAddFooterEl] = useState(null); @@ -57,7 +59,7 @@ export function CustomMetricList() { router.push(`/custom-metrics/${saved.id}`); } - if (!canManage) return null; + if (inSharedRecord) return null; return (
({ })); vi.mock("@/hooks/use-record-capabilities", () => ({ - useRecordCapabilities: () => ({ canManage: true, canAdd: true }), + useRecordCapabilities: () => ({ + canManage: true, + canAdd: true, + canManageDomain: () => true, + }), })); const errorQuery = { diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index c268f4a20..b8c084508 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -93,7 +93,8 @@ export function CycleView() { // v1.36.x — no grant level admits a cycle write. Inside somebody else's // record the page stays readable and every path into the log-day sheet and // the profile settings is absent, rather than opening onto a refusal. - const { canManage } = useRecordCapabilities(); + const { canManageDomain } = useRecordCapabilities(); + const canManageCycle = canManageDomain("cycle"); // Deep-link support: `/cycle?tab=insights` opens the insights tab. The value // is read ONCE for the initial `defaultValue` (uncontrolled tabs — so normal @@ -188,7 +189,7 @@ export function CycleView() { // One action. The settings glyph that used to sit beside it was a // second door to the tab directly below, and no other page in the // app carries a gear in that corner. - canManage ? ( + canManageCycle ? ( ) : null} - {canManage && ( + {canManageDocuments && ( deleteMutation.mutate(marker.id)} title={t("labs.biomarker.deleteConfirmTitle")} description={t("labs.biomarker.deleteConfirmDescription")} diff --git a/src/components/labs/lab-biomarker-detail.tsx b/src/components/labs/lab-biomarker-detail.tsx index dbcfce3ed..97e0dcd3d 100644 --- a/src/components/labs/lab-biomarker-detail.tsx +++ b/src/components/labs/lab-biomarker-detail.tsx @@ -95,7 +95,8 @@ const READINGS_PAGE_SIZE = 200; */ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { const { user } = useAuth(); - const { canAdd, canManage } = useRecordCapabilities(); + const { canAdd, canManageDomain } = useRecordCapabilities(); + const canManageLabs = canManageDomain("labs"); const { t, locale } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); @@ -332,6 +333,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { full marker editor behind the pencil, so the separate range control was dropped). */} deleteMarker.mutate()} title={t("labs.biomarker.deleteConfirmTitle")} description={t("labs.biomarker.deleteConfirmDescription")} @@ -340,7 +342,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) { className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9" iconClassName="h-4 w-4" /> - {canManage && ( + {canManageLabs && ( @@ -864,7 +866,8 @@ function VorsorgeCard({ }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { canManage } = useRecordCapabilities(); + const { canManageDomain } = useRecordCapabilities(); + const canManageMeasurements = canManageDomain("measurements"); // Issue #490 — day-boundary zone for the relative "today / yesterday" // bucket must match the zone `fmt.date` renders in (mirror → Berlin). // The next-due phrase reads off the SAME zone as the last-done date below @@ -938,7 +941,7 @@ function VorsorgeCard({ // Single med-style kebab: Edit + Delete. The Delete item is the shared // confirm-on-delete control rendered as a full-width menu row. - const headerActions = canManage ? ( + const headerActions = canManageMeasurements ? ( )} deleteMutation.mutate(m.id)} title={t("measurements.deleteConfirmTitle")} @@ -1535,6 +1537,7 @@ export function MeasurementList({ {/* v1.15.13 — page-scoped multi-select action bar. */} (""); const [busy, setBusy] = useState(false); @@ -443,7 +445,7 @@ function RetargetControl({ } }; - if (!canManage || items.length === 0) return null; + if (inSharedRecord || items.length === 0) return null; return (
= new Set([ + // Schedule times are a MANAGE route, but the tab also carries the reminder + // and notification settings, which resolve the caller. "zeitplan", - "bestand", + // Per-medication tokens: a credential surface, never delegated. "api", + // Phase config, export and the token-adjacent rows resolve the caller. "erweitert", ]); +/** + * v1.38.12 — tabs whose every write is a MANAGE route under `medications`, + * so a grant that reaches those routes is shown the tab. The supply ledger + * is one; the three above are not, because each mixes in a route that + * resolves the caller and would 403 under a switch. + */ +const MANAGE_TABS: ReadonlySet = new Set(["bestand"]); + /** * Legacy slugs that point at a dissolved tab. The Erinnerung tab folded * into Zeitplan in v1.15.20; old deep-links keep landing on the surface @@ -242,7 +253,8 @@ export function MedicationDetailTabs({ }) { const { t } = useTranslations(); const fmt = useFormatters(); - const { canManage } = useRecordCapabilities(); + const { canManageDomain, inSharedRecord } = useRecordCapabilities(); + const canManageMedications = canManageDomain("medications"); const router = useRouter(); const searchParams = useSearchParams(); @@ -308,9 +320,16 @@ export function MedicationDetailTabs({ (slug !== "injektion" || isInjectable) && (slug !== "zeitplan" || !asNeeded) && (slug !== "wirkung" || hasEfficacyTarget) && - (canManage || !OWNER_ONLY_TABS.has(slug)), + (!inSharedRecord || !OWNER_ONLY_TABS.has(slug)) && + (canManageMedications || !MANAGE_TABS.has(slug)), ), - [isInjectable, asNeeded, hasEfficacyTarget, canManage], + [ + isInjectable, + asNeeded, + hasEfficacyTarget, + inSharedRecord, + canManageMedications, + ], ); const requestedRaw = searchParams?.get("tab"); @@ -908,7 +927,7 @@ export function MedicationDetailTabs({ (`PUT /api/medications/[id]` is not delegable), so the deep link is gated exactly like the hero button it stands in for. */} void; }) { const { t } = useTranslations(); - const { canAdd, canManage } = useRecordCapabilities(); + const { canAdd, canManageDomain } = useRecordCapabilities(); + const canManageMedications = canManageDomain("medications"); const fmt = useFormatters(); const actionable = isSlotActionable(row); @@ -733,7 +734,7 @@ export function LedgerRowItem({ items in one menu, a delegate gets skip as its own control beside take, which is the shape the card already uses. The owner keeps it where it was. */} - {showTakenButton && canAdd && !canManage && ( + {showTakenButton && canAdd && !canManageMedications && (
+ {/* `/settings` is not a shared-record destination, so the cross-link + is withheld under a switch rather than opening onto the "not part + of what was shared" panel. */} + {!inSharedRecord && ( +
+ + {lowStockDays !== null + ? t("medications.detail.bestand.lowStockLinkOn", { + days: lowStockDays, + }) + : t("medications.detail.bestand.lowStockLinkOff")} + +
+ )} {/* v1.16.11 — packaging economics surfaced where the stock lives: @@ -443,6 +450,7 @@ export function InventorySection({ {t("medications.detail.bestand.adjustButton")} void deleteItem(item)} title={t("medications.detail.bestand.deleteTitle")} description={t("medications.detail.bestand.deleteDescription")} diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx index 1e376d0be..ebc9f3418 100644 --- a/src/components/medications/side-effects-section.tsx +++ b/src/components/medications/side-effects-section.tsx @@ -112,7 +112,8 @@ function entryI18nKey(entry: MedicationSideEffectEntry): string { export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) { const { t } = useTranslations(); - const { canAdd, canManage } = useRecordCapabilities(); + const { canAdd, canManageDomain } = useRecordCapabilities(); + const canManageMedications = canManageDomain("medications"); const fmt = useFormatters(); const queryClient = useQueryClient(); @@ -346,7 +347,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {

)} - {canManage && ( + {canManageMedications && ( - ) : onAddFirst && canManage ? ( + ) : onAddFirst && canManageMind ? ( deleteMutation.mutate(row.id)} title={t("records.allergies.deleteConfirmTitle")} description={t("records.allergies.deleteConfirmDescription")} diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index ba007a21b..1b865bc8a 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -91,6 +91,7 @@ export function FamilyHistoryManager() { deleteMutation.mutate(row.id)} title={t("records.family.deleteConfirmTitle")} description={t("records.family.deleteConfirmDescription")} diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index 4a7609b06..ef791c475 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -14,6 +14,8 @@ * nothing" goes red. * - make the `!active` branch return `canAdd: false` → "the caller's own * record keeps everything" goes red. + * - answer `canManageDomain` from `level === "manage"` instead of the list → + * "the vault is never manageable" goes red. */ import { describe, expect, it } from "vitest"; @@ -22,6 +24,8 @@ import { resolveRecordCapabilities, } from "@/hooks/use-record-capabilities"; import type { AccountAccessEntry } from "@/lib/sharing/account-access-view"; +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import { SHARE_DOMAINS } from "@/lib/sharing/scope"; const READ_ONLY: AccountAccessEntry = { accountId: "acct-owner", @@ -33,13 +37,20 @@ const READ_ONLY: AccountAccessEntry = { sections: null, recordKind: "shared", canWrite: false, + writableDomains: [], + manageableDomains: [], }; +// The lists are what the server publishes for each level over the whole +// record: the same helper `resolveAccountAccess` runs, so the fixture is the +// payload and not a guess at it. const WRITABLE: AccountAccessEntry = { ...READ_ONLY, access: "write", level: "write", canWrite: true, + writableDomains: delegatedDomains("write", null, "write"), + manageableDomains: [], }; const MANAGING: AccountAccessEntry = { @@ -47,6 +58,8 @@ const MANAGING: AccountAccessEntry = { access: "write", level: "manage", canWrite: true, + writableDomains: delegatedDomains("manage", null, "write"), + manageableDomains: delegatedDomains("manage", null, "manage"), }; const SCOPED: AccountAccessEntry = { @@ -59,7 +72,8 @@ describe("resolveRecordCapabilities", () => { // `null` is not "no access", it is "this is mine". Both an unswitched // session and a payload from a server that has never heard of sharing // land here, which is why the absent case must be the permissive one. - expect(resolveRecordCapabilities(null)).toEqual({ + const own = resolveRecordCapabilities(null); + expect(own).toMatchObject({ inSharedRecord: false, canWrite: false, canAdd: true, @@ -71,13 +85,21 @@ describe("resolveRecordCapabilities", () => { sections: null, recordKind: "self", }); - expect(resolveRecordCapabilities(undefined)).toEqual( - resolveRecordCapabilities(null), - ); + for (const domain of SHARE_DOMAINS) { + expect(own.canWriteDomain(domain), domain).toBe(true); + expect(own.canManageDomain(domain), domain).toBe(true); + } + expect(resolveRecordCapabilities(undefined)).toMatchObject({ + inSharedRecord: false, + canAdd: true, + canManage: true, + recordKind: "self", + }); }); it("refuses a malformed published block instead of treating it as own record", () => { - expect(resolveRecordCapabilities(null, true)).toEqual({ + const refused = resolveRecordCapabilities(null, true); + expect(refused).toMatchObject({ inSharedRecord: true, canWrite: false, canAdd: false, @@ -87,10 +109,15 @@ describe("resolveRecordCapabilities", () => { recordKind: "shared", accessRefused: true, }); + for (const domain of SHARE_DOMAINS) { + expect(refused.canWriteDomain(domain), domain).toBe(false); + expect(refused.canManageDomain(domain), domain).toBe(false); + } }); it("a read-only delegate adds nothing", () => { - expect(resolveRecordCapabilities(READ_ONLY)).toEqual({ + const reader = resolveRecordCapabilities(READ_ONLY); + expect(reader).toMatchObject({ inSharedRecord: true, canWrite: false, canAdd: false, @@ -99,12 +126,17 @@ describe("resolveRecordCapabilities", () => { sections: null, recordKind: "shared", }); + for (const domain of SHARE_DOMAINS) { + expect(reader.canWriteDomain(domain), domain).toBe(false); + expect(reader.canManageDomain(domain), domain).toBe(false); + } }); it("a delegate may not change what is already there", () => { // The asymmetry the whole design rests on: a WRITE grant adds, and that // is all it does. Not even to an entry the delegate made a minute ago. - expect(resolveRecordCapabilities(WRITABLE)).toEqual({ + const writer = resolveRecordCapabilities(WRITABLE); + expect(writer).toMatchObject({ inSharedRecord: true, canWrite: true, canAdd: true, @@ -113,6 +145,14 @@ describe("resolveRecordCapabilities", () => { sections: null, recordKind: "shared", }); + // Writable where a WRITE route exists, manageable nowhere. + expect(writer.canWriteDomain("measurements")).toBe(true); + expect(writer.canWriteDomain("labs")).toBe(true); + expect(writer.canWriteDomain("mind")).toBe(false); + expect(writer.canWriteDomain("documents")).toBe(false); + for (const domain of SHARE_DOMAINS) { + expect(writer.canManageDomain(domain), domain).toBe(false); + } }); it("carries the level and the sections through untouched", () => { @@ -128,22 +168,45 @@ describe("resolveRecordCapabilities", () => { expect(resolveRecordCapabilities(MANAGING).level).toBe("manage"); }); - it("does not hand a manage grant the affordances its routes cannot answer yet", () => { - // The deliberate gap, pinned so it cannot close by accident. MANAGE will - // bring the edit and delete controls back, and it does so in the release - // where the routes behind them answer — not in the one that merely - // publishes the level. A `canManage: true` here today would paint - // controls that 403, which is the exact failure these booleans exist to - // prevent. - expect(resolveRecordCapabilities(MANAGING)).toEqual({ + it("hands a manage grant exactly the sections its routes answer for", () => { + // The v1.37.0 hold-back answered `canManage: false` here until the routes + // behind the edit and delete controls existed. They exist; the answer is + // the server's per-section list, and the coarse boolean is "is that list + // non-empty". + const manager = resolveRecordCapabilities(MANAGING); + expect(manager).toMatchObject({ inSharedRecord: true, canWrite: true, canAdd: true, - canManage: false, + canManage: true, level: "manage", sections: null, recordKind: "shared", }); + expect(manager.canManageDomain("mind")).toBe(true); + expect(manager.canManageDomain("profile")).toBe(true); + expect(manager.canManageDomain("labs")).toBe(true); + expect(manager.canWriteDomain("mind")).toBe(true); + }); + + it("never lets a shared record manage the vault", () => { + // The vault has no delegated write route, so the server's list never + // names it — and the hook binds the list rather than the level. Answering + // from `level === "manage"` would paint the upload control that 403s. + expect( + resolveRecordCapabilities(MANAGING).canManageDomain("documents"), + ).toBe(false); + expect( + resolveRecordCapabilities(MANAGING).canWriteDomain("documents"), + ).toBe(false); + const claimsEverything: AccountAccessEntry = { + ...MANAGING, + manageableDomains: [], + }; + // An empty list from the server is an empty answer, whatever the level. + const held = resolveRecordCapabilities(claimsEverything); + expect(held.canManage).toBe(false); + expect(held.canManageDomain("mind")).toBe(false); }); it("binds the server's boolean rather than the grant's label", () => { @@ -157,6 +220,19 @@ describe("resolveRecordCapabilities", () => { canWrite: false, }; expect(resolveRecordCapabilities(disagreeing).canAdd).toBe(false); + // The same posture for the lists: a scoped WRITE grant is writable only + // where its list says so, not wherever its level would reach. + const scopedWriter: AccountAccessEntry = { + ...WRITABLE, + sections: ["labs"], + writableDomains: ["labs"], + }; + expect(resolveRecordCapabilities(scopedWriter).canWriteDomain("labs")).toBe( + true, + ); + expect( + resolveRecordCapabilities(scopedWriter).canWriteDomain("measurements"), + ).toBe(false); }); }); @@ -242,8 +318,10 @@ describe("an unprovable record context withholds every control", () => { it("leaves every existing arm untouched when the two agree", () => { // The fourth argument defaults to false, so every case above this block // means exactly what it meant before the fence existed. - expect(resolveRecordCapabilities(READ_ONLY)).toEqual( - resolveRecordCapabilities(READ_ONLY, false, false, false), - ); + expect(resolveRecordCapabilities(READ_ONLY)).toMatchObject({ + ...resolveRecordCapabilities(READ_ONLY, false, false, false), + canWriteDomain: expect.any(Function), + canManageDomain: expect.any(Function), + }); }); }); diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index ddfdbcf90..524c075a8 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -6,6 +6,10 @@ import { accountLabel, type AccountAccessEntry, } from "@/lib/sharing/account-access-view"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +const NEVER = () => false; +const ALWAYS = () => true; /** * What the person at the keyboard may do to the record on screen. @@ -20,7 +24,7 @@ import { * still taught them the product was broken. This hook is what every mutation * affordance asks before it renders. * - * ## The two answers, and why there are two + * ## The answers, and why there are several * * A grant at WRITE admits a short, closed list of verbs: entering readings, * lab results, an illness entry, a biomarker, a side effect, a medication, and @@ -32,34 +36,44 @@ import { * reason one release later: its only form sits on `/custom-metrics/{id}`, * which a switch closes too. * `sharing-surface-guard.test.ts` carries that argument - * in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and - * bulk-acting stay with the owner, including on an entry the delegate added - * themselves a minute ago, and so does every create the list does not name - * (a mood entry, a screener, a cycle day). Two booleans carry that: + * in full and is the list that binds; this paragraph has to agree with it. + * Editing, deleting, restoring, purging, importing and bulk-acting are MANAGE + * verbs, and so is every create the list does not name (a mood entry, a + * screener, a cycle day). Which sections answer at which level is the + * server's table (`domain-write-support.ts`), published per grant as two + * lists on `accountAccess.active`. Four answers carry that: * * - `canAdd` — may this person add one of the admitted kinds. - * - `canManage` — may this person change what is already there, or add - * something the delegation does not cover. True only in one's own record. + * - `canWriteDomain(domain)` — is there a delegated write in this section + * the grant satisfies. + * - `canManageDomain(domain)` — may this person change what is already + * there in this section, or add what a WRITE grant does not cover. + * - `canManage` — the coarse switch: is there ANY section this person may + * manage. The banner and the navigation ask it; a control never should, + * because a control belongs to one section and the answer differs per + * section (the vault is read-only under every grant). * - * A surface that offers an admitted create asks `canAdd`. Everything else - * asks `canManage`. Neither is a permission decision made here: `canWrite` - * arrives resolved from the server on `accountAccess.active` and is bound, - * never recomputed (`account-access-view.ts:11-15`). What this file decides is - * only which class of affordance a resolved level covers, and that mapping is - * a property of the release, not of the caller. + * A surface that offers an admitted create asks `canAdd`. A control that + * edits, deletes or creates what WRITE does not cover asks `canManageDomain` + * for its own section. A control whose route resolves the caller rather than + * the record (settings, credentials, AI budget, chart preferences) asks + * `!inSharedRecord`: no grant reaches it at any level. None of these is a + * permission decision made here: `canWrite` and the two lists arrive resolved + * from the server on `accountAccess.active` and are bound, never recomputed + * (`account-access-view.ts`). What this file decides is only which class of + * affordance a resolved answer covers. * - * ## The two facts this hook carries but does not yet act on + * ## Why the lists are per section * - * v1.37.0 gives a grant a section set and a third level, and both arrive here - * (`sections`, `level`) as resolved values beside the booleans. Nothing in - * this file turns either into a capability, and that is the state of the - * release rather than an omission: the nav narrowing that reads `sections` - * and the return of the edit and delete affordances that reads `level` are - * the shared-session UX work, which lands after the routes that answer for a - * MANAGE grant exist. Publishing the facts here first is what lets those be - * a rendering change rather than a second access decision — and a control - * that appeared before its route answered would be the failure the two - * booleans above were written to end. + * v1.37.0 published `level` and `sections` and answered `canManage: false` + * for every shared record, a hold-back for the release where the level + * arrived before the routes behind it. The routes landed and the hold-back + * stayed, so a guardian of a managed profile saw no add, edit or delete + * control on Mood, screeners, visits, allergies and labs while the server + * accepted every one of those writes (#939). A single boolean could not have + * closed that without opening the vault, whose routes accept none; the lists + * close it section by section, and a section with no delegated route stays + * closed by the same mechanism. * * ## Absent, not disabled * @@ -82,17 +96,35 @@ export interface RecordCapabilities { canWrite: boolean; /** May the caller add an entry of a kind the delegation admits. */ canAdd: boolean; - /** May the caller change what exists, or add what the delegation excludes. */ + /** + * May the caller change what exists somewhere in this record, or add what + * the delegation excludes. In one's own record, always. In a shared record, + * true when `manageableDomains` is non-empty: the coarse switch the banner + * and the navigation read. A control asks {@link canManageDomain} for its + * own section instead, because the answer differs per section. + */ canManage: boolean; + /** + * v1.38.12 — is there a delegated write in `domain` this grant satisfies. + * Own record: always. Shared record: membership in + * `accountAccess.active.writableDomains`. Refused, pending or unproven + * context: never. + */ + canWriteDomain: (domain: ShareDomain) => boolean; + /** + * v1.38.12 — may the caller edit, delete or bulk-act in `domain`, or add + * what a WRITE grant does not cover there. Own record: always. Shared + * record: membership in `accountAccess.active.manageableDomains`. Refused, + * pending or unproven context: never. + */ + canManageDomain: (domain: ShareDomain) => boolean; /** * v1.37.0 — the resolved level of the grant on screen, or `null` in one's * own record (which is not a grant and has no level). * - * Bound from `accountAccess.active.level`, never derived. `canManage` is - * deliberately NOT computed from it in this release: the edit and delete - * affordances come back for `"manage"` when the routes that answer them - * land, and a control that appears before its route answers is the exact - * failure the two booleans above exist to prevent. + * Bound from `accountAccess.active.level`, never derived, and not what the + * controls read: the two domain lists are, because a level says what was + * granted and the lists say which routes answer for it. */ level: AccountAccessEntry["level"] | null; /** @@ -136,6 +168,8 @@ export function resolveRecordCapabilities( canWrite: false, canAdd: false, canManage: false, + canWriteDomain: NEVER, + canManageDomain: NEVER, level: null, sections: [], recordKind: "shared", @@ -147,6 +181,8 @@ export function resolveRecordCapabilities( canWrite: false, canAdd: true, canManage: true, + canWriteDomain: ALWAYS, + canManageDomain: ALWAYS, // One's own record has no grant, so it has no level, and it is open in // full. Null on both counts rather than a fabricated "manage" over all // eight sections: the caller is not a delegate, and a consumer that had @@ -156,11 +192,19 @@ export function resolveRecordCapabilities( recordKind: "self", }; } + // Bound, not derived: the two lists are the server's intersection of the + // grant with the routes that exist. Membership is the whole of the client's + // part, and `canManage` is only the question "is either list non-empty" + // asked of the manage one. + const writable = new Set(active.writableDomains); + const manageable = new Set(active.manageableDomains); return { inSharedRecord: true, canWrite: active.canWrite, canAdd: active.canWrite, - canManage: false, + canManage: manageable.size > 0, + canWriteDomain: (domain) => writable.has(domain), + canManageDomain: (domain) => manageable.has(domain), level: active.level, sections: active.sections, recordKind: active.recordKind, From 4b3e71570ce2dd3ee67c4fd5dce6809384837e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 19:42:27 +0200 Subject: [PATCH 4/8] test(sharing): drive the managed-record capabilities over the routes and the browser The integration leg reads GET /api/auth/me as a guardian acting on a managed profile and asserts both lists, lands a mood entry and an allergy on the profile's record, is refused a vault write, and shows a WRITE delegate writable sections without manageable ones together with the refused mood entry the list withheld. The browser leg opens the managed profile, adds a mood entry through the control that used to be hidden and asserts the POST's status, and finds the owner-only sentence and no upload control on the documents page. The spec gets its own session jar and runs in the desktop project only, like every other spec that moves the record selector. --- e2e/setup/global-setup.ts | 12 + e2e/setup/test-helpers.ts | 1 + e2e/v138-managed-record-capabilities.spec.ts | 140 ++++++++++ playwright.config.ts | 1 + .../managed-record-capabilities.test.ts | 246 ++++++++++++++++++ 5 files changed, 400 insertions(+) create mode 100644 e2e/v138-managed-record-capabilities.spec.ts create mode 100644 tests/integration/managed-record-capabilities.test.ts diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 44ac59a20..14f0343ad 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -198,6 +198,17 @@ export const SCOPE_A11Y_STORAGE_STATE_PATH = resolve( "e2e/setup/storageStateScopeA11y.json", ); +/** + * v1.38.12 — the managed-record capabilities journey's own jar. Same scope + * delegate, its own session row, for the reason every switching spec has + * one: it moves the record selector, and two specs driving one row from two + * workers would test their interference rather than the controls. + */ +export const SCOPE_CAPABILITIES_STORAGE_STATE_PATH = resolve( + process.cwd(), + "e2e/setup/storageStateScopeCapabilities.json", +); + /** * v1.37.0 — one jar per SWITCHING spec, for the reason spelled out at * `DELEGATE_STORAGE_STATE_PATH` above and now applied to the scope delegate as @@ -739,6 +750,7 @@ export default async function globalSetup(config: FullConfig): Promise { await capture(E2E_SCOPE_DELEGATE, SCOPE_DELEGATE_STORAGE_STATE_PATH); await capture(E2E_SCOPE_DELEGATE, SCOPE_A11Y_STORAGE_STATE_PATH); + await capture(E2E_SCOPE_DELEGATE, SCOPE_CAPABILITIES_STORAGE_STATE_PATH); // v1.37.0 — one jar per switching spec. See the block comment on // FENCE_STORAGE_STATE_PATH: these three used to share the scope delegate's diff --git a/e2e/setup/test-helpers.ts b/e2e/setup/test-helpers.ts index 9fd153d33..024f9199e 100644 --- a/e2e/setup/test-helpers.ts +++ b/e2e/setup/test-helpers.ts @@ -21,6 +21,7 @@ export { DELEGATE_STORAGE_STATE_PATH, SCOPE_DELEGATE_STORAGE_STATE_PATH, SCOPE_A11Y_STORAGE_STATE_PATH, + SCOPE_CAPABILITIES_STORAGE_STATE_PATH, FENCE_STORAGE_STATE_PATH, FENCE_OFFLINE_STORAGE_STATE_PATH, CROSS_TAB_STORAGE_STATE_PATH, diff --git a/e2e/v138-managed-record-capabilities.spec.ts b/e2e/v138-managed-record-capabilities.spec.ts new file mode 100644 index 000000000..29650bf10 --- /dev/null +++ b/e2e/v138-managed-record-capabilities.spec.ts @@ -0,0 +1,140 @@ +/** + * v1.38.12 — the controls a guardian sees inside a managed profile (#939). + * + * A person acting on a managed profile holds a MANAGE grant, and the server + * has accepted their mood entries, screeners, visits, allergies and lab edits + * since v1.37.0. The interface withheld every one of those controls anyway, + * because the client answered `canManage: false` for every shared record. It + * answers per section now, from the two lists `GET /api/auth/me` publishes. + * + * Two pages, one each way: + * + * - Mood is a section whose create answers at MANAGE, so the add control + * is there and an entry can be added — asserted on the POST's status, not + * on the paint, because the paint is what lied before. + * - Documents is the section with no delegated write route, so the upload + * control is absent and the page says why, in a sentence, where the + * control would have been. + * + * Modelled on `v137-sharing-managed-profiles.spec.ts`; the switch helpers are + * the same ones, for the same reasons written there. + */ +import type { Page } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { + E2E_LEVEL_RECORDS, + SCOPE_CAPABILITIES_STORAGE_STATE_PATH, +} from "./setup/test-helpers"; + +async function openSwitcher(page: Page) { + await page.getByRole("button", { name: "User menu" }).first().click(); + await page.locator('[data-slot="account-switcher-trigger"]').click(); + await expect( + page.locator('[data-slot="account-switcher-menu"]'), + ).toBeVisible(); +} + +/** + * Run `act` and wait for the document it replaces to be gone. See the + * v1.37.0 spec for why waiting on the banner alone settles nothing. + */ +async function withDocumentReplacement( + page: Page, + act: () => Promise, +): Promise { + await page.evaluate(() => { + (window as Window & { __hlNavToken?: true }).__hlNavToken = true; + }); + await act(); + await page.waitForFunction( + () => + (window as Window & { __hlNavToken?: true }).__hlNavToken === undefined, + undefined, + { timeout: 30_000 }, + ); +} + +async function openRecord(page: Page, username: string) { + await page.goto("/"); + await openSwitcher(page); + const entry = page.locator( + `[data-slot="account-switcher-entry"][data-account-username="${username}"]`, + ); + await expect(entry).toHaveCount(1); + await withDocumentReplacement(page, () => entry.click()); + await expect(page.locator('[data-slot="shared-record-banner"]')).toBeVisible({ + timeout: 30_000, + }); +} + +async function leaveRecord(page: Page) { + await withDocumentReplacement(page, () => + page.locator('[data-slot="shared-record-banner-exit"]').click(), + ); + await expect(page.locator('[data-slot="shared-record-banner"]')).toHaveCount( + 0, + { timeout: 30_000 }, + ); +} + +test.describe.serial("a guardian's controls inside a managed profile", () => { + // Its own session row: this journey moves the record selector, and the + // scoped-sharing journey drives the same account from another worker. + test.use({ storageState: SCOPE_CAPABILITIES_STORAGE_STATE_PATH }); + + const managed = E2E_LEVEL_RECORDS.find( + (record) => record.recordKind === "managed", + ); + if (!managed) throw new Error("managed record fixture is missing"); + + test("offers the mood add control and lands the entry", async ({ page }) => { + await openRecord(page, managed.username); + const banner = page.locator('[data-slot="shared-record-banner"]'); + await expect(banner).toHaveAttribute("data-record-kind", "managed"); + await expect(banner).toHaveAttribute("data-access-level", "manage"); + + await page.goto("/mood"); + const add = page.locator('[data-slot="mood-add-entry"]'); + await expect(add).toBeVisible({ timeout: 30_000 }); + await add.click(); + + const face = page.locator('[data-slot="mood-face"][data-mood="GUT"]'); + await expect(face).toBeVisible(); + await face.click(); + + const posted = page.waitForResponse( + (response) => + response.request().method() === "POST" && + /\/api\/mood-entries(\?|$)/.test(response.url()), + { timeout: 30_000 }, + ); + await page.getByRole("button", { name: "Save", exact: true }).click(); + const response = await posted; + // The route, not the paint: a control that renders and 403s is the exact + // failure this release ends. + expect(response.status()).toBe(201); + await expect(page.locator('[data-slot="mood-rows"]').first()).toBeVisible({ + timeout: 30_000, + }); + + await leaveRecord(page); + }); + + test("withholds the vault's upload control and says why", async ({ + page, + }) => { + await openRecord(page, managed.username); + + await page.goto("/documents"); + const note = page.locator('[data-slot="documents-owner-only"]'); + await expect(note).toBeVisible({ timeout: 30_000 }); + await expect(note).toHaveText( + "Documents can only be added by the record owner.", + ); + await expect(page.getByRole("button", { name: "Upload" })).toHaveCount(0); + await expect(page.locator('input[type="file"]')).toHaveCount(0); + + await leaveRecord(page); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 0fb132700..928caf004 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -117,6 +117,7 @@ export default defineConfig({ testIgnore: [ "v137-sharing-managed-profiles.spec.ts", "v137-sharing-managed-profiles-a11y.spec.ts", + "v138-managed-record-capabilities.spec.ts", // v1.37.0 — every spec that MOVES a session's record selector runs in // exactly one project. // diff --git a/tests/integration/managed-record-capabilities.test.ts b/tests/integration/managed-record-capabilities.test.ts new file mode 100644 index 000000000..5939af474 --- /dev/null +++ b/tests/integration/managed-record-capabilities.test.ts @@ -0,0 +1,246 @@ +/** + * v1.38.12 — what a delegate is told they may do, and what the routes then do. + * + * `GET /api/auth/me` publishes two lists on the active entry, + * `writableDomains` and `manageableDomains`, and every add / edit / delete + * control in a shared record reads them. The lists are worth nothing unless + * the routes agree with them, so this file drives both ends over the real + * route handlers, the real grant table and real Postgres: + * + * 1. a guardian acting on a managed profile reads the payload and gets + * every section with a delegated route as manageable, and never the + * vault; + * 2. the same guardian posts a mood entry and an allergy — the two writes + * #939 was reported on — and both land on the PROFILE's record; + * 3. the same guardian is refused a vault write, which is what the empty + * answer for `documents` promised; + * 4. a WRITE delegate on an adult's record reads writable sections and no + * manageable ones, and is refused the mood entry the list withheld. + * + * The lists are compared against `delegatedDomains`, the helper the resolver + * itself runs, so a change to the table shows up here as a change in what the + * routes must then honour, not as a fixture to update. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { delegatedDomains } from "@/lib/sharing/domain-write-support"; +import type { ShareDomain } from "@/lib/sharing/scope"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `caps-${suffix}`, + email: `caps-${suffix}@example.test`, + displayName: `Caps ${suffix}`, + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + onboardingCompletedAt: new Date(), + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +interface ActiveEntry { + accountId: string; + level: "read" | "write" | "manage"; + recordKind: "shared" | "managed"; + writableDomains: ShareDomain[]; + manageableDomains: ShareDomain[]; +} + +async function readActive(): Promise { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { accountAccess: { active: ActiveEntry | null } }; + }; + const active = body.data.accountAccess.active; + if (active === null) throw new Error("expected a switched payload"); + return active; +} + +function jsonRequest(url: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${url}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function postMood() { + const { POST } = await import("@/app/api/mood-entries/route"); + return POST(jsonRequest("/api/mood-entries", { mood: "GUT" })); +} + +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); + }); +}); From 386e92e6f998b0ecd2880b9f268303979c67da01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 20:06:48 +0200 Subject: [PATCH 5/8] test(sharing): carry the two domain lists through the account-access contract The account-access integration contract compares whole entries, so the two lists the payload now publishes belong in its expected shapes. The managed-record leg posts the mood entry with the logged-at instant the create schema requires. --- tests/integration/managed-record-capabilities.test.ts | 7 ++++++- tests/integration/sharing-account-access.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/integration/managed-record-capabilities.test.ts b/tests/integration/managed-record-capabilities.test.ts index 5939af474..66458e9ff 100644 --- a/tests/integration/managed-record-capabilities.test.ts +++ b/tests/integration/managed-record-capabilities.test.ts @@ -111,7 +111,12 @@ function jsonRequest(url: string, body: unknown): NextRequest { async function postMood() { const { POST } = await import("@/app/api/mood-entries/route"); - return POST(jsonRequest("/api/mood-entries", { mood: "GUT" })); + return POST( + jsonRequest("/api/mood-entries", { + mood: "GUT", + moodLoggedAt: new Date().toISOString(), + }), + ); } async function postAllergy() { diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts index 3eca07f02..204e2e4de 100644 --- a/tests/integration/sharing-account-access.test.ts +++ b/tests/integration/sharing-account-access.test.ts @@ -81,6 +81,8 @@ interface AccountAccessEntry { recordKind: "shared" | "managed"; sections: string[] | null; canWrite: boolean; + writableDomains: string[]; + manageableDomains: string[]; } interface AccountAccessBlock { @@ -183,6 +185,8 @@ describe("accountAccess — what the payload publishes", () => { recordKind: "shared", sections: null, canWrite: false, + writableDomains: [], + manageableDomains: [], }, ]); expect(accountAccess.canSwitch).toBe(true); @@ -342,6 +346,8 @@ describe("accountAccess — the active record", () => { recordKind: "shared", sections: null, canWrite: false, + writableDomains: [], + manageableDomains: [], }); // The active entry is one of the listed ones, by construction. A banner // that had to join the two could render unnamed when they disagreed. From 915bd63a4a500c358cc9de037b5bfd121d15f32a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 20:55:06 +0200 Subject: [PATCH 6/8] =?UTF-8?q?chore(release):=20v1.38.13=20=E2=80=94=20a?= =?UTF-8?q?=20guardian=20sees=20the=20controls=20the=20routes=20accept?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version anchors, the OpenAPI document and the changelog entry. Reported in #939. --- public/sw.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/sw.js b/public/sw.js index 2630a9bf1..bfb9d7a72 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.11"; + /* @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/*` From 40b9427259a7c9e8ed33486b027806b4f654146e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 21:38:16 +0200 Subject: [PATCH 7/8] chore(deps): let the newer hono and js-yaml bounds win pnpm-workspace.yaml carried two overrides for the same packages: an older floor (hono < 4.12.25, js-yaml >= 4.0.0 < 4.3.1) and the newer one added for the scanner findings. The older selector won the resolution, so the lockfile kept hono 4.13.3 and js-yaml 4.3.1 and four of the seventeen findings stayed open after v1.38.12. The stale bounds are gone; the lockfile now resolves hono 4.13.7 and js-yaml 4.3.2. --- pnpm-lock.yaml | 36 +++++++++++++++++------------------- pnpm-workspace.yaml | 4 ---- 2 files changed, 17 insertions(+), 23 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbef6f00b..461af96d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,13 +13,11 @@ overrides: postcss@<8.5.23: ^8.5.23 nanoid@<3.3.18: ^3.3.18 '@hono/node-server@<1.19.15': ^1.19.15 - hono@<4.12.25: ^4.12.25 picomatch@<4.0.4: ^4.0.4 body-parser@<2.3.0: ^2.3.0 qs@<6.16.0: ^6.16.0 fast-uri@<3.1.6: ^3.1.6 flatted@<3.4.2: ^3.4.2 - js-yaml@>=4.0.0 <4.3.1: ^4.3.1 mysql2@<3.22.0: ^3.22.0 minimatch@<3.1.4: ^3.1.4 ip-address@<10.3.1: ^10.3.1 @@ -699,7 +697,7 @@ packages: resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4.12.25 + hono: ^4.13.5 '@hookform/resolvers@5.9.1': resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==} @@ -4464,8 +4462,8 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hono@4.13.3: - resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -4790,8 +4788,8 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.1: - resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} hasBin: true jsesc@3.1.0: @@ -7411,7 +7409,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -7464,9 +7462,9 @@ snapshots: '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.19.17(hono@4.13.3)': + '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: - hono: 4.13.3 + hono: 4.13.7 '@hookform/resolvers@5.9.1(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(effect@3.20.0)(react-hook-form@7.83.0(react@19.2.7))(valibot@1.4.2(typescript@6.0.3))(zod@4.4.3)': dependencies: @@ -7677,7 +7675,7 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.3) + '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7687,7 +7685,7 @@ snapshots: eventsource-parser: 3.1.1 express: 5.2.1(supports-color@7.2.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) - hono: 4.13.3 + hono: 4.13.7 jose: 6.2.10 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -7701,7 +7699,7 @@ snapshots: '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.4.3)': dependencies: - '@hono/node-server': 1.19.17(hono@4.13.3) + '@hono/node-server': 1.19.17(hono@4.13.7) ajv: 8.20.0 ajv-formats: 3.0.1(ajv@8.20.0) content-type: 1.0.5 @@ -7711,7 +7709,7 @@ snapshots: eventsource-parser: 3.1.1 express: 5.2.1(supports-color@7.2.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) - hono: 4.13.3 + hono: 4.13.7 jose: 6.2.10 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 @@ -8245,13 +8243,13 @@ snapshots: '@electric-sql/pglite': 0.4.1 '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) - '@hono/node-server': 1.19.17(hono@4.13.3) + '@hono/node-server': 1.19.17(hono@4.13.7) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 '@prisma/streams-local': 0.1.2 foreground-child: 3.3.1 get-port-please: 3.2.0 - hono: 4.13.3 + hono: 4.13.7 http-status-codes: 2.3.0 pathe: 2.0.3 proper-lockfile: 4.1.2 @@ -10239,7 +10237,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.3.1 + js-yaml: 4.3.2 parse-json: 5.2.0 optionalDependencies: typescript: 6.0.3 @@ -11215,7 +11213,7 @@ snapshots: dependencies: hermes-estree: 0.25.1 - hono@4.13.3: {} + hono@4.13.7: {} html-escaper@2.0.2: {} @@ -11498,7 +11496,7 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.1: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dafb166e4..9284664c0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,7 +24,6 @@ overrides: # because the advisory is fixed there too and 2.x is a major nothing asked # for. "@hono/node-server@<1.19.15": "^1.19.15" - "hono@<4.12.25": "^4.12.25" "picomatch@<4.0.4": "^4.0.4" "body-parser@<2.3.0": "^2.3.0" # Array-limit bypass through bracket-key comma parsing, and a denial of @@ -36,9 +35,6 @@ overrides: # copy, so it sits on a path this app exposes. "fast-uri@<3.1.6": "^3.1.6" "flatted@<3.4.2": "^3.4.2" - # The advisory covers 4.x only; the bound keeps a hypothetical 3.x consumer - # off a major it never asked for. - "js-yaml@>=4.0.0 <4.3.1": "^4.3.1" # GHSA-3f6p-5ww8-9rcr: auth-plugin downgrade leaking plaintext credentials. # Reached only through prisma's MySQL driver, which this app never opens; # pinned so the audit reads clean, and mirrored for /opt/prisma-cli in the From 8a3499d0f9c003f7a2850453cc41e05a7d45f211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 9 Sep 2026 21:38:42 +0200 Subject: [PATCH 8/8] chore(release): add the v1.38.13 changelog entry The release commit carried the version anchors and the OpenAPI document but not the changelog entry; it is added here. --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08914f206..1be66997d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## [1.38.13] — 2026-09-09 + +A guardian acting on a managed profile gets the add, edit and delete +controls the server has accepted since v1.37, decided per domain and +published by the server. Reported by @sreeramachandramurthy in #939. + +### Fixed + +- **Managed profiles: the controls the routes accept are shown again.** A + person acting on a managed profile, or on a record shared with them at the + manage level, saw no way to add or change anything on mood, wellbeing + check-ins, visits, allergies, family history, labs and more, although the + server accepted those writes. The client hard-wired `canManage` to false for + every shared record, a hold-back from the release that introduced managed + profiles that was never lifted. It is lifted per domain: a table records + which share domains accept delegated writes at `write` and at `manage`, + frozen by a structural test that reads every API route and fails on drift; + every entry in the account access list on `/api/auth/me` now carries + `writableDomains` and `manageableDomains` (additive, existing clients + ignore them); and every surface asks for its own domain. Documents cannot + be delegated at any level, so the documents page tells a guardian that only + the record owner can add documents instead of showing an empty slot. Module + switches per managed profile, editing a profile after creation and asking + for sex at creation are still open in #939 and follow as one piece. + ## [1.38.12] — 2026-09-09 A dependency release: the image and the lockfile stop carrying twelve