Files
HealthLog/src/lib/sharing/account-access-schema.ts
T
Marc-André Bombeck e33700c7a9 feat(sharing): name a record's owner in full to whoever they share with
The switcher and the banner named a shared record's owner by their greeting
name — the value they typed at onboarding for "how should HealthLog address
you". When an owner has set a full name, that is what a delegate should see:
entering somebody's record, you read whose it is by who they are.

- the account-access resolver publishes the owner's `fullName` alongside the
  greeting name and the username
- `accountLabel` prefers fullName, then displayName, then username. Optional
  on the argument so the grant-view and actor shapes that never carried it
  keep the old two-field behaviour untouched
- the closed presentation schema carries `fullName` and its parity matcher
  compares it, so the two published views of the active record cannot name
  the same owner differently
- the OpenAPI contract regenerates with the additive field

This deliberately discloses the owner's full name to every delegate, read-only
included — it was patient-identity data behind a MANAGE-gated export until now.
That is the intended behaviour, documented at `accountLabel` so it is not
mistaken for a leak and tightened back. The resolver, the schema parity, the
label fallback and a read-only delegate's banner are each pinned by a test.
2026-08-08 16:07:25 +02:00

132 lines
4.2 KiB
TypeScript

import { z } from "zod/v4";
import type { AccountAccess } from "./account-access-view";
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();
/** The browser's resolved account-access payload, shared with OpenAPI. */
export const accountAccessEntrySchema = z
.object({
accountId: z.string().min(1),
username: z.string().min(1),
displayName: z.string().nullable(),
// v1.37.2 — the record owner's full name, shown in front of a delegate on
// the switcher + banner. A deliberate disclosure to everyone they share
// with; see `accountLabel`.
fullName: z.string().nullable(),
access: z.enum(["read", "write"]),
level: accountAccessLevelSchema,
recordKind: accountRecordKindSchema,
sections: shareSectionsSchema,
canWrite: z.boolean(),
})
.superRefine((entry, ctx) => {
const uniqueSections = new Set(entry.sections ?? []);
if (uniqueSections.size !== (entry.sections?.length ?? 0)) {
ctx.addIssue({
code: "custom",
message: "Account access sections must not repeat a domain",
path: ["sections"],
});
}
const isManage = entry.level === "manage";
if (isManage && (entry.access !== "write" || entry.sections !== null)) {
ctx.addIssue({
code: "custom",
message: "Manage access must use legacy write access and whole scope",
});
}
if (entry.canWrite !== (entry.level !== "read")) {
ctx.addIssue({
code: "custom",
message: "Account access write flag disagrees with its level",
path: ["canWrite"],
});
}
if (!isManage && entry.access !== entry.level) {
ctx.addIssue({
code: "custom",
message: "Legacy access must agree with the resolved level",
path: ["access"],
});
}
});
export const accountAccessBlockSchema = z.object({
accounts: z.array(accountAccessEntrySchema),
active: accountAccessEntrySchema.nullable(),
recordKind: z.enum(["self", "shared", "managed"]),
canSwitch: z.boolean(),
});
/** Both published views of the active record must name the same resolved grant. */
function matchesCanonicalAccountAccessEntry(
active: z.infer<typeof accountAccessEntrySchema>,
canonical: z.infer<typeof accountAccessEntrySchema>,
): boolean {
const activeSections = active.sections;
const canonicalSections = canonical.sections;
const sameSections =
activeSections === null || canonicalSections === null
? activeSections === canonicalSections
: activeSections.length === canonicalSections.length &&
activeSections.every(
(section, index) => section === canonicalSections[index],
);
return (
active.accountId === canonical.accountId &&
active.username === canonical.username &&
active.displayName === canonical.displayName &&
active.fullName === canonical.fullName &&
active.access === canonical.access &&
active.level === canonical.level &&
active.recordKind === canonical.recordKind &&
active.canWrite === canonical.canWrite &&
sameSections
);
}
/** Parse the server-resolved access block as a closed presentation contract. */
export function parseAccountAccess(value: unknown): AccountAccess | null {
const parsed = accountAccessBlockSchema.safeParse(value);
if (!parsed.success) return null;
const { accounts, active, recordKind, canSwitch } = parsed.data;
const accountIds = new Set(accounts.map((entry) => entry.accountId));
if (
accountIds.size !== accounts.length ||
canSwitch !== accounts.length > 0
) {
return null;
}
if (active === null) {
return recordKind === "self"
? { accounts, active: null, recordKind, canSwitch }
: null;
}
if (!accountIds.has(active.accountId) || recordKind !== active.recordKind) {
return null;
}
const canonicalActive = accounts.find(
(entry) => entry.accountId === active.accountId,
);
if (
!canonicalActive ||
!matchesCanonicalAccountAccessEntry(active, canonicalActive)
) {
return null;
}
return { accounts, active: canonicalActive, recordKind, canSwitch };
}