Files
HealthLog/src/lib/sharing/grant-view.ts
T
Marc-André Bombeck ab14fdb6f3 Let an invitation name its sections and offer management
An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.

The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.

Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.

The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.

The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.

The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.

Six locales.
2026-08-05 23:37:00 +02:00

115 lines
4.3 KiB
TypeScript

/**
* v1.36.0 — how a grant row reaches a client.
*
* One shape for every sharing route, because five endpoints each shaping the
* same row their own way is five chances for the panel to render "active" on a
* row the resolver has already stopped honouring.
*
* `state` is the important field and it is not stored anywhere: it comes from
* `grantState()`, the same function the resolver's own predicate is built on.
* A serialiser that derived it here from `revokedAt`/`acceptedAt` would be the
* second decider this feature spends most of its design avoiding — and it
* would drift in the dangerous direction, showing a grant as ended while the
* resolver still admitted it, or the reverse.
*
* What is NOT published: the other party's e-mail. The owner typed an
* identifier to invite somebody and does not need it echoed; the delegate
* never asked for the owner's. Handing every party to a grant a durable
* address for every other party is reach the grant was never meant to confer.
*/
import {
grantState,
normaliseScope,
type GrantState,
} from "@/lib/sharing/grants";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import type { AccountGrant } from "@/generated/prisma/client";
import type { Prisma } from "@/generated/prisma/client";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
id: string;
username: string;
displayName: string | null;
}
/**
* v1.37.0 — the stored scope as a client can read it.
*
* `null` is the entire record and stays null all the way to the screen: it is
* the answer every grant carries unless somebody narrowed one, and flattening
* it into "all eight sections" would turn a first-class value into a list that
* looks like a choice nobody made.
*
* The order is {@link SHARE_DOMAINS}' own, which is the consent screen's
* reading order — not the order the keys happened to be stored in. Two grants
* over the same sections therefore serialise identically, so a diff of two
* payloads means what it looks like.
*
* Everything else about the value is decided by `normaliseScope`, including
* the fail-closed reading of a blob this build cannot parse: that resolves to
* the empty set, and an empty array is published rather than papered over.
* `[]` on the wire says "this grant opens nothing", which is exactly what the
* resolver will tell the delegate on their next request, and a client that
* rendered it as "everything" would be the second program deciding somebody's
* access.
*
* The one place a stored scope becomes a published list, so the grant panel
* and the account payload cannot disagree about what a grant opens.
*/
export function resolveGrantSections(
scopeJson: Prisma.JsonValue | null,
): ShareDomain[] | null {
const scope = normaliseScope(scopeJson);
if (scope === null) return null;
return SHARE_DOMAINS.filter((domain) => scope.has(domain));
}
export interface GrantView {
id: string;
/** The account on the other end: the delegate on a given grant, the owner on a received one. */
account: GrantParty;
access: AccountGrant["access"];
/**
* Which sections of the record the grant opens, or null for the whole of
* it. Published so the delegate can read what they are accepting BEFORE the
* accept button, and so the owner's panel can say what each live grant
* reaches without opening it.
*/
scope: ShareDomain[] | null;
state: GrantState;
invitedAt: string;
acceptedAt: string | null;
expiresAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
revokedBy: AccountGrant["revokedBy"];
}
/** The columns a party needs, so a caller can `select` exactly them. */
export const GRANT_PARTY_SELECT = {
id: true,
username: true,
displayName: true,
} as const;
export function toGrantView(
grant: AccountGrant,
account: GrantParty,
now: Date = new Date(),
): GrantView {
return {
id: grant.id,
account,
access: grant.access,
scope: resolveGrantSections(grant.scopeJson),
state: grantState(grant, now),
invitedAt: grant.invitedAt.toISOString(),
acceptedAt: grant.acceptedAt?.toISOString() ?? null,
expiresAt: grant.expiresAt?.toISOString() ?? null,
lastUsedAt: grant.lastUsedAt?.toISOString() ?? null,
revokedAt: grant.revokedAt?.toISOString() ?? null,
revokedBy: grant.revokedBy,
};
}