Files
HealthLog/src/lib/sharing/grant-view.ts
T
Marc-André Bombeck 78ac2c5aeb refactor(sharing): read and judge grants from a leaf module
lib/sharing/grants.ts held both the grant transitions and the predicates
every reader asks (state, level, scope, the live grant for a pair). The
transitions reach into the managed-profile lifecycle and the OAuth connect
target, and both of those read a live grant, so grants, lifecycle and
connect-target formed an import cycle.

The predicates, findActiveGrant and touchGrantUsage move verbatim to
lib/sharing/grant-access.ts, which imports only the database client and
the scope vocabulary. Importers and the mocks that replaced those
functions point at the new module. Runtime import cycles fall from 7 to 6.
2026-10-23 11:02:10 +02:00

122 lines
4.6 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/grant-access";
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,
};
}
/**
* The 404 both invitation routes answer when no account carries the typed
* name or e-mail. Its own code, because each route has a second 404 that
* needs a different next step.
*/
export const INVITE_NO_ACCOUNT_CODE = "sharing.invite.no_account";