Files
HealthLog/src/lib/sharing/account-access-view.ts
T
Marc-André Bombeck 4410a496cc docs(sharing): say that the writable-section list is existential, not per verb
`writableDomains` answers "is there at least one delegated write route in this
section this grant satisfies". Its docblock promised a per-section answer the
table cannot give: within a section the split runs by verb, and three of the
four sections marked writable carry MANAGE-only creates beside their WRITE ones
— an allergy and a family-history entry under `profile`, a water entry and a
checkup reminder under `measurements`, a snooze and a re-recorded dose under
`medications`.

No shipped control is wrong because of it: the allergy and family-history forms
already ask the manage question, and so does the water entry. But the name and
the sentence beside it invite a control to read "my section is writable,
therefore my create is", and that reading is wrong in three sections out of
four.

Documentation only, in the three places the claim is made — the published
field, the function that derives it, and the guard that holds it against the
routes, whose green run now says what it does not prove. A per-verb table would
be a second place the route declarations live, and the declarations are what
decides; the control keeps asking the hook for its own verb class.
2026-09-10 11:16:55 +02:00

191 lines
8.8 KiB
TypeScript

/**
* v1.36.0 — the shape of the sharing block, and nothing else.
*
* Types only, with no VALUE imports, because both ends read them: the route
* that builds the block (`account-access.ts`, which talks to Postgres) and the
* client that renders it. Declaring them beside the server code would put a
* module that imports Prisma on the client's import graph — erased at compile
* today, one careless value export away from being bundled tomorrow. The
* boundary is cheaper to keep than to rediscover.
*
* The one import below is `import type` from `sharing/scope.ts`, which is a
* closed list of string literals and imports nothing itself. It is erased
* whole, so the property above still holds: nothing this file names can pull
* a server module into a bundle. A value import from anywhere would break it.
*
* What these types are FOR is stated once here and holds everywhere they are
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
* the server has already made; a client that recomputed either from the rest
* of the payload would be the second program deciding one person's access.
*/
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";
export type SharedAccountRecordKind = Exclude<AccountRecordKind, "self">;
/**
* 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. */
accountId: string;
username: string;
displayName: string | null;
/**
* v1.37.2 — the record owner's full name, when they have set one. Shown in
* front of a delegate on the switcher and the banner, ahead of the greeting
* name, so the person whose record they are entering reads as who they are
* rather than by a nickname. See {@link accountLabel} for the deliberate
* disclosure this carries; it is a maintainer decision, not an oversight.
*/
fullName: string | null;
/**
* The grant's level, as the owner offered it and the caller accepted it.
*
* The same value as {@link AccountAccessEntry.level}, under the name the
* v1.36.0 contract published and shipped clients already read. Both are
* assigned from one expression in `account-access.ts`, so they cannot come
* apart; `level` is the name to write new code against.
*/
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: SharedAccountRecordKind;
/**
* v1.37.0 — which sections of the record this grant opens, or `null` for
* the entire record.
*
* `null` is a first-class answer rather than a missing one: it is what every
* grant written before this release means, what an owner who does not narrow
* still gets, and what a MANAGE grant always carries. Render it as "the
* entire record", never as a legacy state.
*
* An EMPTY array is a real answer too, and it means the grant opens nothing:
* the fail-closed reading of a stored scope this build cannot parse. Render
* that as nothing. The server will refuse every section for that grant on
* the next request, and agreeing with it is the only honest option.
*
* Resolved server-side, in the consent screen's own reading order.
*/
sections: ShareDomain[] | null;
/**
* The grant's LEVEL, resolved server-side: is it at or above WRITE.
*
* It is not "may this caller add something", and reading it that way is how
* section-blind controls happen. There is no scope term in it: a WRITE grant
* scoped to the document vault answers `true` here with
* `writableDomains: []`, because the vault takes no delegated write at any
* level. Ask {@link AccountAccessEntry.writableDomains} for the section the
* control belongs to; this boolean answers only whether the grant is a
* reading one.
*
* It does not by itself mean edit or delete — a WRITE grant adds and does
* nothing more, and that stayed true when a third level arrived. A MANAGE
* grant answers true here as well, and what it additionally admits is said
* by `level` rather than smuggled into this boolean.
*/
canWrite: boolean;
/**
* v1.38.12 — the sections with AT LEAST ONE delegated write route this
* grant satisfies, resolved server-side from its level and scope against
* the 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.
*
* Existential, and the word matters: within a section the answer varies by
* verb, and this list cannot say which. `profile` is writable because a
* visit, a practitioner and a dose answer at WRITE, while an allergy and a
* family-history entry are MANAGE creates; `measurements` is writable
* because a reading is, while a water entry and a checkup reminder are
* MANAGE. So a control does not read "my section is writable, therefore my
* create is": it asks the hook for its own verb class —
* `canWriteDomain` for a create the WRITE level covers, `canManageDomain`
* for one it does not. A per-verb table would be a second place the route
* declarations live, and the declarations are the thing that decides.
*
* 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`. */
export interface AccountAccess {
/** Every account this caller may open, newest grant first. */
accounts: AccountAccessEntry[];
/**
* The record this browser is inside right now, resolved to a full entry —
* not an id the client has to look up. Null when it is in its own record,
* 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;
}
/**
* The name to put in front of a person, given what the payload published.
*
* `fullName` when it is set, `displayName` next, the username last. All three
* are published; which reads best is the client's business, not a derivation
* of anything the server decided. Kept here so the banner, the switcher and
* the sharing panel cannot disagree about what to call somebody.
*
* `fullName` is optional on the argument on purpose: only the account-access
* entry carries it, so the switcher and the banner prefer it while the
* grant-view and actor shapes that never had it keep falling back to the
* greeting name exactly as before.
*
* v1.37.2 — the record owner's full name is shown to anyone they share with,
* read-only included; maintainer decision 2026-08-08. It is patient-identity
* data that until now stayed behind a MANAGE-gated export, so a future reader
* should not "tighten" this back as a leak: it is the intended behaviour, not
* an accident of the label falling through to it.
*/
export function accountLabel(entry: {
displayName: string | null;
username: string;
fullName?: string | null;
}): string {
const full = entry.fullName?.trim();
if (full && full.length > 0) return full;
const named = entry.displayName?.trim();
return named && named.length > 0 ? named : entry.username;
}