diff --git a/src/__tests__/baselines/import-cycles.json b/src/__tests__/baselines/import-cycles.json index ba40e52d7..8a2c22e85 100644 --- a/src/__tests__/baselines/import-cycles.json +++ b/src/__tests__/baselines/import-cycles.json @@ -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" diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index f6b268ac4..3046040e8 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -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, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 61a2cd86c..6514d9799 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -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, diff --git a/src/__tests__/sharing-scope-contract.test.ts b/src/__tests__/sharing-scope-contract.test.ts index 469f69b83..dc0b6e76c 100644 --- a/src/__tests__/sharing-scope-contract.test.ts +++ b/src/__tests__/sharing-scope-contract.test.ts @@ -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"; diff --git a/src/app/api/account/switch/route.ts b/src/app/api/account/switch/route.ts index 073f739c5..eecb91dc8 100644 --- a/src/app/api/account/switch/route.ts +++ b/src/app/api/account/switch/route.ts @@ -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) => { diff --git a/src/app/api/auth/me/sessions/route.ts b/src/app/api/auth/me/sessions/route.ts index 9f256b4f4..7e4c9c97c 100644 --- a/src/app/api/auth/me/sessions/route.ts +++ b/src/app/api/auth/me/sessions/route.ts @@ -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"; diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 37efb2d1c..089047d44 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -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 }; diff --git a/src/lib/__tests__/require-record-auth-scope.test.ts b/src/lib/__tests__/require-record-auth-scope.test.ts index e8fc344de..1cc2cd3ea 100644 --- a/src/lib/__tests__/require-record-auth-scope.test.ts +++ b/src/lib/__tests__/require-record-auth-scope.test.ts @@ -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"; diff --git a/src/lib/ai/capabilities/__tests__/egress.test.ts b/src/lib/ai/capabilities/__tests__/egress.test.ts index 6b81d3d2f..38769bfcb 100644 --- a/src/lib/ai/capabilities/__tests__/egress.test.ts +++ b/src/lib/ai/capabilities/__tests__/egress.test.ts @@ -12,8 +12,8 @@ let auth: Record | undefined; vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }), })); -vi.mock("@/lib/sharing/grants", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@/lib/sharing/grant-access", async (importOriginal) => ({ + ...(await importOriginal()), findActiveGrant: vi.fn(), })); vi.mock("../load", () => ({ loadAiCapabilityInputs: vi.fn() })); diff --git a/src/lib/ai/capabilities/__tests__/gate.test.ts b/src/lib/ai/capabilities/__tests__/gate.test.ts index e05add81a..a0cf0f8eb 100644 --- a/src/lib/ai/capabilities/__tests__/gate.test.ts +++ b/src/lib/ai/capabilities/__tests__/gate.test.ts @@ -9,14 +9,14 @@ let auth: Record | undefined; vi.mock("@/lib/logging/context", () => ({ getEvent: () => ({ getAuth: () => auth, addWarning: vi.fn() }), })); -vi.mock("@/lib/sharing/grants", async (importOriginal) => ({ - ...(await importOriginal()), +vi.mock("@/lib/sharing/grant-access", async (importOriginal) => ({ + ...(await importOriginal()), 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"; diff --git a/src/lib/ai/capabilities/gate.ts b/src/lib/ai/capabilities/gate.ts index d558c483b..b490a06ac 100644 --- a/src/lib/ai/capabilities/gate.ts +++ b/src/lib/ai/capabilities/gate.ts @@ -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"; diff --git a/src/lib/auth/guards/record.ts b/src/lib/auth/guards/record.ts index 30e1e68b0..076450841 100644 --- a/src/lib/auth/guards/record.ts +++ b/src/lib/auth/guards/record.ts @@ -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 { diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 7b374faf3..278f76875 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -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"; diff --git a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts index e83fc2082..c1f7555cb 100644 --- a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts @@ -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 }, diff --git a/src/lib/integrations/oauth-kit/connect-target.ts b/src/lib/integrations/oauth-kit/connect-target.ts index 2289d320e..fd7028a7b 100644 --- a/src/lib/integrations/oauth-kit/connect-target.ts +++ b/src/lib/integrations/oauth-kit/connect-target.ts @@ -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 = diff --git a/src/lib/managed-profiles/guardian-list.ts b/src/lib/managed-profiles/guardian-list.ts index db27a63d4..4d4184217 100644 --- a/src/lib/managed-profiles/guardian-list.ts +++ b/src/lib/managed-profiles/guardian-list.ts @@ -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"; /** diff --git a/src/lib/managed-profiles/handover.ts b/src/lib/managed-profiles/handover.ts index b09ce6596..76e725663 100644 --- a/src/lib/managed-profiles/handover.ts +++ b/src/lib/managed-profiles/handover.ts @@ -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"; diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index 7aac92b03..d3f69ba1e 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -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"; diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 432dd8d1b..bd5d8d96a 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -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"; /** diff --git a/src/lib/sharing/acting-domains.ts b/src/lib/sharing/acting-domains.ts index cbd0628c9..7be7a3d1a 100644 --- a/src/lib/sharing/acting-domains.ts +++ b/src/lib/sharing/acting-domains.ts @@ -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( diff --git a/src/lib/sharing/grant-access.ts b/src/lib/sharing/grant-access.ts new file mode 100644 index 000000000..99cfd6cbd --- /dev/null +++ b/src/lib/sharing/grant-access.ts @@ -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 = { + READ: 0, + WRITE: 1, + MANAGE: 2, +}; + +const NEED_RANK: Record = { + 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 | null; + +const EMPTY_SCOPE: ReadonlySet = new Set(); + +/** + * 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(); + 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 { + 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 { + return db.accountGrant + .update({ where: { id: grantId }, data: { lastUsedAt: new Date() } }) + .then(() => undefined) + .catch(() => undefined); +} diff --git a/src/lib/sharing/grant-view.ts b/src/lib/sharing/grant-view.ts index 0f3c97337..c64f16da4 100644 --- a/src/lib/sharing/grant-view.ts +++ b/src/lib/sharing/grant-view.ts @@ -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"; diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index dcb8daf68..699ba45ca 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -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; - -// ── 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 = { - READ: 0, - WRITE: 1, - MANAGE: 2, -}; - -const NEED_RANK: Record = { - 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 | null; - -const EMPTY_SCOPE: ReadonlySet = new Set(); - -/** - * 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(); - 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 { - 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 { - return db.accountGrant - .update({ where: { id: grantId }, data: { lastUsedAt: new Date() } }) - .then(() => undefined) - .catch(() => undefined); -} - // ── Internals ─────────────────────────────────────────────────────────────── /** diff --git a/src/lib/sharing/provider-work-authority.ts b/src/lib/sharing/provider-work-authority.ts index 4ab9a0438..a2d8a9d72 100644 --- a/src/lib/sharing/provider-work-authority.ts +++ b/src/lib/sharing/provider-work-authority.ts @@ -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. diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index be280015f..eb573e09d 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -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"; diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index bd570ee3b..b1367db2d 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -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, diff --git a/tests/integration/sharing-scope-routes.test.ts b/tests/integration/sharing-scope-routes.test.ts index ef078d988..35b0d48b9 100644 --- a/tests/integration/sharing-scope-routes.test.ts +++ b/tests/integration/sharing-scope-routes.test.ts @@ -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;