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