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] 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"), }; });