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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 11:02:10 +02:00
1 parent e84b80ee3c
commit 78ac2c5aeb
27 files changed
+315 -296

No files matched your search

+1 -6
View File
@@ -1,6 +1,6 @@
{
"$comment": "Runtime import cycles under src/: the back-edge count and the strongly connected components. Maintained by src/__tests__/import-cycle-guard.test.ts.",
"cycles": 7,
"cycles": 6,
"components": [
[
"components/command-palette/command-palette.tsx",
@@ -18,11 +18,6 @@
"lib/medications/scheduling/compliance.ts",
"lib/medications/scheduling/schedule-eras.ts"
],
[
"lib/integrations/oauth-kit/connect-target.ts",
"lib/managed-profiles/lifecycle.ts",
"lib/sharing/grants.ts"
],
[
"lib/cycle/gate.ts",
"lib/modules/gate.ts"
@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 120,
"total": 141792,
"total": 141553,
"entries": {
"app/api/dashboard/summary/route.ts": 1032,
"app/api/documents/inbound/route.ts": 811,
@@ -115,7 +115,7 @@
"lib/polar/client.ts": 1010,
"lib/rollups/measurement-rollups.ts": 1496,
"lib/search/search-records.ts": 996,
"lib/sharing/grants.ts": 1046,
"lib/sharing/grants.ts": 807,
"lib/support/report.ts": 905,
"lib/telegram-webhook-handlers.ts": 1341,
"lib/validations/inbound-documents.ts": 1130,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2954,
"total": 10481,
"total": 10480,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -2819,7 +2819,7 @@
"lib/sharing/audit-details.ts": 1,
"lib/sharing/delegated-generation.ts": 1,
"lib/sharing/grant-view.ts": 2,
"lib/sharing/grants.ts": 4,
"lib/sharing/grants.ts": 3,
"lib/sharing/module-disclosure.ts": 1,
"lib/sharing/record-session-fence-contract.ts": 1,
"lib/sharing/record-session-fence.ts": 1,
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grants";
import { grantCoversDomain, normaliseScope } from "@/lib/sharing/grant-access";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
+1 -1
View File
@@ -69,7 +69,7 @@ import {
pointSessionAt,
pointSessionAtIfUnchanged,
} from "@/lib/sharing/acting-session";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { switchAccountSchema } from "@/lib/validations/account-sharing";
export const POST = apiHandler(async (request: NextRequest) => {
+1 -1
View File
@@ -38,7 +38,7 @@ import {
} from "@/lib/auth/session";
import { lookupIpLocation } from "@/lib/geo";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { isGrantActive } from "@/lib/sharing/grants";
import { isGrantActive } from "@/lib/sharing/grant-access";
import { coarseDeviceLabel, maskIp } from "@/lib/auth/device-fingerprint";
export const dynamic = "force-dynamic";
+2 -2
View File
@@ -54,13 +54,13 @@ import {
} from "../idempotency";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
vi.mock("@/lib/sharing/grants", () => ({
vi.mock("@/lib/sharing/grant-access", () => ({
findActiveGrant: vi.fn(),
}));
import { createHash } from "node:crypto";
import { headers } from "next/headers";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
/** A live WRITE grant, whole record, as `delegatedGrantFacet` reads it. */
const GRANT = { id: "grant-1", access: "WRITE", scopeJson: null };
@@ -31,7 +31,7 @@ vi.mock("@/lib/auth/audit", () => ({
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/sharing/grants", () => ({
vi.mock("@/lib/sharing/grant-access", () => ({
findActiveGrant: vi.fn(),
grantAllows: vi.fn(() => true),
grantCoversDomain: vi.fn(() => true),
@@ -60,7 +60,7 @@ import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { auditLog } from "@/lib/auth/audit";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { ACCOUNT_SELECTOR_HEADER } from "@/lib/auth/acting-carrier";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
@@ -12,8 +12,8 @@ let auth: Record<string, unknown> | undefined;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
}));
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
vi.mock("@/lib/sharing/grant-access", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grant-access")>()),
findActiveGrant: vi.fn(),
}));
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
@@ -9,14 +9,14 @@ let auth: Record<string, unknown> | undefined;
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }),
}));
vi.mock("@/lib/sharing/grants", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grants")>()),
vi.mock("@/lib/sharing/grant-access", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/sharing/grant-access")>()),
findActiveGrant: vi.fn(),
}));
vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() }));
vi.mock("@/lib/db", () => ({ prisma: {} }));
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { MODULE_KEYS, type ModuleKey } from "@/lib/modules/registry";
import type { ModuleAccessState } from "@/lib/sharing/module-disclosure";
+1 -1
View File
@@ -32,7 +32,7 @@
* consent_required → `consent.ai.required`
* check_failed → `ai.unavailable`; 503
*/
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { resolveGrantSections } from "@/lib/sharing/grant-view";
import { getEvent } from "@/lib/logging/context";
import { providerWorkAuthorityForRecord } from "@/lib/sharing/provider-work-authority";
+1 -1
View File
@@ -15,7 +15,7 @@ import {
grantCoversDomain,
touchGrantUsage,
type GrantNeed,
} from "@/lib/sharing/grants";
} from "@/lib/sharing/grant-access";
import type { ShareScope } from "@/lib/sharing/scope";
import { assertRecordSessionFence } from "@/lib/sharing/record-session-fence";
import {
+1 -1
View File
@@ -38,7 +38,7 @@ import { refuseStaleRecordSessionClaim } from "@/lib/sharing/record-session-fenc
import { hashToken } from "@/lib/auth/hmac";
import { annotate } from "@/lib/logging/context";
import { isP2002 } from "@/lib/prisma-errors";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import { decrypt, encrypt } from "@/lib/crypto";
import { createHash } from "node:crypto";
import { looksSecretShaped } from "@/lib/secret-shape";
@@ -9,7 +9,7 @@ const { findUnique, update, findActiveGrant, auditLog } = vi.hoisted(() => ({
vi.mock("@/lib/db", () => ({ prisma: { user: { findUnique, update } } }));
vi.mock("@/lib/auth/audit", () => ({ auditLog }));
vi.mock("@/lib/sharing/grants", () => ({
vi.mock("@/lib/sharing/grant-access", () => ({
findActiveGrant,
grantAllows: (
grant: { access: string; revokedAt: Date | null },
@@ -19,7 +19,7 @@
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
import { auditLog } from "@/lib/auth/audit";
import { findActiveGrant, grantAllows } from "@/lib/sharing/grants";
import { findActiveGrant, grantAllows } from "@/lib/sharing/grant-access";
import type { OAuthKitProvider } from "./token";
export type CallbackActorVerdict =
+1 -1
View File
@@ -1,6 +1,6 @@
import { prisma } from "@/lib/db";
import { activeGuardianWhere } from "@/lib/managed-profiles/lifecycle";
import { grantState } from "@/lib/sharing/grants";
import { grantState } from "@/lib/sharing/grant-access";
import { GRANT_PARTY_SELECT, type GrantParty } from "@/lib/sharing/grant-view";
/**
+1 -1
View File
@@ -51,10 +51,10 @@ import {
type HandoverAccess,
} from "@/lib/managed-profiles/handover-access";
import {
isGrantActive,
settleHandoverGuardianGrant,
withdrawPendingGrantsOfRecord,
} from "@/lib/sharing/grants";
import { isGrantActive } from "@/lib/sharing/grant-access";
import { isP2002 } from "@/lib/prisma-errors";
import type { Prisma } from "@/generated/prisma/client";
+2 -2
View File
@@ -13,15 +13,15 @@
*/
import { describe, expect, it } from "vitest";
import { inviteGrant } from "@/lib/sharing/grants";
import {
grantAllows,
grantCoversDomain,
grantState,
inviteGrant,
isGrantActive,
normaliseScope,
type GrantLifecycle,
} from "@/lib/sharing/grants";
} from "@/lib/sharing/grant-access";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import { Prisma } from "@/generated/prisma/client";
import type { AccountGrant } from "@/generated/prisma/client";
+1 -1
View File
@@ -44,7 +44,7 @@ import type {
} from "@/lib/sharing/account-access-view";
import { delegatedDomains } from "@/lib/sharing/domain-write-support";
import { resolveGrantSections } from "@/lib/sharing/grant-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
import { grantAllows, isGrantActive } from "@/lib/sharing/grant-access";
import type { AccountGrantAccess } from "@/generated/prisma/client";
/**
+1 -1
View File
@@ -17,7 +17,7 @@
*/
import type { Prisma } from "@/generated/prisma/client";
import { grantCoversDomain } from "@/lib/sharing/grants";
import { grantCoversDomain } from "@/lib/sharing/grant-access";
import type { ShareScope } from "@/lib/sharing/scope";
export async function actingDomainVisibility(
+267
View File
@@ -0,0 +1,267 @@
/**
* What an account grant MEANS at a given instant: its state, whether it
* permits a need, whether it covers a section of the record, and the live
* grant for a pair. Pure predicates plus one read and one fire-and-forget
* stamp, and nothing that changes a grant.
*
* The transitions that DO change a grant (invite, accept, revoke, renounce,
* the managed-profile handover) are in `./grants.ts` and decide through the
* predicates here, so the resolver and the CRUD surface cannot carry two
* ideas of "active". They live apart because the transitions reach into the
* managed-profile lifecycle and the OAuth connect target, and those two read
* a live grant themselves: with the predicates in the same module, every
* reader of a grant was part of one import cycle.
*/
import { prisma } from "@/lib/db";
import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope";
import type { ShareDomain, ShareScope } from "@/lib/sharing/scope";
import type { Prisma } from "@/generated/prisma/client";
import type {
AccountGrant,
AccountGrantAccess,
} from "@/generated/prisma/client";
/** The slice of a grant row the pure predicates read. */
export interface GrantLifecycle {
acceptedAt: Date | null;
revokedAt: Date | null;
expiresAt: Date | null;
}
/**
* What a grant currently is.
*
* Ordering matters and is stated rather than left to the reader: a revoked
* grant reads REVOKED even if it also sat past its expiry, because somebody
* ended it and the record should say so. An invitation past its expiry reads
* EXPIRED rather than PENDING, because it can no longer be accepted. Only
* ACTIVE confers anything.
*/
export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE";
/** What a caller needs the grant to permit. */
export type GrantNeed = "read" | "write" | "manage";
/**
* The narrow client slice these functions need, so a caller can pass a
* transaction handle. Same shape as `issue-token.ts`.
*/
export type GrantDb = Pick<
Prisma.TransactionClient,
"accountGrant" | "auditLog"
>;
// ── The state machine, pure ─────────────────────────────────────────────────
/** What this grant is, at `now`. */
export function grantState(
grant: GrantLifecycle,
now: Date = new Date(),
): GrantState {
if (grant.revokedAt !== null) return "REVOKED";
// `<=` matches the Bearer-token expiry comparison: the instant named is the
// first instant the grant no longer holds.
if (grant.expiresAt !== null && grant.expiresAt <= now) return "EXPIRED";
if (grant.acceptedAt === null) return "PENDING";
return "ACTIVE";
}
/**
* Does this grant confer anything right now?
*
* The one predicate the resolver asks. Deliberately derived from
* {@link grantState} rather than re-listing the conditions, so a fourth
* terminal state can never be added without this answering for it.
*/
export function isGrantActive(
grant: GrantLifecycle,
now: Date = new Date(),
): boolean {
return grantState(grant, now) === "ACTIVE";
}
/**
* The three levels, ordered. Higher reaches everything lower.
*
* A total order rather than a set of capabilities per level, because that is
* what the consent screen says out loud: view, view and add, manage. Written
* as two tables rather than as a chain of comparisons so that a fourth level
* cannot be added without deciding where it sits — an unlisted key does not
* typecheck, and the exhaustiveness is the point of the `Record` type.
*/
const ACCESS_RANK: Record<AccountGrantAccess, number> = {
READ: 0,
WRITE: 1,
MANAGE: 2,
};
const NEED_RANK: Record<GrantNeed, number> = {
read: 0,
write: 1,
manage: 2,
};
/**
* Does this grant permit what the caller is about to do?
*
* Active first, then the level. A READ grant permits reads only; a WRITE grant
* permits both, because write access to a record without read access describes
* nothing anyone asked for; a MANAGE grant permits all three, because managing
* a record it cannot read or add to describes nothing either. The state
* outranks the level in both directions: a revoked MANAGE grant permits
* nothing at all, and no level survives an expiry.
*
* The comparison is `>=` on the ranks above and not equality, and the
* difference matters in exactly one direction: a WRITE grant must never
* satisfy `"manage"`. That is the whole of the third level's meaning — an
* accepted WRITE grant carries a consent to add, and nothing about it carries
* a consent to rewrite or remove.
*/
export function grantAllows(
grant: GrantLifecycle & { access: AccountGrantAccess },
need: GrantNeed,
now: Date = new Date(),
): boolean {
if (!isGrantActive(grant, now)) return false;
return ACCESS_RANK[grant.access] >= NEED_RANK[need];
}
// ── Scope ───────────────────────────────────────────────────────────────────
/** The slice of a grant row the scope predicate reads. */
export interface GrantScope {
scopeJson: Prisma.JsonValue | null;
}
/**
* What a stored scope actually opens.
*
* `null` — the whole record. Not a default standing in for a missing answer:
* it is the answer every grant written before the column existed was consented
* as, and the answer an owner who ticks "entire record" gives today. It is a
* first-class value everywhere, never a legacy badge.
*
* A set — those sections and nothing else, `record` included (see
* {@link grantCoversDomain}).
*/
export type ResolvedScope = ReadonlySet<ShareDomain> | null;
const EMPTY_SCOPE: ReadonlySet<ShareDomain> = new Set<ShareDomain>();
/**
* Turn the stored blob into what it opens, refusing anything it cannot read.
*
* Fail-closed, and this is the deliberate inverse of `normalisePrefs` in the
* module gate: that one resolves a malformed preference blob to "everything
* on", because a presentation preference nobody can read should not hide a
* person's own data from them. This one answers an authorization question, so
* a value it cannot read resolves to the EMPTY set — every section refused,
* including any the garbage might have happened to name. Both directions are
* "do the safe thing with a value we do not understand"; they only look
* opposite because the safe thing differs.
*
* What counts as unreadable is deliberately wide: anything that is not an
* array, an empty array, an array holding a key outside the vocabulary, an
* array holding a non-string. There is no partial credit — a set that is half
* recognisable is a set somebody wrote in a shape this file does not
* understand, and honouring the half we recognise would be guessing at
* consent. `record` is not a member of the vocabulary, so a stored `["record"]`
* is unreadable too, which is the correct reading: a scope that means "no
* scope" is a thing the invite path refuses to write, and one that reached
* the column by any other route opens nothing.
*/
export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope {
if (stored === null || stored === undefined) return null;
if (!Array.isArray(stored) || stored.length === 0) return EMPTY_SCOPE;
const domains = new Set<ShareDomain>();
for (const entry of stored) {
if (!isShareDomain(entry)) return EMPTY_SCOPE;
// A scope is a subset, not merely an array of recognisable values. A
// duplicated key is malformed rather than a second consent to the same
// domain, so it receives no partial credit.
if (domains.has(entry)) return EMPTY_SCOPE;
domains.add(entry);
}
return domains;
}
/**
* Does this grant open the section the route declared?
*
* Three rules, and the second is the one that carries the invariant:
*
* * A NULL scope covers everything, `record` included. That is what every
* grant in the product was until this release and what most will stay.
* * A non-NULL scope NEVER covers `record`. A route that declares `record`
* reads across sections — a health score, a digest, an achievement set —
* and there is no honest way to answer one of those for a delegate who was
* given part of a record. A score that says 70 to the owner and 64 to the
* delegate is a support case and a clinical hazard, so the scoped delegate
* does not get a filtered answer, they get no answer.
* * Otherwise, membership. A key that postdates the invitation is not in the
* stored set and so is not covered, which is why a new section is a
* consent question rather than a schema question.
*
* The level is a separate question, asked by {@link grantAllows}. This one
* knows nothing about READ, WRITE or MANAGE — and MANAGE rows carry a NULL
* scope by construction, so it answers true for them by the first rule rather
* than by a special case.
*/
export function grantCoversDomain(
grant: GrantScope,
domain: ShareScope,
): boolean {
const scope = normaliseScope(grant.scopeJson);
if (scope === null) return true;
if (domain === ENTIRE_RECORD) return false;
return scope.has(domain);
}
// ── Resolution ──────────────────────────────────────────────────────────────
/**
* The grant that lets `granteeId` act on `grantorId`'s record right now, or
* null.
*
* The query narrows on `revokedAt: null` only — which the partial unique index
* makes at most one row — and every other condition is decided by
* {@link isGrantActive}. That split is deliberate: a SQL predicate spelling out
* "accepted and not expired" beside a TypeScript predicate spelling out the
* same thing is two places deciding one question, and the copy that drifts is
* the one nobody reads. Here the database narrows and the state machine
* decides.
*/
export async function findActiveGrant(
pair: { grantorId: string; granteeId: string },
db: GrantDb = prisma,
now: Date = new Date(),
): Promise<AccountGrant | null> {
const live = await db.accountGrant.findFirst({
where: {
grantorId: pair.grantorId,
granteeId: pair.granteeId,
revokedAt: null,
},
});
if (!live) return null;
return isGrantActive(live, now) ? live : null;
}
/**
* Stamp `lastUsedAt` on a grant that was just exercised.
*
* Fire-and-forget, the `ApiToken.lastUsedAt` posture (`bearer.ts`): the request
* that triggered it must not wait, and a failure to record the use must not
* fail the read. The promise is returned so a test can await it; production
* callers deliberately do not.
*/
export function touchGrantUsage(
grantId: string,
db: GrantDb = prisma,
): Promise<void> {
return db.accountGrant
.update({ where: { id: grantId }, data: { lastUsedAt: new Date() } })
.then(() => undefined)
.catch(() => undefined);
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
grantState,
normaliseScope,
type GrantState,
} from "@/lib/sharing/grants";
} 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";
+9 -248
View File
@@ -1,14 +1,16 @@
/**
* v1.36.0 — the account-grant state machine. One file, one set of rules.
* The account-grant state machine: the transitions here, the predicates they
* decide by (state, level, scope, the live grant for a pair) in
* `./grant-access.ts`. Two modules, one set of rules.
*
* A grant is one account's standing permission to open another account's
* record — to read it, and where the grant says so to add to it. The row is
* also the durable record of the consent that created it (there is no second
* receipt table — see the model docblock in `prisma/schema.prisma`).
* Everything that decides what a grant currently MEANS lives here, and nothing
* else in the tree is allowed to decide it.
* Everything that decides what a grant currently MEANS lives in these two
* modules, and nothing else in the tree is allowed to decide it.
*
* Why one file rather than the rules at the call sites: two consumers read
* Why one place rather than the rules at the call sites: two consumers read
* these rows for different reasons — the acting-account resolver on every
* request, and the grant CRUD surface when a person invites, accepts, revokes
* or renounces. If each carried its own idea of "active", the two would drift,
@@ -67,35 +69,15 @@ import {
grantAccessFor,
type HandoverAccess,
} from "@/lib/managed-profiles/handover-access";
import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope";
import type { ShareDomain, ShareScope } from "@/lib/sharing/scope";
import { isShareDomain } from "@/lib/sharing/scope";
import type { ShareDomain } from "@/lib/sharing/scope";
import { grantState, type GrantDb } from "@/lib/sharing/grant-access";
import { Prisma } from "@/generated/prisma/client";
import type {
AccountGrant,
AccountGrantAccess,
} from "@/generated/prisma/client";
/** The slice of a grant row the pure predicates read. */
export interface GrantLifecycle {
acceptedAt: Date | null;
revokedAt: Date | null;
expiresAt: Date | null;
}
/**
* What a grant currently is.
*
* Ordering matters and is stated rather than left to the reader: a revoked
* grant reads REVOKED even if it also sat past its expiry, because somebody
* ended it and the record should say so. An invitation past its expiry reads
* EXPIRED rather than PENDING, because it can no longer be accepted. Only
* ACTIVE confers anything.
*/
export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE";
/** What a caller needs the grant to permit. */
export type GrantNeed = "read" | "write" | "manage";
export type GrantErrorCode =
/** An account cannot share its record with itself. */
| "self_grant"
@@ -134,179 +116,6 @@ export class GrantError extends Error {
}
}
/**
* The narrow client slice these functions need, so a caller can pass a
* transaction handle. Same shape as `issue-token.ts`.
*/
type GrantDb = Pick<Prisma.TransactionClient, "accountGrant" | "auditLog">;
// ── The state machine, pure ─────────────────────────────────────────────────
/** What this grant is, at `now`. */
export function grantState(
grant: GrantLifecycle,
now: Date = new Date(),
): GrantState {
if (grant.revokedAt !== null) return "REVOKED";
// `<=` matches the Bearer-token expiry comparison: the instant named is the
// first instant the grant no longer holds.
if (grant.expiresAt !== null && grant.expiresAt <= now) return "EXPIRED";
if (grant.acceptedAt === null) return "PENDING";
return "ACTIVE";
}
/**
* Does this grant confer anything right now?
*
* The one predicate the resolver asks. Deliberately derived from
* {@link grantState} rather than re-listing the conditions, so a fourth
* terminal state can never be added without this answering for it.
*/
export function isGrantActive(
grant: GrantLifecycle,
now: Date = new Date(),
): boolean {
return grantState(grant, now) === "ACTIVE";
}
/**
* The three levels, ordered. Higher reaches everything lower.
*
* A total order rather than a set of capabilities per level, because that is
* what the consent screen says out loud: view, view and add, manage. Written
* as two tables rather than as a chain of comparisons so that a fourth level
* cannot be added without deciding where it sits — an unlisted key does not
* typecheck, and the exhaustiveness is the point of the `Record` type.
*/
const ACCESS_RANK: Record<AccountGrantAccess, number> = {
READ: 0,
WRITE: 1,
MANAGE: 2,
};
const NEED_RANK: Record<GrantNeed, number> = {
read: 0,
write: 1,
manage: 2,
};
/**
* Does this grant permit what the caller is about to do?
*
* Active first, then the level. A READ grant permits reads only; a WRITE grant
* permits both, because write access to a record without read access describes
* nothing anyone asked for; a MANAGE grant permits all three, because managing
* a record it cannot read or add to describes nothing either. The state
* outranks the level in both directions: a revoked MANAGE grant permits
* nothing at all, and no level survives an expiry.
*
* The comparison is `>=` on the ranks above and not equality, and the
* difference matters in exactly one direction: a WRITE grant must never
* satisfy `"manage"`. That is the whole of the third level's meaning — an
* accepted WRITE grant carries a consent to add, and nothing about it carries
* a consent to rewrite or remove.
*/
export function grantAllows(
grant: GrantLifecycle & { access: AccountGrantAccess },
need: GrantNeed,
now: Date = new Date(),
): boolean {
if (!isGrantActive(grant, now)) return false;
return ACCESS_RANK[grant.access] >= NEED_RANK[need];
}
// ── Scope ───────────────────────────────────────────────────────────────────
/** The slice of a grant row the scope predicate reads. */
export interface GrantScope {
scopeJson: Prisma.JsonValue | null;
}
/**
* What a stored scope actually opens.
*
* `null` — the whole record. Not a default standing in for a missing answer:
* it is the answer every grant written before the column existed was consented
* as, and the answer an owner who ticks "entire record" gives today. It is a
* first-class value everywhere, never a legacy badge.
*
* A set — those sections and nothing else, `record` included (see
* {@link grantCoversDomain}).
*/
export type ResolvedScope = ReadonlySet<ShareDomain> | null;
const EMPTY_SCOPE: ReadonlySet<ShareDomain> = new Set<ShareDomain>();
/**
* Turn the stored blob into what it opens, refusing anything it cannot read.
*
* Fail-closed, and this is the deliberate inverse of `normalisePrefs` in the
* module gate: that one resolves a malformed preference blob to "everything
* on", because a presentation preference nobody can read should not hide a
* person's own data from them. This one answers an authorization question, so
* a value it cannot read resolves to the EMPTY set — every section refused,
* including any the garbage might have happened to name. Both directions are
* "do the safe thing with a value we do not understand"; they only look
* opposite because the safe thing differs.
*
* What counts as unreadable is deliberately wide: anything that is not an
* array, an empty array, an array holding a key outside the vocabulary, an
* array holding a non-string. There is no partial credit — a set that is half
* recognisable is a set somebody wrote in a shape this file does not
* understand, and honouring the half we recognise would be guessing at
* consent. `record` is not a member of the vocabulary, so a stored `["record"]`
* is unreadable too, which is the correct reading: a scope that means "no
* scope" is a thing the invite path refuses to write, and one that reached
* the column by any other route opens nothing.
*/
export function normaliseScope(stored: Prisma.JsonValue | null): ResolvedScope {
if (stored === null || stored === undefined) return null;
if (!Array.isArray(stored) || stored.length === 0) return EMPTY_SCOPE;
const domains = new Set<ShareDomain>();
for (const entry of stored) {
if (!isShareDomain(entry)) return EMPTY_SCOPE;
// A scope is a subset, not merely an array of recognisable values. A
// duplicated key is malformed rather than a second consent to the same
// domain, so it receives no partial credit.
if (domains.has(entry)) return EMPTY_SCOPE;
domains.add(entry);
}
return domains;
}
/**
* Does this grant open the section the route declared?
*
* Three rules, and the second is the one that carries the invariant:
*
* * A NULL scope covers everything, `record` included. That is what every
* grant in the product was until this release and what most will stay.
* * A non-NULL scope NEVER covers `record`. A route that declares `record`
* reads across sections — a health score, a digest, an achievement set —
* and there is no honest way to answer one of those for a delegate who was
* given part of a record. A score that says 70 to the owner and 64 to the
* delegate is a support case and a clinical hazard, so the scoped delegate
* does not get a filtered answer, they get no answer.
* * Otherwise, membership. A key that postdates the invitation is not in the
* stored set and so is not covered, which is why a new section is a
* consent question rather than a schema question.
*
* The level is a separate question, asked by {@link grantAllows}. This one
* knows nothing about READ, WRITE or MANAGE — and MANAGE rows carry a NULL
* scope by construction, so it answers true for them by the first rule rather
* than by a special case.
*/
export function grantCoversDomain(
grant: GrantScope,
domain: ShareScope,
): boolean {
const scope = normaliseScope(grant.scopeJson);
if (scope === null) return true;
if (domain === ENTIRE_RECORD) return false;
return scope.has(domain);
}
// ── Lifecycle transitions ───────────────────────────────────────────────────
export interface InviteGrantInput {
@@ -878,54 +687,6 @@ export async function renounceGrantAndClearSwitch(
);
}
// ── Resolution ──────────────────────────────────────────────────────────────
/**
* The grant that lets `granteeId` act on `grantorId`'s record right now, or
* null.
*
* The query narrows on `revokedAt: null` only — which the partial unique index
* makes at most one row — and every other condition is decided by
* {@link isGrantActive}. That split is deliberate: a SQL predicate spelling out
* "accepted and not expired" beside a TypeScript predicate spelling out the
* same thing is two places deciding one question, and the copy that drifts is
* the one nobody reads. Here the database narrows and the state machine
* decides.
*/
export async function findActiveGrant(
pair: { grantorId: string; granteeId: string },
db: GrantDb = prisma,
now: Date = new Date(),
): Promise<AccountGrant | null> {
const live = await db.accountGrant.findFirst({
where: {
grantorId: pair.grantorId,
granteeId: pair.granteeId,
revokedAt: null,
},
});
if (!live) return null;
return isGrantActive(live, now) ? live : null;
}
/**
* Stamp `lastUsedAt` on a grant that was just exercised.
*
* Fire-and-forget, the `ApiToken.lastUsedAt` posture (`bearer.ts`): the request
* that triggered it must not wait, and a failure to record the use must not
* fail the read. The promise is returned so a test can await it; production
* callers deliberately do not.
*/
export function touchGrantUsage(
grantId: string,
db: GrantDb = prisma,
): Promise<void> {
return db.accountGrant
.update({ where: { id: grantId }, data: { lastUsedAt: new Date() } })
.then(() => undefined)
.catch(() => undefined);
}
// ── Internals ───────────────────────────────────────────────────────────────
/**
+1 -1
View File
@@ -2,7 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { findActiveGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
/**
* The authority that admitted provider work for a record.
@@ -31,13 +31,15 @@ import { encrypt } from "@/lib/crypto";
import {
GrantError,
acceptGrant,
findActiveGrant,
grantAllows,
inviteGrant,
renounceGrant,
revokeGrant,
touchGrantUsage,
} from "@/lib/sharing/grants";
import {
findActiveGrant,
grantAllows,
touchGrantUsage,
} from "@/lib/sharing/grant-access";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables } from "./setup";
@@ -4,11 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import {
acceptGrant,
findActiveGrant,
inviteGrant,
} from "@/lib/sharing/grants";
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
import { findActiveGrant } from "@/lib/sharing/grant-access";
import {
clearManagedProfileMarker,
ManagedProfileLifecycleError,
@@ -1,11 +1,8 @@
import { beforeEach, describe, expect, it } from "vitest";
import { getPrismaClient, truncateAllTables } from "./setup";
import {
acceptGrant,
grantCoversDomain,
inviteGrant,
} from "@/lib/sharing/grants";
import { acceptGrant, inviteGrant } from "@/lib/sharing/grants";
import { grantCoversDomain } from "@/lib/sharing/grant-access";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
let counter = 0;