mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The lifecycle surface under /api/account. An owner offers read access to somebody already on the instance, the delegate accepts, either side ends it, and a browser session points itself at a record it has been granted. Ending a grant clears the acting-account carrier on every session of the delegate pointing at that record, in the same transaction that stamps the row. Split apart, a revocation would leave a browser inside a record it can no longer read: every request refused, the banner still naming the owner, nothing on screen able to explain it. Revocation has no step-up and no confirmation step. Reducing access must never be harder than granting it was. Grant management is non-delegable by construction rather than by a check: every route here resolves through bare requireAuth(), which refuses while the caller is acting on another account, so no code path through them runs as somebody else. The switch endpoint is the one actor surface, and it is the way back out. A delegate already inside a record renounces after switching out, one request, rather than the fail-closed default growing an exception that later gets widened. The state machine is not re-implemented anywhere: routes call the grant module's predicate and its transitions, and the session carrier has one writer.
69 lines
2.4 KiB
TypeScript
69 lines
2.4 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, type GrantState } from "@/lib/sharing/grants";
|
|
import type { AccountGrant } 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;
|
|
}
|
|
|
|
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"];
|
|
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,
|
|
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,
|
|
};
|
|
}
|