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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-09 19:42:26 +02:00
1 parent 54f0030ead
commit b5c392636f
14 files changed
+319 -2

No files matched your search

+56
View File
@@ -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:
@@ -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$])`));
}
});
});
@@ -59,6 +59,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
const OWN_RECORD: AccountAccess = {
@@ -13,6 +13,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: true,
writableDomains: [],
manageableDomains: [],
};
const OWN_RECORD: AccountAccess = {
@@ -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(
@@ -62,6 +62,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
function render(access: AccountAccess, node: React.ReactNode): string {
@@ -37,6 +37,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/lib/i18n/context";
import { ApiError } from "@/lib/api/api-fetch";
import type { AccountAccessEntry } from "@/lib/sharing/account-access-view";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
import type { GrantList } from "@/lib/queries/use-account-grants";
import type { ManagedProfileGuardian } from "@/lib/queries/use-managed-profiles";
@@ -219,6 +220,8 @@ function entry(partial: Partial<AccountAccessEntry> = {}): AccountAccessEntry {
recordKind: "managed",
sections: null,
canWrite: true,
writableDomains: delegatedDomains("manage", null, "write"),
manageableDomains: delegatedDomains("manage", null, "manage"),
...partial,
};
}
@@ -37,6 +37,8 @@ const canonicalEntry = {
recordKind: "shared",
sections: ["labs"],
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
beforeEach(() => {
@@ -35,6 +35,8 @@ const OWNER = {
sections: null,
recordKind: "shared" as const,
canWrite: true,
writableDomains: [],
manageableDomains: [],
};
const mockAccessRef: { value: AccountAccess } = {
@@ -12,6 +12,8 @@ const sharedEntry = {
recordKind: "shared",
sections: ["labs"],
canWrite: false,
writableDomains: [],
manageableDomains: [],
};
function accessBlock(overrides: Record<string, unknown> = {}) {
@@ -45,6 +47,8 @@ describe("parseAccountAccess", () => {
level: "manage",
sections: null,
canWrite: true,
writableDomains: ["measurements", "mind"],
manageableDomains: ["mind"],
};
const parsed = parseAccountAccess(
@@ -56,9 +60,28 @@ describe("parseAccountAccess", () => {
access: "write",
sections: null,
canWrite: true,
writableDomains: ["measurements", "mind"],
manageableDomains: ["mind"],
});
});
it("keeps the delegated write lists when they are consistent with the grant", () => {
const writer = {
...sharedEntry,
access: "write",
level: "write",
sections: ["labs", "measurements"],
canWrite: true,
writableDomains: ["measurements", "labs"],
manageableDomains: [],
};
const parsed = parseAccountAccess(
accessBlock({ accounts: [writer], active: writer }),
);
expect(parsed?.active?.writableDomains).toEqual(["measurements", "labs"]);
expect(parsed?.active?.manageableDomains).toEqual([]);
});
it.each([
[
"legacy access and resolved level",
@@ -79,6 +102,28 @@ describe("parseAccountAccess", () => {
// field. A parity matcher blind to it would let the banner and the
// switcher name the same owner differently.
["full name", { fullName: "Different name" }, {}],
// v1.38.12 — the two lists are read by every control, so the two views
// must agree on them like on every other field.
[
"writable domains",
{
access: "write",
level: "write",
canWrite: true,
writableDomains: ["labs"],
},
{
accounts: [
{
...sharedEntry,
access: "write",
level: "write",
canWrite: true,
writableDomains: [],
},
],
},
],
])(
"fails closed when active disagrees with its canonical entry on %s",
(_field, activeOverrides, blockOverrides) => {
@@ -105,6 +150,51 @@ describe("parseAccountAccess", () => {
"manage with read legacy field",
{ access: "read", level: "manage", sections: null, canWrite: true },
],
// v1.38.12 — structural invariants of the two lists. Which sections
// qualify is the server's table and is not re-decided here; what IS
// checked is that the lists cannot claim more than the grant.
["read grant with a writable domain", { writableDomains: ["labs"] }],
[
"write grant with a manageable domain",
{
access: "write",
level: "write",
canWrite: true,
writableDomains: ["labs"],
manageableDomains: ["labs"],
},
],
[
"manageable domain that is not writable",
{
access: "write",
level: "manage",
sections: null,
canWrite: true,
writableDomains: ["labs"],
manageableDomains: ["mind"],
},
],
[
"writable domain outside the grant's sections",
{
access: "write",
level: "write",
canWrite: true,
writableDomains: ["measurements"],
},
],
[
"duplicated writable domain",
{
access: "write",
level: "write",
canWrite: true,
writableDomains: ["labs", "labs"],
},
],
["unknown writable domain", { writableDomains: ["other"] }],
["missing writable domains", { writableDomains: undefined }],
])("fails closed for %s entries", (_name, entryOverrides) => {
const entry = { ...sharedEntry, ...entryOverrides };
@@ -10,20 +10,23 @@ vi.mock("@/lib/db", () => ({
import { prisma } from "@/lib/db";
import { resolveAccountAccess } from "../account-access";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
import { decodeLegacyWholeRecordAccess } from "../account-access-view";
import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads";
function activeGrant({
access,
managedProfileAt = null,
scopeJson = null,
}: {
access: "READ" | "WRITE" | "MANAGE";
managedProfileAt?: Date | null;
scopeJson?: string[] | null;
}) {
return {
id: `grant-${access.toLowerCase()}`,
access,
scopeJson: null,
scopeJson,
acceptedAt: new Date("2026-08-01T00:00:00.000Z"),
revokedAt: null,
expiresAt: null,
@@ -69,11 +72,47 @@ describe("resolveAccountAccess", () => {
level: "manage",
sections: null,
recordKind: "managed",
// v1.38.12 — the routes' answer for a whole-record MANAGE grant: every
// section with a delegated write route, and never the vault.
writableDomains: delegatedDomains("manage", null, "write"),
manageableDomains: delegatedDomains("manage", null, "manage"),
}),
);
expect(access.active?.manageableDomains).toContain("mind");
expect(access.active?.manageableDomains).not.toContain("documents");
expect(access.recordKind).toBe("managed");
});
it("publishes a WRITE grant's writable sections within its scope and no manageable ones", async () => {
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
activeGrant({ access: "WRITE", scopeJson: ["mind", "labs"] }),
] as never);
const access = await resolveAccountAccess({
user: { id: "delegate" },
session: { actingAsUserId: "record-owner" },
});
// `mind` is opened by the scope but has no WRITE route, so it is absent;
// `labs` has one. Consent order, not scope order.
expect(access.active?.writableDomains).toEqual(["labs"]);
expect(access.active?.manageableDomains).toEqual([]);
});
it("publishes two empty lists for a READ grant", async () => {
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
activeGrant({ access: "READ" }),
] as never);
const access = await resolveAccountAccess({
user: { id: "delegate" },
session: { actingAsUserId: "record-owner" },
});
expect(access.active?.writableDomains).toEqual([]);
expect(access.active?.manageableDomains).toEqual([]);
});
it("resolves an ordinary shared record on the server", async () => {
vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([
activeGrant({ access: "READ" }),
+66
View File
@@ -6,6 +6,20 @@ import { SHARE_DOMAINS } from "./scope";
const accountAccessLevelSchema = z.enum(["read", "write", "manage"]);
const accountRecordKindSchema = z.enum(["shared", "managed"]);
const shareSectionsSchema = z.array(z.enum(SHARE_DOMAINS)).nullable();
const shareDomainListSchema = z.array(z.enum(SHARE_DOMAINS));
function hasRepeat(domains: readonly string[]): boolean {
return new Set(domains).size !== domains.length;
}
function isSubset(
inner: readonly string[],
outer: readonly string[] | null,
): boolean {
if (outer === null) return true;
const set = new Set(outer);
return inner.every((domain) => set.has(domain));
}
/** The browser's resolved account-access payload, shared with OpenAPI. */
export const accountAccessEntrySchema = z
@@ -22,6 +36,11 @@ export const accountAccessEntrySchema = z
recordKind: accountRecordKindSchema,
sections: shareSectionsSchema,
canWrite: z.boolean(),
// v1.38.12 — the sections the grant can add to / change, resolved
// server-side. Structural invariants only are checked here; which
// sections qualify is the server's table and is not re-decided.
writableDomains: shareDomainListSchema,
manageableDomains: shareDomainListSchema,
})
.superRefine((entry, ctx) => {
const uniqueSections = new Set(entry.sections ?? []);
@@ -56,6 +75,48 @@ export const accountAccessEntrySchema = z
path: ["access"],
});
}
if (
hasRepeat(entry.writableDomains) ||
hasRepeat(entry.manageableDomains)
) {
ctx.addIssue({
code: "custom",
message: "Delegated write domains must not repeat a domain",
path: ["writableDomains"],
});
}
if (
!isSubset(entry.writableDomains, entry.sections) ||
!isSubset(entry.manageableDomains, entry.sections)
) {
ctx.addIssue({
code: "custom",
message: "Delegated write domains must lie within the grant's sections",
path: ["writableDomains"],
});
}
if (!isSubset(entry.manageableDomains, entry.writableDomains)) {
ctx.addIssue({
code: "custom",
message: "Manageable domains must be writable domains",
path: ["manageableDomains"],
});
}
if (entry.level === "read" && entry.writableDomains.length > 0) {
ctx.addIssue({
code: "custom",
message: "A read grant has no writable domains",
path: ["writableDomains"],
});
}
if (!isManage && entry.manageableDomains.length > 0) {
ctx.addIssue({
code: "custom",
message: "Only a manage grant has manageable domains",
path: ["manageableDomains"],
});
}
});
export const accountAccessBlockSchema = z.object({
@@ -80,7 +141,12 @@ function matchesCanonicalAccountAccessEntry(
(section, index) => section === canonicalSections[index],
);
const sameList = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && a.every((domain, index) => domain === b[index]);
return (
sameList(active.writableDomains, canonical.writableDomains) &&
sameList(active.manageableDomains, canonical.manageableDomains) &&
active.accountId === canonical.accountId &&
active.username === canonical.username &&
active.displayName === canonical.displayName &&
+18
View File
@@ -103,6 +103,24 @@ export interface AccountAccessEntry {
* than smuggled into this boolean.
*/
canWrite: boolean;
/**
* v1.38.12 — the sections this grant can add to, resolved server-side from
* the grant's level and scope against the delegated write routes that
* exist (`domain-write-support.ts`). In the consent screen's order. Empty
* for a READ grant, and empty for any section whose routes accept no
* delegated write at all — the vault, today.
*
* A list rather than a boolean because the answer differs per section, and
* the client's per-control question is "this section", never "any".
*/
writableDomains: ShareDomain[];
/**
* v1.38.12 — the sections this grant can change: edit, delete, restore,
* bulk-act, and the creates a WRITE grant does not cover. A subset of
* {@link AccountAccessEntry.writableDomains} by construction, and empty
* below MANAGE.
*/
manageableDomains: ShareDomain[];
}
/** The `accountAccess` block on `GET /api/auth/me`. */
+9 -1
View File
@@ -42,6 +42,7 @@ import type {
AccountAccessEntry,
AccountAccessLevel,
} from "@/lib/sharing/account-access-view";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
import { resolveGrantSections } from "@/lib/sharing/grant-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
import type { AccountGrantAccess } from "@/generated/prisma/client";
@@ -126,6 +127,7 @@ export async function resolveAccountAccess(auth: {
// clients that have not learned the canonical level. `level` names the
// complete three-level contract.
const level = LEVEL[grant.access];
const sections = resolveGrantSections(grant.scopeJson);
return {
accountId: grant.grantor.id,
username: grant.grantor.username,
@@ -135,8 +137,14 @@ export async function resolveAccountAccess(auth: {
level,
recordKind:
grant.grantor.managedProfileAt === null ? "shared" : "managed",
sections: resolveGrantSections(grant.scopeJson),
sections,
canWrite: grantAllows(grant, "write", now),
// v1.38.12 — per-section write answers, from the same level and scope
// the two fields above publish, intersected with the routes that
// exist. The lists are what the controls read; the level and the
// sections stay published so the chrome can say what was granted.
writableDomains: delegatedDomains(level, sections, "write"),
manageableDomains: delegatedDomains(level, sections, "manage"),
};
});