diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 896cdae75..464b9e4b3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -36712,12 +36712,20 @@ components: banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one." + recordKind: + type: string + enum: + - self + - shared + - managed + description: The server-resolved kind of the record currently in view. `self` applies when `active` is null. canSwitch: type: boolean description: Whether there is anywhere to switch to. Bind it directly. required: - accounts - active + - recordKind - canSwitch additionalProperties: false description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server @@ -36743,9 +36751,8 @@ components: enum: - read - write - - manage - description: The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one - server-side expression — and kept because shipped clients read it. New code reads `level`. + description: The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads + canonical `level`. level: type: string enum: @@ -36755,6 +36762,13 @@ components: description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`. + recordKind: + type: string + enum: + - self + - shared + - managed + description: Server-resolved presentation metadata for the record. It is never accepted as an authorization input. sections: anyOf: - type: array @@ -36778,6 +36792,7 @@ components: - displayName - access - level + - recordKind - sections - canWrite additionalProperties: false diff --git a/messages/de.json b/messages/de.json index 9a2f19e82..388c99a38 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9520,6 +9520,8 @@ "description": "Akten, die andere mit dir geteilt haben.", "levelRead": "Darf lesen", "levelWrite": "Darf lesen und hinzufügen", + "kindShared": "Geteilte Akte", + "kindManaged": "Verwaltetes Profil", "open": "Akte von {name} öffnen" }, "toast": { diff --git a/messages/en.json b/messages/en.json index e30323d2d..48f404d69 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9520,6 +9520,8 @@ "description": "Records other people have shared with you.", "levelRead": "Can view", "levelWrite": "Can view and add", + "kindShared": "Shared record", + "kindManaged": "Managed profile", "open": "Open {name}'s record" }, "toast": { diff --git a/messages/es.json b/messages/es.json index eb7ba6b3f..bbd66c8fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9520,6 +9520,8 @@ "description": "Historiales que otras personas han compartido contigo.", "levelRead": "Puede ver", "levelWrite": "Puede ver y añadir", + "kindShared": "Registro compartido", + "kindManaged": "Perfil administrado", "open": "Abrir el historial de {name}" }, "toast": { diff --git a/messages/fr.json b/messages/fr.json index 13e690d84..9f68a3ef5 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9520,6 +9520,8 @@ "description": "Les dossiers que d'autres personnes partagent avec vous.", "levelRead": "Peut consulter", "levelWrite": "Peut consulter et ajouter", + "kindShared": "Dossier partagé", + "kindManaged": "Profil géré", "open": "Ouvrir le dossier de {name}" }, "toast": { diff --git a/messages/it.json b/messages/it.json index aec50ff84..c37fdb228 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9520,6 +9520,8 @@ "description": "Cartelle che altre persone hanno condiviso con te.", "levelRead": "Può consultare", "levelWrite": "Può consultare e aggiungere", + "kindShared": "Cartella condivisa", + "kindManaged": "Profilo gestito", "open": "Apri la cartella di {name}" }, "toast": { diff --git a/messages/pl.json b/messages/pl.json index dba4b4487..e97a5d6ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9520,6 +9520,8 @@ "description": "Karty, które udostępniły ci inne osoby.", "levelRead": "Może przeglądać", "levelWrite": "Może przeglądać i dodawać", + "kindShared": "Udostępniona karta", + "kindManaged": "Zarządzany profil", "open": "Otwórz kartę użytkownika {name}" }, "toast": { diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index e4c2fda78..f19b838f7 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -80,6 +80,19 @@ const NOT_A_CONSUMER = new Set([ join(SRC, "app", "api", "auth", "me", "route.ts"), ]); +const ACCOUNT_ENTRY_CONSUMER = join( + SRC, + "components", + "dashboard", + "looking-after-card.tsx", +); +const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts"); +const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [ + "level", + "sections", + "recordKind", +] 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 @@ -192,4 +205,24 @@ describe("account payload consumer guard", () => { expect(reason.length).toBeGreaterThan(20); } }); + + it("has non-zero entry and active-record readers for additive access fields", () => { + const entrySource = stripComments( + readFileSync(ACCOUNT_ENTRY_CONSUMER, "utf8"), + ); + const activeSource = stripComments( + readFileSync(ACTIVE_RECORD_CONSUMER, "utf8"), + ); + + const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce( + (count, field) => { + expect(entrySource).toMatch(new RegExp(`entry\\.${field}(?![\\w$])`)); + expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`)); + return count + 2; + }, + 0, + ); + + expect(readerCount).toBeGreaterThan(0); + }); }); 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 5c3694151..1454d53b5 100644 --- a/src/app/medications/__tests__/take-all-due-affordance.test.tsx +++ b/src/app/medications/__tests__/take-all-due-affordance.test.tsx @@ -56,6 +56,7 @@ const OWNER = { access: "read" as const, level: "read" as const, sections: null, + recordKind: "shared" as const, canWrite: false, }; diff --git a/src/components/__tests__/delegated-write-affordances.test.tsx b/src/components/__tests__/delegated-write-affordances.test.tsx index f5a786503..524605f27 100644 --- a/src/components/__tests__/delegated-write-affordances.test.tsx +++ b/src/components/__tests__/delegated-write-affordances.test.tsx @@ -39,6 +39,7 @@ const OWNER = { access: "read" as const, level: "read" as const, sections: null, + recordKind: "shared" as const, canWrite: false, }; diff --git a/src/components/charts/__tests__/chart-overlay-controls.test.tsx b/src/components/charts/__tests__/chart-overlay-controls.test.tsx index 15bc0838c..8eca455f6 100644 --- a/src/components/charts/__tests__/chart-overlay-controls.test.tsx +++ b/src/components/charts/__tests__/chart-overlay-controls.test.tsx @@ -10,6 +10,7 @@ const OWNER = { access: "write" as const, level: "write" as const, sections: null, + recordKind: "shared" as const, canWrite: true, }; diff --git a/src/components/dashboard/__tests__/looking-after-card.test.tsx b/src/components/dashboard/__tests__/looking-after-card.test.tsx index 2358a02d1..9cfe7e1f2 100644 --- a/src/components/dashboard/__tests__/looking-after-card.test.tsx +++ b/src/components/dashboard/__tests__/looking-after-card.test.tsx @@ -27,6 +27,7 @@ const GRANDMOTHER = { access: "read" as const, level: "read" as const, sections: null, + recordKind: "shared" as const, canWrite: false, }; const FATHER = { @@ -36,6 +37,7 @@ const FATHER = { access: "write" as const, level: "write" as const, sections: null, + recordKind: "shared" as const, canWrite: true, }; diff --git a/src/components/dashboard/looking-after-card.tsx b/src/components/dashboard/looking-after-card.tsx index 704e69433..c95d6f94b 100644 --- a/src/components/dashboard/looking-after-card.tsx +++ b/src/components/dashboard/looking-after-card.tsx @@ -97,6 +97,12 @@ export function LookingAfterCard() { {levelLabel(entry, t)} + + {recordKindLabel(entry, t)} + + + {scopeLabel(entry, t)} + {switchAccount.isPending && ( string, ): string { + if (entry.level === "manage") { + return t("recordSharing.row.canManage"); + } return entry.canWrite ? t("recordSharing.lookingAfter.levelWrite") : t("recordSharing.lookingAfter.levelRead"); } + +function recordKindLabel( + entry: AccountAccessEntry, + t: (key: string) => string, +): string { + return entry.recordKind === "managed" + ? t("recordSharing.lookingAfter.kindManaged") + : t("recordSharing.lookingAfter.kindShared"); +} + +function scopeLabel( + entry: AccountAccessEntry, + t: (key: string) => string, +): string { + if (entry.sections === null) { + return t("recordSharing.row.entireRecord"); + } + if (entry.sections.length === 0) { + return t("recordSharing.row.noSections"); + } + return entry.sections + .map((domain) => t(`recordSharing.section.${domain}.label`)) + .join(", "); +} 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 d5f5166ac..7e05b31b5 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 @@ -117,6 +117,7 @@ const OWNER_READ: AccountAccessEntry = { access: "read" as const, level: "read" as const, sections: null, + recordKind: "shared" as const, canWrite: false, }; const OWNER_WRITE: AccountAccessEntry = { diff --git a/src/components/layout/__tests__/shared-record-chrome.test.tsx b/src/components/layout/__tests__/shared-record-chrome.test.tsx index eff5e8653..a6c8a9651 100644 --- a/src/components/layout/__tests__/shared-record-chrome.test.tsx +++ b/src/components/layout/__tests__/shared-record-chrome.test.tsx @@ -59,6 +59,7 @@ const OWNER = { access: "read" as const, level: "read" as const, sections: null, + recordKind: "shared" as const, canWrite: false, }; diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index ed6aac0d0..90dc83abc 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -27,6 +27,7 @@ const READ_ONLY: AccountAccessEntry = { access: "read", level: "read", sections: null, + recordKind: "shared", canWrite: false, }; @@ -39,7 +40,7 @@ const WRITABLE: AccountAccessEntry = { const MANAGING: AccountAccessEntry = { ...READ_ONLY, - access: "manage", + access: "write", level: "manage", canWrite: true, }; @@ -64,6 +65,7 @@ describe("resolveRecordCapabilities", () => { // apart from a real one. level: null, sections: null, + recordKind: "self", }); expect(resolveRecordCapabilities(undefined)).toEqual( resolveRecordCapabilities(null), @@ -78,6 +80,7 @@ describe("resolveRecordCapabilities", () => { canManage: false, level: "read", sections: null, + recordKind: "shared", }); }); @@ -91,6 +94,7 @@ describe("resolveRecordCapabilities", () => { canManage: false, level: "write", sections: null, + recordKind: "shared", }); }); @@ -121,6 +125,7 @@ describe("resolveRecordCapabilities", () => { canManage: false, level: "manage", sections: null, + recordKind: "shared", }); }); diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts index 39927b406..53cd921f7 100644 --- a/src/hooks/use-auth.ts +++ b/src/hooks/use-auth.ts @@ -34,6 +34,7 @@ import type { AccountAccess } from "@/lib/sharing/account-access-view"; export const NO_ACCOUNT_ACCESS: AccountAccess = { accounts: [], active: null, + recordKind: "self", canSwitch: false, }; @@ -347,6 +348,12 @@ function coerceAccountAccess(value: unknown): AccountAccess { return { accounts: block.accounts, active: block.active ?? null, + recordKind: + block.recordKind === "self" || + block.recordKind === "shared" || + block.recordKind === "managed" + ? block.recordKind + : "self", canSwitch: block.canSwitch === true, }; } diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index b9370eb2a..f0d2ecb7c 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -98,6 +98,8 @@ export interface RecordCapabilities { * nothing; it is a real answer and reads as one. */ sections: AccountAccessEntry["sections"]; + /** The server-resolved kind of the record currently in view. */ + recordKind: AccountAccessEntry["recordKind"] | "self"; } /** @@ -122,6 +124,7 @@ export function resolveRecordCapabilities( // to tell those apart would have to guess. level: null, sections: null, + recordKind: "self", }; } return { @@ -131,6 +134,7 @@ export function resolveRecordCapabilities( canManage: false, level: active.level, sections: active.sections, + recordKind: active.recordKind, }; } diff --git a/src/lib/insights/__tests__/coach-launch-context.test.tsx b/src/lib/insights/__tests__/coach-launch-context.test.tsx index 391d8e14a..b294d8ad7 100644 --- a/src/lib/insights/__tests__/coach-launch-context.test.tsx +++ b/src/lib/insights/__tests__/coach-launch-context.test.tsx @@ -32,6 +32,7 @@ const OWNER = { access: "write" as const, level: "write" as const, sections: null, + recordKind: "shared" as const, canWrite: true, }; diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index 3d1a08f23..ecba5c64d 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -220,15 +220,20 @@ const accountAccessEntry = z username: z.string(), displayName: z.string().nullable(), access: z - .enum(["read", "write", "manage"]) + .enum(["read", "write"]) .describe( - "The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one server-side expression — and kept because shipped clients read it. New code reads `level`.", + "The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads canonical `level`.", ), level: z .enum(["read", "write", "manage"]) .describe( "The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.", ), + recordKind: z + .enum(["self", "shared", "managed"]) + .describe( + "Server-resolved presentation metadata for the record. It is never accepted as an authorization input.", + ), sections: z .array(shareSection) .nullable() @@ -257,6 +262,11 @@ export const accountAccessBlock = z .describe( "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a banner can name the person without joining two fields. Null when the caller is in their own record, and ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses one.", ), + recordKind: z + .enum(["self", "shared", "managed"]) + .describe( + "The server-resolved kind of the record currently in view. `self` applies when `active` is null.", + ), canSwitch: z .boolean() .describe("Whether there is anywhere to switch to. Bind it directly."), diff --git a/src/lib/sharing/__tests__/account-access.test.ts b/src/lib/sharing/__tests__/account-access.test.ts new file mode 100644 index 000000000..a1b89c734 --- /dev/null +++ b/src/lib/sharing/__tests__/account-access.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + accountGrant: { + findMany: vi.fn(), + }, + }, +})); + +import { prisma } from "@/lib/db"; +import { resolveAccountAccess } from "../account-access"; +import { decodeLegacyWholeRecordAccess } from "../account-access-view"; +import { LEGACY_ACCOUNT_PAYLOADS } from "../../../../tests/fixtures/v137/legacy-account-payloads"; + +function activeGrant({ + access, + managedProfileAt = null, +}: { + access: "READ" | "WRITE" | "MANAGE"; + managedProfileAt?: Date | null; +}) { + return { + id: `grant-${access.toLowerCase()}`, + access, + scopeJson: null, + acceptedAt: new Date("2026-08-01T00:00:00.000Z"), + revokedAt: null, + expiresAt: null, + grantor: { + id: "record-owner", + username: "record-owner", + displayName: "Record owner", + managedProfileAt, + }, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("resolveAccountAccess", () => { + it("keeps MANAGE compatible with legacy access while publishing its canonical level", async () => { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ + access: "MANAGE", + managedProfileAt: new Date("2026-08-01T00:00:00.000Z"), + }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: { actingAsUserId: "record-owner" }, + }); + + expect(access.accounts).toEqual([ + expect.objectContaining({ + access: "write", + level: "manage", + sections: null, + canWrite: true, + recordKind: "managed", + }), + ]); + expect(access.active).toEqual( + expect.objectContaining({ + level: "manage", + sections: null, + recordKind: "managed", + }), + ); + expect(access.recordKind).toBe("managed"); + }); + + it("resolves an ordinary shared record on the server", async () => { + vi.mocked(prisma.accountGrant.findMany).mockResolvedValue([ + activeGrant({ access: "READ" }), + ] as never); + + const access = await resolveAccountAccess({ + user: { id: "delegate" }, + session: {}, + }); + + expect(access.accounts[0]).toEqual( + expect.objectContaining({ + access: "read", + level: "read", + recordKind: "shared", + }), + ); + expect(access.recordKind).toBe("self"); + }); + + it("keeps whole-record legacy payloads decodable and scoped payloads closed", () => { + for (const fixture of LEGACY_ACCOUNT_PAYLOADS) { + const decoded = decodeLegacyWholeRecordAccess(fixture); + expect(decoded === null ? "deny" : "allow", fixture.name).toBe( + fixture.legacyDecoder, + ); + if (fixture.legacyDecoder === "allow") { + expect(decoded).toBe(fixture.access); + } + } + }); +}); diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 167ea0cd4..d2caada5a 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -23,6 +23,26 @@ import type { ShareDomain } from "@/lib/sharing/scope"; /** What a grant lets its holder do, resolved. */ export type AccountAccessLevel = "read" | "write" | "manage"; +/** The two values published by the original account-access contract. */ +export type LegacyAccountAccessLevel = "read" | "write"; + +/** The server-resolved kind of record a presentation is naming. */ +export type AccountRecordKind = "self" | "shared" | "managed"; + +/** + * Read the portion of an account-access entry that an older whole-record + * client understands. A scoped entry is refused rather than guessed at. + */ +export function decodeLegacyWholeRecordAccess( + value: unknown, +): LegacyAccountAccessLevel | null { + if (value === null || typeof value !== "object") return null; + const entry = value as { access?: unknown; sections?: unknown }; + if (entry.access !== "read" && entry.access !== "write") return null; + if (entry.sections !== undefined && entry.sections !== null) return null; + return entry.access; +} + /** One account this caller may act on. */ export interface AccountAccessEntry { /** The account whose record it is — the value `POST /api/account/switch` takes. */ @@ -37,13 +57,18 @@ export interface AccountAccessEntry { * assigned from one expression in `account-access.ts`, so they cannot come * apart; `level` is the name to write new code against. */ - access: AccountAccessLevel; + access: LegacyAccountAccessLevel; /** * v1.37.0 — the same resolved level, under the name the three-level * contract speaks. Read it; never derive it from `canWrite`, and never * derive `canWrite` from it. */ level: AccountAccessLevel; + /** + * What kind of record this is. This is presentation metadata only; it is + * resolved by the server and never accepted as an authorization input. + */ + recordKind: AccountRecordKind; /** * v1.37.0 — which sections of the record this grant opens, or `null` for * the entire record. @@ -81,6 +106,8 @@ export interface AccountAccess { * and always null on the Bearer transport. */ active: AccountAccessEntry | null; + /** The server-resolved kind of the record currently in view. */ + recordKind?: AccountRecordKind; /** Is there anywhere to switch to. */ canSwitch: boolean; } diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index a60894680..019e80da0 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -104,24 +104,32 @@ export async function resolveAccountAccess(auth: { where: { granteeId: actorId, revokedAt: null }, orderBy: { createdAt: "desc" }, include: { - grantor: { select: { id: true, username: true, displayName: true } }, + grantor: { + select: { + id: true, + username: true, + displayName: true, + managedProfileAt: true, + }, + }, }, }); const accounts: AccountAccessEntry[] = rows .filter((grant) => isGrantActive(grant, now)) .map((grant) => { - // One expression, two names. `access` is what v1.36.0 published and - // what shipped clients read; `level` is what the three-level contract - // calls it. Deriving both from one lookup is what stops them saying - // different things about the same row. + // `access` is the shipped two-level field, so MANAGE remains write for + // clients that have not learned the canonical level. `level` names the + // complete three-level contract. const level = LEVEL[grant.access]; return { accountId: grant.grantor.id, username: grant.grantor.username, displayName: grant.grantor.displayName, - access: level, + access: level === "read" ? "read" : "write", level, + recordKind: + grant.grantor.managedProfileAt === null ? "shared" : "managed", sections: resolveGrantSections(grant.scopeJson), canWrite: grantAllows(grant, "write", now), }; @@ -132,5 +140,10 @@ export async function resolveAccountAccess(auth: { ? null : (accounts.find((a) => a.accountId === stamped) ?? null); - return { accounts, active, canSwitch: accounts.length > 0 }; + return { + accounts, + active, + recordKind: active?.recordKind ?? "self", + canSwitch: accounts.length > 0, + }; }