mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Give a grant a section set and a third level
A grant has meant one thing since it shipped: the whole record, to read or to add to. This lays the words for the two shapes the rest of the sharing work needs — part of a record, and management of one — while changing what no existing grant does. `ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`, clustered from the delegable read surfaces rather than invented, plus the distinguished `record` for the routes that read across them. It never converts to a `ModuleKey` and never imports one: two masks, two questions, no shared spelling, because the day they share a name is the day somebody makes one imply the other, and a module the owner enabled is not a section the owner shared. `account_grants.scope_json` is NULL on every row that exists, and NULL is the entire record — the answer those rows were consented as, so no backfill and no re-consent. A non-NULL value is read through one fail-closed normaliser: a blob it cannot parse opens nothing, including a section the blob happened to name. That is the deliberate inverse of the module preference normaliser, which fails open on purpose; both do the safe thing with a value nobody can read, and the safe thing differs because one answers an authorization question and the other a presentation question. `MANAGE` joins the level enum, ordered above WRITE so a write grant can never satisfy it, and carries no scope at all: management of part of a record promises a boundary that cannot survive an edit, so the invite path refuses to write one. `requireRecordAuth` takes the section as a second required argument. The requirement is the lever — a delegable route without a classification does not typecheck, so the set of classified routes cannot fall behind the set of delegable ones. All fifty-seven modules carry theirs. The structural guard freezes each against the production vocabulary, fails when a module's own arms disagree, and pins a non-zero count of resolved sections so a matcher that stopped reading the argument fails instead of agreeing with everything. `requireGuardianAuth` is exported and reaches nothing yet. It gates on the managed-profile marker rather than on the grant, so an adult who granted management of their own record cannot arrive at that record's settings by any argument about what MANAGE means: widening MANAGE would widen the record resolver and would not touch this one. Its frozen list lands empty, with the declaration site as the positive control that keeps "nothing reaches it" a measurement rather than a renamed symbol. `users.managed_profile_at` says since when a record is one somebody else runs. A timestamp and not a boolean, for a reason the schema has to keep possible: clearing it is the handover, and the grants and the activity trail span it untouched.
This commit is contained in:
1 parent
03877a0971
commit
da3cabb55f
77 files changed
+2305
-266
No files matched your search
@@ -0,0 +1,40 @@
|
||||
-- v1.37.0 — a grant can open part of a record, and a grant can hand it over.
|
||||
--
|
||||
-- Two additive changes, no backfill, no re-consent, and no existing row
|
||||
-- changes meaning:
|
||||
--
|
||||
-- * `scope_json` is NULL on every row that exists, and NULL means the entire
|
||||
-- record. That is not a default standing in for a missing answer — it is
|
||||
-- the answer every one of those rows was actually consented as, before
|
||||
-- narrowing was a thing anybody could ask for. A non-NULL value is a
|
||||
-- non-empty array of section keys from the closed vocabulary in
|
||||
-- `src/lib/sharing/scope.ts`.
|
||||
-- * `MANAGE` joins the level enum. No row becomes MANAGE here; the value
|
||||
-- exists so a row can state the level instead of implying it, the same
|
||||
-- reason `account_grant_access` was a type rather than a boolean from day
|
||||
-- one (migration 0292).
|
||||
--
|
||||
-- Json rather than a text[] or a join table. A join table would be the
|
||||
-- normalised answer and it would be the wrong one: the set is read on every
|
||||
-- delegated request as part of deciding an authorization, so it belongs on the
|
||||
-- row the resolver already loads, and it is written exactly once, at
|
||||
-- invitation, by a machine — never edited, never queried across grants. There
|
||||
-- is no in-place widening anywhere in this feature, so the set has no update
|
||||
-- path to keep consistent. A text[] would carry the same shape with a
|
||||
-- Postgres-specific type Prisma models less directly, for no read this
|
||||
-- product performs.
|
||||
--
|
||||
-- No CHECK constraint asserting the array is non-empty, well-formed, or absent
|
||||
-- on a MANAGE row. Those three conditions are enforced in
|
||||
-- `src/lib/sharing/grants.ts`, at the one function that writes the column, and
|
||||
-- the reason is the same one migrations 0290 and 0292 gave for declining their
|
||||
-- own CHECKs: the delete-all-data guard seeds a row into every table by
|
||||
-- walking `information_schema`, and a constraint it cannot satisfy breaks a
|
||||
-- fixture whose whole value is that nobody hand-writes it. The READ side is
|
||||
-- what actually protects the record anyway — `normaliseScope` resolves a
|
||||
-- malformed blob to the empty set and refuses every section, so a bad value
|
||||
-- that reached the column by any route grants nothing rather than everything.
|
||||
|
||||
ALTER TYPE "account_grant_access" ADD VALUE 'MANAGE';
|
||||
|
||||
ALTER TABLE "account_grants" ADD COLUMN "scope_json" JSONB;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- v1.37.0 — a record somebody else runs.
|
||||
--
|
||||
-- A managed profile is an account for a person who cannot run their own
|
||||
-- record: a child, a parent under care. It has a display name, no e-mail and
|
||||
-- no password, never signs in, and is administered by whoever holds an active
|
||||
-- MANAGE grant on it.
|
||||
--
|
||||
-- One column, and the whole of the feature's identity fence hangs off it. An
|
||||
-- invited adult who granted management of their OWN record must never reach
|
||||
-- the settings, integrations, notification routing, grant management, AI
|
||||
-- configuration, export or deletion of that record — those are the surfaces
|
||||
-- that would let a manager outlive their own revocation. On a managed profile
|
||||
-- there is no self to reserve them for, so the guardian holds them. The gate
|
||||
-- is this column and not the grant level, which is what makes the two cases
|
||||
-- structurally different rather than differently argued.
|
||||
--
|
||||
-- A timestamp rather than a boolean, deliberately. It says since when, which
|
||||
-- the activity trail needs to be readable across the transition, and clearing
|
||||
-- it is the emancipation act: the day the person takes the record over,
|
||||
-- credentials attach (`email` and `password_hash` are already nullable, so
|
||||
-- nothing here has to change for that) and this goes back to NULL. The grants
|
||||
-- survive untouched — the handover changes who holds the revoke button, not
|
||||
-- which rows exist.
|
||||
--
|
||||
-- No index. The column is read by primary key, on a row the request already
|
||||
-- loaded, and the one enumeration that scans for marked rows (the reminder
|
||||
-- fan-out) resolves them from the grant side.
|
||||
|
||||
ALTER TABLE "users" ADD COLUMN "managed_profile_at" TIMESTAMPTZ(3);
|
||||
@@ -52,6 +52,28 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
/// v1.37.0 — since when this account is a record somebody else runs.
|
||||
///
|
||||
/// A managed profile is a person who cannot run their own record: it has a
|
||||
/// display name and no credentials, never signs in, and is administered
|
||||
/// end to end by whoever holds an active MANAGE grant on it. The marker is
|
||||
/// what separates that case from an ordinary adult who granted management
|
||||
/// of their own record — the identity and reach surfaces open for the
|
||||
/// former and stay shut for the latter, and the gate is this column rather
|
||||
/// than the grant, so no argument about a grant level can reach them.
|
||||
///
|
||||
/// A timestamp rather than a boolean, for a reason the schema has to keep
|
||||
/// possible: it says since when, and clearing it is the emancipation act —
|
||||
/// the day the person takes their own record over, credentials attach and
|
||||
/// this goes back to NULL, with the grants and the whole activity trail
|
||||
/// spanning the transition untouched.
|
||||
///
|
||||
/// Consumers land in this release: the guardian resolver in
|
||||
/// `src/lib/api-handler.ts` reads it on every guardian request, the
|
||||
/// notification dispatcher fans reminders out to the guardians' channels on
|
||||
/// it, and the creation flow is what writes it.
|
||||
managedProfileAt DateTime? @map("managed_profile_at")
|
||||
|
||||
// Profile
|
||||
heightCm Float? @map("height_cm")
|
||||
dateOfBirth DateTime? @map("date_of_birth")
|
||||
@@ -5904,6 +5926,14 @@ model ClinicianShareLinkDocument {
|
||||
enum AccountGrantAccess {
|
||||
READ
|
||||
WRITE
|
||||
/// v1.37.0 — full management of the record's data: everything WRITE reaches,
|
||||
/// plus the edit, delete and restore verbs beside it, plus the record-wide
|
||||
/// derived surfaces. Never the identity and reach surfaces — credentials,
|
||||
/// channels, tokens, integrations, grant management, AI configuration —
|
||||
/// which stay unreachable at every invited level, because a manager who
|
||||
/// could mint a credential would survive their own revocation. MANAGE is
|
||||
/// whole-record by construction: `scopeJson` must be NULL on a MANAGE row.
|
||||
MANAGE
|
||||
|
||||
@@map("account_grant_access")
|
||||
}
|
||||
@@ -5970,6 +6000,24 @@ model AccountGrant {
|
||||
|
||||
access AccountGrantAccess @default(READ)
|
||||
|
||||
/// v1.37.0 — which sections of the record this grant opens.
|
||||
///
|
||||
/// NULL means the entire record, which is exactly what every row written
|
||||
/// before this column existed was consented as — so there is no backfill and
|
||||
/// nobody is asked to consent again. A non-NULL value is a non-empty array
|
||||
/// of `ShareDomain` keys (`src/lib/sharing/scope.ts`).
|
||||
///
|
||||
/// Read fail-closed through `normaliseScope` in `src/lib/sharing/grants.ts`
|
||||
/// and nowhere else: a malformed non-NULL blob resolves to the empty set and
|
||||
/// refuses every section, the opposite direction from the module preference
|
||||
/// normaliser, because this column answers an authorization question and
|
||||
/// that one answers a presentation question.
|
||||
///
|
||||
/// Always NULL on a MANAGE row. Management is whole-record: "they can do
|
||||
/// anything, but only to part of you" promises a boundary that cannot be
|
||||
/// kept once edits are in play.
|
||||
scopeJson Json? @map("scope_json")
|
||||
|
||||
/// When the invitation was offered. A domain event, so re-offering an
|
||||
/// invitation on the same pending row moves it; `createdAt` below is row
|
||||
/// bookkeeping and never moves. They agree the day the row is written and
|
||||
|
||||
@@ -168,16 +168,25 @@ describe("the cookie-only helpers cannot see the acting account", () => {
|
||||
expect(body).not.toContain("readActingCarrier");
|
||||
});
|
||||
|
||||
it("substitutes a data-scope user in exactly one function", () => {
|
||||
it("substitutes a data-scope user in exactly two functions", () => {
|
||||
const src = read("lib/api-handler.ts");
|
||||
const record = functionBody(src, "requireRecordAuth");
|
||||
expect(record).toContain("readActingCarrier");
|
||||
|
||||
// v1.37.0 — the guardian resolver is the second function that acts on the
|
||||
// carrier, and it is a second declaration rather than a level on the
|
||||
// first: it admits the surfaces of a record NOBODY runs themselves, gated
|
||||
// on the managed-profile marker rather than on the grant.
|
||||
const guardian = functionBody(src, "requireGuardianAuth");
|
||||
expect(guardian.length).toBeGreaterThan(0);
|
||||
expect(guardian).toContain("readActingCarrier");
|
||||
expect(guardian).toContain("managedProfileAt");
|
||||
|
||||
// `readActingCarrier` is what turns a request into "acting as somebody
|
||||
// else". Two callers: the bare path, which refuses on it, and the record
|
||||
// path, which acts on it. A third would be a mode nobody declared.
|
||||
// else". Three callers: the bare path, which refuses on it, and the two
|
||||
// record paths, which act on it. A fourth would be a mode nobody declared.
|
||||
const callers = (src.match(/await readActingCarrier\(/g) ?? []).length;
|
||||
expect(callers).toBe(2);
|
||||
expect(callers).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -142,6 +142,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
grantorId: user.id,
|
||||
granteeId: invitee.id,
|
||||
access,
|
||||
// The entire record, which is what this endpoint has always offered and
|
||||
// what every stored grant means. Named rather than defaulted, because
|
||||
// the domain module takes the scope as a required argument for the same
|
||||
// reason it takes the level as one: a scope nobody chose is not a scope.
|
||||
// The consent surface that lets an owner narrow it lands beside this
|
||||
// line, in this release.
|
||||
scope: null,
|
||||
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.allergy.findUnique({ where: { id } });
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
import { toAllergyDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = allergyListQuerySchema.safeParse({
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
const payload = await readHealthProfileFacts(user.id);
|
||||
annotate({
|
||||
action: { name: "anamnesis.facts.get" },
|
||||
|
||||
@@ -64,7 +64,7 @@ function serialiseBiomarker(row: {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.biomarker.findFirst({ where: { id } });
|
||||
|
||||
@@ -71,7 +71,7 @@ function serialiseBiomarker(row: {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
|
||||
// Visible markers first, then hidden — the manager renders them in two
|
||||
// sections and the lab-entry pickers drop the hidden tail client-side.
|
||||
@@ -95,7 +95,7 @@ async function postBiomarker(request: NextRequest) {
|
||||
// duplicate pre-check and the `@@unique([userId, name])` backstop both scope
|
||||
// to the same resolved id, so a delegate adding a marker the owner already
|
||||
// tracks gets the ordinary 409 rather than a second copy.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -79,7 +79,7 @@ export const GET = apiHandler(async (req: Request) => {
|
||||
//
|
||||
// The module gate below resolves against the record, so the surface exists
|
||||
// for a delegate only where the OWNER switched the Coach on.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -38,7 +38,7 @@ function effectiveBound(
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -29,7 +29,7 @@ import { createCustomMetricSchema } from "@/lib/validations/custom-metrics";
|
||||
*/
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const rows = await prisma.customMetric.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
|
||||
@@ -29,7 +29,7 @@ const DEFAULT_LIMIT = 24;
|
||||
const REGULAR_MAD_THRESHOLD = 2;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -48,7 +48,7 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postDayLog));
|
||||
* row id. Gated + owner-scoped + `deletedAt:null`.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { toCycleProfileDTO } from "@/lib/cycle/dto";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "cycle");
|
||||
|
||||
const gate = await requireCycleEnabled(user.id, user.gender);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -31,7 +31,7 @@ export const GET = apiHandler(async () => {
|
||||
// matters — a delegate gets the hero only where the owner switched insights
|
||||
// on, never where the delegate did. Re-examine with the snapshot when
|
||||
// per-module scope lands; a digest is a summary of several modules at once.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
const m = await requireModuleEnabled(user.id, "insights");
|
||||
if (!m.enabled) return m.response;
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ export const GET = apiHandler(async () => {
|
||||
//
|
||||
// The cache cell keys on the resolved id, so the owner's snapshot lands in
|
||||
// the owner's cell and a delegated read neither reads nor poisons their own.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
annotate({ action: { name: "dashboard.snapshot" } });
|
||||
|
||||
const timings: Record<string, number> = {};
|
||||
|
||||
@@ -196,7 +196,7 @@ export const GET = apiHandler(async () => {
|
||||
// matter, which comparison baseline reads correctly for it. A delegate
|
||||
// opening somebody's dashboard should see it arranged the way its owner
|
||||
// arranged it.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
|
||||
// 5-minute TTL per blueprint §5; the layout changes only when the user
|
||||
// hits the Settings → Dashboard save button, which invalidates this
|
||||
|
||||
@@ -117,7 +117,7 @@ function safeDownloadName(
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user, actor } = await requireRecordAuth("read");
|
||||
const { user, actor } = await requireRecordAuth("read", "documents");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -76,7 +76,7 @@ async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "documents");
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ const THUMBNAIL_READ_WINDOW_MS = 60 * 60 * 1000;
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user, actor } = await requireRecordAuth("read");
|
||||
const { user, actor } = await requireRecordAuth("read", "documents");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -458,7 +458,7 @@ export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
|
||||
* declare separately rather than share one line.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "documents");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -34,7 +34,7 @@ export const dynamic = "force-dynamic";
|
||||
* No write arm exists on this route.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "documents");
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const { id } = await params;
|
||||
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "profile");
|
||||
|
||||
const params = new URL(request.url).searchParams;
|
||||
const parsed = familyHistoryListQuerySchema.safeParse({
|
||||
|
||||
@@ -47,9 +47,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
//
|
||||
// Read-only in fact as well as in declaration: v1.35.3 moved the unlock
|
||||
// INSERT onto the sweep job, so there is no write for a read grant to have
|
||||
// to refuse. `requireRecordAuth("read")` would refuse a non-safe method
|
||||
// to refuse. `requireRecordAuth("read", "record")` would refuse a non-safe method
|
||||
// anyway; this file exports no other verb.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
|
||||
// v1.18.0 — when the account has the achievements module turned off the
|
||||
// whole gamification surface disappears: no badge evaluation, no unlock
|
||||
|
||||
@@ -55,7 +55,7 @@ async function loadOwnedEpisode(
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -30,7 +30,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
import { toIllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
@@ -78,7 +78,7 @@ async function postEpisode(request: NextRequest): Promise<Response> {
|
||||
// the caller: whether illness tracking exists is a property of the record, so
|
||||
// a delegate who runs the module on their own account does not thereby gain
|
||||
// an episode surface inside somebody else's.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "illness");
|
||||
|
||||
const gate = await requireIllnessEnabled(user.id);
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -43,7 +43,7 @@ export const GET = apiHandler(async () => {
|
||||
// switch and that is not in tension with this: chat spends the owner's AI
|
||||
// budget and writes into their conversation, and reading whether the thread
|
||||
// has something unopened does neither.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "record");
|
||||
// Operator-level flag — an `AppSettings` singleton, unaffected by whose
|
||||
// record is open.
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
@@ -65,7 +65,7 @@ function toResolved(
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
const { id } = await params;
|
||||
|
||||
const row = await prisma.labResult.findFirst({
|
||||
|
||||
@@ -70,7 +70,7 @@ function toResolved(
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "labs");
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listLabResultsSchema.safeParse(params);
|
||||
@@ -143,7 +143,7 @@ async function postLabResult(request: NextRequest) {
|
||||
// it mints it under `user.id`: the marker joins the record's catalogue, not
|
||||
// the caller's, which is the only reading of "add a result to your record"
|
||||
// that leaves the owner with a usable catalogue afterwards.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "labs");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
|
||||
@@ -41,7 +41,7 @@ async function resolveTimezone(userId: string): Promise<string> {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
const { id } = await params;
|
||||
|
||||
const reminder = await prisma.measurementReminder.findFirst({
|
||||
|
||||
@@ -36,7 +36,7 @@ async function resolveTimezone(userId: string): Promise<string> {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
|
||||
@@ -27,7 +27,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ import type {
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listMeasurementsSchema.safeParse(params);
|
||||
@@ -755,7 +755,7 @@ async function postMeasurement(request: NextRequest) {
|
||||
// who may or may not be that person. Nothing else in this handler reads the
|
||||
// caller: the safety-floor check, the reminder satisfaction, the rollup
|
||||
// recompute and the cache eviction are all statements about the record.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "measurements");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "@/lib/validations/series-batch";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = seriesBatchQuerySchema.safeParse(params);
|
||||
|
||||
@@ -160,7 +160,7 @@ function stdDev(values: number[]): number {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = querySchema.safeParse(rawQuery);
|
||||
|
||||
@@ -42,7 +42,7 @@ const querySchema = z.object({
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
|
||||
@@ -51,7 +51,10 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
// v1.36.x — a delegated write, the per-medication form of the same verb.
|
||||
// `user` is the record the dose belongs to; `actor` is whoever pressed the
|
||||
// button, and is used for nothing but the owner's notification below.
|
||||
const { user, actor, authMethod } = await requireRecordAuth("write");
|
||||
const { user, actor, authMethod } = await requireRecordAuth(
|
||||
"write",
|
||||
"medications",
|
||||
);
|
||||
|
||||
// v1.36.1 follow-up — the sibling route `POST /api/medications/intake`
|
||||
// refuses a delegate changing a dose the owner already recorded. This route
|
||||
@@ -588,7 +591,7 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
|
||||
@@ -44,7 +44,7 @@ const POST_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
|
||||
@@ -10,7 +10,7 @@ type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
|
||||
@@ -76,7 +76,7 @@ function reconcileScheduleWindow(
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
|
||||
@@ -73,7 +73,7 @@ function summariseEntries(payload: unknown): Array<{
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
|
||||
@@ -46,7 +46,7 @@ const POST_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
@@ -100,7 +100,7 @@ export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
// v1.36.x — a delegated write. `user` is the record the entry lands under;
|
||||
// `actor` is whoever is typing, and the two differ only under a switch.
|
||||
const { user, actor } = await requireRecordAuth("write");
|
||||
const { user, actor } = await requireRecordAuth("write", "medications");
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
|
||||
@@ -26,7 +26,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
|
||||
* detail page keeps the per-id route for the grid.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user, actor } = await requireRecordAuth("read");
|
||||
const { user, actor } = await requireRecordAuth("read", "medications");
|
||||
|
||||
// v1.36.0 — the bucket keys on the ACTOR while everything below it scopes to
|
||||
// the record. Two reasons, and both are the same reason from opposite ends:
|
||||
|
||||
@@ -85,7 +85,7 @@ const updateSchema = z.object({
|
||||
});
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
const parsed = querySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams),
|
||||
@@ -226,7 +226,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// address the delegate, and none of them should — the delegate's feedback is
|
||||
// the response they are already awaiting. What the owner gets instead is the
|
||||
// notification at the end of this handler.
|
||||
const { user, actor } = await requireRecordAuth("write");
|
||||
const { user, actor } = await requireRecordAuth("write", "medications");
|
||||
|
||||
const { data: body, error } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@@ -84,7 +84,7 @@ export const GET = apiHandler(async () => {
|
||||
// owner's list would fall back to default order — a preference that cannot
|
||||
// apply is not a preference, it is noise. `/medications` is a shared
|
||||
// destination, so this is a list a delegate really does see.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
// 5-minute TTL matches the dashboard-widgets / insights-layout caches; the
|
||||
// blob changes only on a view toggle or an order save, which invalidates via
|
||||
|
||||
@@ -33,7 +33,7 @@ import { NextRequest } from "next/server";
|
||||
const APPLE_HEALTH_MIRROR_CAP = 30;
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
// Cache the list shape on userId. The 60 s fresh TTL bounds the
|
||||
// cross-midnight staleness window for `todayEventCount` and the
|
||||
@@ -98,7 +98,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// it afterwards — `PUT /api/medications/[id]` and the DELETE beside it stay
|
||||
// on `requireAuth()` and refuse under a switch. Adding is help; rewriting
|
||||
// the owner's cabinet is not.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "medications");
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
|
||||
@@ -99,7 +99,7 @@ function respondExisting(row: Parameters<typeof shapeRow>[0]): Response {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "mind");
|
||||
// Opt-in module (default OFF): the screener history is unreachable until the
|
||||
// account turns the mental-health module on. Enforced server-side even for a
|
||||
// valid Bearer token (the nav entry / page redirect are UX-only).
|
||||
|
||||
@@ -36,7 +36,7 @@ function parseTags(tags: string | null): string[] {
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "mind");
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ function parseTags(tags: string | null): string[] {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "mind");
|
||||
|
||||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listMoodEntriesSchema.safeParse(params);
|
||||
|
||||
@@ -34,7 +34,7 @@ import { DEFAULT_TIMEZONE, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -92,7 +92,7 @@ function pickTopSkipReason(byReason: Record<string, number>): string | null {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "nutrients");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -36,7 +36,7 @@ const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "personalRecords.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
@@ -24,7 +24,7 @@ export const GET = apiHandler(async () => {
|
||||
//
|
||||
// No write arm exists, so there is nothing to split: a delegate cannot move
|
||||
// the owner's threshold, only read the one the owner set.
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "medications");
|
||||
|
||||
// Operator-level singleton (`lateMinutes` / `missedMinutes`) — the same for
|
||||
// every account on the deployment, so the switch does not touch it.
|
||||
|
||||
@@ -105,7 +105,7 @@ function serializeSession(s: SleepSession) {
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
// Per-domain gate: the hypnogram read serves only the sleep module's
|
||||
// view surfaces, so it gates on the sleep module. Disabled ⇒ 403.
|
||||
|
||||
@@ -26,7 +26,7 @@ import { buildSleepRhythm } from "@/lib/insights/derived/sleep-rhythm";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
// Per-domain gate: the sleep-rhythm read serves only the sleep module's
|
||||
// view surfaces (Sleep page + iOS), so it gates on the sleep module.
|
||||
|
||||
@@ -70,7 +70,7 @@ const DEFAULT_LIMIT = 50;
|
||||
const MAX_LIMIT = 200;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
annotate({ action: { name: "workouts.list" } });
|
||||
|
||||
// v1.18.0 B1 — the workouts surface is a toggleable module. A disabled
|
||||
|
||||
@@ -31,14 +31,19 @@ interface GrantRow {
|
||||
id: string;
|
||||
grantorId: string;
|
||||
granteeId: string;
|
||||
access: "READ" | "WRITE";
|
||||
access: "READ" | "WRITE" | "MANAGE";
|
||||
/** v1.37.0 — NULL is the entire record; an array narrows to those sections. */
|
||||
scopeJson: string[] | null;
|
||||
acceptedAt: Date | null;
|
||||
revokedAt: Date | null;
|
||||
expiresAt: Date | null;
|
||||
}
|
||||
|
||||
const grantRows: GrantRow[] = [];
|
||||
const userRows = new Map<string, { id: string; role: string }>();
|
||||
const userRows = new Map<
|
||||
string,
|
||||
{ id: string; role: string; managedProfileAt: Date | null }
|
||||
>();
|
||||
const touched: string[] = [];
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -112,6 +117,7 @@ import {
|
||||
ACCOUNT_SELECTOR_HEADER,
|
||||
apiHandler,
|
||||
requireActorAuth,
|
||||
requireGuardianAuth,
|
||||
requireAdmin,
|
||||
requireAuth,
|
||||
requireCookieAuth,
|
||||
@@ -128,8 +134,18 @@ import type { WideEvent } from "@/lib/logging/types";
|
||||
const OWNER = "owner-1";
|
||||
const DELEGATE = "delegate-1";
|
||||
|
||||
function user(id: string, role: "USER" | "ADMIN" = "USER") {
|
||||
return { id, role, username: id, email: `${id}@example.test` };
|
||||
function user(
|
||||
id: string,
|
||||
role: "USER" | "ADMIN" = "USER",
|
||||
managedProfileAt: Date | null = null,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
role,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
managedProfileAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sign the delegate in over the cookie transport. */
|
||||
@@ -172,6 +188,7 @@ function liveGrant(overrides: Partial<GrantRow> = {}): GrantRow {
|
||||
grantorId: OWNER,
|
||||
granteeId: DELEGATE,
|
||||
access: "READ",
|
||||
scopeJson: null,
|
||||
acceptedAt: new Date(Date.now() - 60_000),
|
||||
revokedAt: null,
|
||||
expiresAt: null,
|
||||
@@ -252,7 +269,9 @@ describe("do no harm — nothing is acting as anyone", () => {
|
||||
const actor = await resolveInRoute(() => requireActorAuth());
|
||||
expect(actor.user.id).toBe(DELEGATE);
|
||||
|
||||
const record = await resolveInRoute(() => requireRecordAuth("read"));
|
||||
const record = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
expect(record.user.id).toBe(DELEGATE);
|
||||
expect(record.actor.id).toBe(DELEGATE);
|
||||
expect(record.grantId).toBeNull();
|
||||
@@ -266,7 +285,7 @@ describe("do no harm — nothing is acting as anyone", () => {
|
||||
|
||||
it("leaves the wide event's actor identity alone", async () => {
|
||||
signedInCookie(null);
|
||||
const call = route(() => requireRecordAuth("read"));
|
||||
const call = route(() => requireRecordAuth("read", "measurements"));
|
||||
await call();
|
||||
|
||||
expect(lastEvent().auth?.user_id).toBe(DELEGATE);
|
||||
@@ -382,7 +401,7 @@ describe("requireRecordAuth — substitution", () => {
|
||||
const grant = liveGrant();
|
||||
|
||||
const resolved: RecordAuthContext = await resolveInRoute(() =>
|
||||
requireRecordAuth("read"),
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
expect(resolved.user.id).toBe(OWNER);
|
||||
expect(resolved.actor.id).toBe(DELEGATE);
|
||||
@@ -394,7 +413,9 @@ describe("requireRecordAuth — substitution", () => {
|
||||
selector(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const resolved = await resolveInRoute(() => requireRecordAuth("read"));
|
||||
const resolved = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
);
|
||||
expect(resolved.user.id).toBe(OWNER);
|
||||
expect(resolved.actor.id).toBe(DELEGATE);
|
||||
});
|
||||
@@ -403,7 +424,7 @@ describe("requireRecordAuth — substitution", () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
await route(() => requireRecordAuth("read"))();
|
||||
await route(() => requireRecordAuth("read", "measurements"))();
|
||||
expect(lastEvent().auth?.user_id).toBe(DELEGATE);
|
||||
expect(lastEvent().auth?.acting_as).toBe(OWNER);
|
||||
});
|
||||
@@ -412,7 +433,7 @@ describe("requireRecordAuth — substitution", () => {
|
||||
signedInCookie(OWNER);
|
||||
const grant = liveGrant();
|
||||
|
||||
await resolveInRoute(() => requireRecordAuth("read"));
|
||||
await resolveInRoute(() => requireRecordAuth("read", "measurements"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(touched).toEqual([grant.id]);
|
||||
});
|
||||
@@ -420,7 +441,9 @@ describe("requireRecordAuth — substitution", () => {
|
||||
|
||||
describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
const refused = async (): Promise<Response> => {
|
||||
const response = await route(() => requireRecordAuth("read"))();
|
||||
const response = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.clone().json()).meta.errorCode).toBe(
|
||||
"sharing.access.denied",
|
||||
@@ -456,7 +479,9 @@ describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const response = await route(() => requireRecordAuth("read"))("POST");
|
||||
const response = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)("POST");
|
||||
expect(response.status).toBe(403);
|
||||
expect((await response.json()).meta.errorCode).toBe(
|
||||
"sharing.access.denied",
|
||||
@@ -467,7 +492,9 @@ describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
|
||||
const response = await route(() => requireRecordAuth("write"))();
|
||||
const response = await route(() =>
|
||||
requireRecordAuth("write", "measurements"),
|
||||
)();
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
@@ -478,7 +505,9 @@ describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ access: "WRITE" });
|
||||
|
||||
const response = await route(() => requireRecordAuth("write"))("POST");
|
||||
const response = await route(() =>
|
||||
requireRecordAuth("write", "measurements"),
|
||||
)("POST");
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
@@ -488,7 +517,9 @@ describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
// guard.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant();
|
||||
await expect(requireRecordAuth("read")).rejects.toMatchObject({
|
||||
await expect(
|
||||
requireRecordAuth("read", "measurements"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "sharing.access.denied",
|
||||
});
|
||||
});
|
||||
@@ -496,7 +527,9 @@ describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
it("refuses a selector too long to name an account, without querying", async () => {
|
||||
signedInBearer();
|
||||
selector("x".repeat(65));
|
||||
await expect(requireRecordAuth("read")).rejects.toMatchObject({
|
||||
await expect(
|
||||
requireRecordAuth("read", "measurements"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "sharing.access.denied",
|
||||
});
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
@@ -506,7 +539,9 @@ describe("requireRecordAuth — every way a grant can fail", () => {
|
||||
signedInCookie(null);
|
||||
selector(OWNER);
|
||||
liveGrant();
|
||||
await expect(requireRecordAuth("read")).rejects.toMatchObject({
|
||||
await expect(
|
||||
requireRecordAuth("read", "measurements"),
|
||||
).rejects.toMatchObject({
|
||||
errorCode: "sharing.not_permitted",
|
||||
});
|
||||
});
|
||||
@@ -526,7 +561,9 @@ describe("refusals do not enumerate", () => {
|
||||
it("answers identically for an account that does not exist and one that granted nothing", async () => {
|
||||
signedInBearer();
|
||||
selector("no-such-account-at-all");
|
||||
const missing = await route(() => requireRecordAuth("read"))();
|
||||
const missing = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
const missingBody = await missing.text();
|
||||
|
||||
vi.clearAllMocks();
|
||||
@@ -536,7 +573,9 @@ describe("refusals do not enumerate", () => {
|
||||
// granted this caller anything.
|
||||
userRows.set("real-stranger", user("real-stranger"));
|
||||
selector("real-stranger");
|
||||
const present = await route(() => requireRecordAuth("read"))();
|
||||
const present = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
const presentBody = await present.text();
|
||||
|
||||
expect(missing.status).toBe(present.status);
|
||||
@@ -549,7 +588,9 @@ describe("refusals do not enumerate", () => {
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
liveGrant({ revokedAt: new Date(Date.now() - 1000) });
|
||||
const revoked = await route(() => requireRecordAuth("read"))();
|
||||
const revoked = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
const revokedBody = await revoked.text();
|
||||
|
||||
vi.clearAllMocks();
|
||||
@@ -557,7 +598,9 @@ describe("refusals do not enumerate", () => {
|
||||
grantRows.length = 0;
|
||||
signedInBearer();
|
||||
selector(OWNER);
|
||||
const never = await route(() => requireRecordAuth("read"))();
|
||||
const never = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
|
||||
expect(revokedBody).toBe(await never.text());
|
||||
});
|
||||
@@ -567,7 +610,9 @@ describe("refusals do not enumerate", () => {
|
||||
selector(OWNER);
|
||||
liveGrant({ expiresAt: new Date(Date.now() - 1000) });
|
||||
|
||||
const body = await (await route(() => requireRecordAuth("read"))()).text();
|
||||
const body = await (
|
||||
await route(() => requireRecordAuth("read", "measurements"))()
|
||||
).text();
|
||||
expect(body).not.toContain("expire");
|
||||
expect(body).not.toContain("grant");
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
@@ -618,3 +663,236 @@ describe("the cookie-only helpers stay out of reach of a switch", () => {
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the section fence.
|
||||
*
|
||||
* A scoped grant is a door policy: the sections it names open, the others do
|
||||
* not answer at all. Every refusal below is the same 403 with the same body as
|
||||
* every other sharing refusal — only the audit reason differs — so these
|
||||
* assert the reason on the row and the bytes on the wire separately.
|
||||
*/
|
||||
describe("a scoped grant reaches only the sections it names", () => {
|
||||
it("admits a route whose section the grant names", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ scopeJson: ["medications"] });
|
||||
|
||||
const ctx = await resolveInRoute(() =>
|
||||
requireRecordAuth("read", "medications"),
|
||||
);
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
expect(ctx.actor.id).toBe(DELEGATE);
|
||||
});
|
||||
|
||||
it("refuses a route whose section the grant does not name", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ scopeJson: ["medications"] });
|
||||
|
||||
const response = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
expect(response.status).toBe(403);
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "out_of_scope" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses every record-wide route, at any scope", async () => {
|
||||
// The invariant: an aggregate reads across sections and there is no honest
|
||||
// partial answer to one. Not filtered — refused.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ scopeJson: ["medications", "measurements", "labs"] });
|
||||
|
||||
const response = await route(() => requireRecordAuth("read", "record"))();
|
||||
expect(response.status).toBe(403);
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "out_of_scope" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses everything when the stored scope is unreadable", async () => {
|
||||
// Fail-closed, end to end: a blob the normaliser cannot read opens
|
||||
// nothing, including a section the blob might have happened to name.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ scopeJson: "medications" as unknown as string[] });
|
||||
|
||||
for (const domain of ["medications", "measurements", "record"] as const) {
|
||||
const response = await route(() => requireRecordAuth("read", domain))();
|
||||
expect(response.status, domain).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a NULL-scope grant reaching everything, as before", async () => {
|
||||
// The no-regression leg. Every grant in the product is this one.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ scopeJson: null });
|
||||
|
||||
for (const domain of [
|
||||
"measurements",
|
||||
"medications",
|
||||
"labs",
|
||||
"profile",
|
||||
"illness",
|
||||
"mind",
|
||||
"cycle",
|
||||
"documents",
|
||||
"record",
|
||||
] as const) {
|
||||
const ctx = await resolveInRoute(() => requireRecordAuth("read", domain));
|
||||
expect(ctx.user.id, domain).toBe(OWNER);
|
||||
}
|
||||
});
|
||||
|
||||
it("says the same thing on the wire as every other refusal", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ scopeJson: ["medications"] });
|
||||
const outOfScope = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
const outOfScopeBody = await outOfScope.text();
|
||||
|
||||
grantRows.length = 0;
|
||||
liveGrant({ access: "READ" });
|
||||
const insufficient = await route(() =>
|
||||
requireRecordAuth("write", "measurements"),
|
||||
)();
|
||||
|
||||
expect(outOfScope.status).toBe(insufficient.status);
|
||||
expect(outOfScopeBody).toBe(await insufficient.text());
|
||||
expect(outOfScopeBody).not.toContain("scope");
|
||||
expect(outOfScopeBody).not.toContain("section");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the third level, at the resolver.
|
||||
*/
|
||||
describe("a route declaring manage refuses everything below it", () => {
|
||||
it("admits a MANAGE grant", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ access: "MANAGE" });
|
||||
|
||||
const ctx = await resolveInRoute(
|
||||
() => requireRecordAuth("manage", "measurements"),
|
||||
"DELETE",
|
||||
);
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
});
|
||||
|
||||
it("refuses a WRITE grant on a safe method", async () => {
|
||||
// The method cannot satisfy a declaration, only escalate one. A GET on a
|
||||
// manage-declaring route is still a manage question.
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ access: "WRITE" });
|
||||
|
||||
const response = await route(() =>
|
||||
requireRecordAuth("manage", "measurements"),
|
||||
)("GET");
|
||||
expect(response.status).toBe(403);
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "insufficient_access" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("lets a MANAGE grant satisfy a route that only asked for a write", async () => {
|
||||
signedInCookie(OWNER);
|
||||
liveGrant({ access: "MANAGE" });
|
||||
|
||||
const ctx = await resolveInRoute(
|
||||
() => requireRecordAuth("write", "labs"),
|
||||
"POST",
|
||||
);
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the identity fence.
|
||||
*
|
||||
* The single most important arm in the release: the line between "manage my
|
||||
* data" and "own my account". An invited adult's MANAGE grant reaches the
|
||||
* record's health data and NONE of its settings, integrations, routing, grant
|
||||
* management or deletion. The gate is the managed-profile marker on the
|
||||
* record, not the level on the grant.
|
||||
*/
|
||||
describe("the guardian resolver gates on the marker, not the grant", () => {
|
||||
it("admits a MANAGE grant on a marked record", async () => {
|
||||
signedInCookie(OWNER);
|
||||
userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z")));
|
||||
liveGrant({ access: "MANAGE" });
|
||||
|
||||
const ctx = await resolveInRoute(() => requireGuardianAuth());
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
expect(ctx.actor.id).toBe(DELEGATE);
|
||||
expect(ctx.grantId).toBe("grant-1");
|
||||
});
|
||||
|
||||
it("refuses a MANAGE grant on an ordinary adult's record", async () => {
|
||||
// The fence. The grant is perfect and the answer is still no, because the
|
||||
// record has an owner who runs it.
|
||||
signedInCookie(OWNER);
|
||||
userRows.set(OWNER, user(OWNER, "USER", null));
|
||||
liveGrant({ access: "MANAGE" });
|
||||
|
||||
const response = await route(() => requireGuardianAuth())();
|
||||
expect(response.status).toBe(403);
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "guardian_only" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a WRITE grant on a marked record", async () => {
|
||||
// The marker is necessary and not sufficient either way round.
|
||||
signedInCookie(OWNER);
|
||||
userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z")));
|
||||
liveGrant({ access: "WRITE" });
|
||||
|
||||
const response = await route(() => requireGuardianAuth())();
|
||||
expect(response.status).toBe(403);
|
||||
expect(sharingAuditCalls()[0][1]).toMatchObject({
|
||||
details: expect.objectContaining({ reason: "insufficient_access" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the scope on a marked record, because MANAGE carries none", async () => {
|
||||
signedInCookie(OWNER);
|
||||
userRows.set(OWNER, user(OWNER, "USER", new Date("2026-08-04T00:00:00Z")));
|
||||
liveGrant({ access: "MANAGE", scopeJson: null });
|
||||
|
||||
const ctx = await resolveInRoute(() => requireGuardianAuth());
|
||||
expect(ctx.user.id).toBe(OWNER);
|
||||
});
|
||||
|
||||
it("serves the caller's own record when nothing is acting as anyone", async () => {
|
||||
// Do no harm: the owner of an ordinary record reaching their own settings
|
||||
// is the request it always was, and no grant question is asked.
|
||||
signedInCookie(null);
|
||||
|
||||
const ctx = await resolveInRoute(() => requireGuardianAuth());
|
||||
expect(ctx.user.id).toBe(DELEGATE);
|
||||
expect(ctx.grantId).toBeNull();
|
||||
expect(prisma.accountGrant.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says the same thing on the wire as every other refusal", async () => {
|
||||
signedInCookie(OWNER);
|
||||
userRows.set(OWNER, user(OWNER, "USER", null));
|
||||
liveGrant({ access: "MANAGE" });
|
||||
const fenced = await route(() => requireGuardianAuth())();
|
||||
const fencedBody = await fenced.text();
|
||||
|
||||
// Compared against the oldest refusal in the feature: no grant at all. The
|
||||
// two are as far apart in meaning as this feature gets — "you were never
|
||||
// given anything" and "you were given management of a record whose owner
|
||||
// runs it" — and the caller must not be able to tell them apart.
|
||||
grantRows.length = 0;
|
||||
const insufficient = await route(() =>
|
||||
requireRecordAuth("read", "measurements"),
|
||||
)();
|
||||
|
||||
expect(fenced.status).toBe(insufficient.status);
|
||||
expect(fencedBody).toBe(await insufficient.text());
|
||||
expect(fencedBody).not.toContain("guardian");
|
||||
expect(fencedBody).not.toContain("managed");
|
||||
});
|
||||
});
|
||||
+112
-7
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { headers } from "next/headers";
|
||||
import type { User } from "@/generated/prisma/client";
|
||||
import type { AccountGrant, User } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { WideEventBuilder } from "./logging/event-builder";
|
||||
import { annotate, eventStorage, getEvent } from "./logging/context";
|
||||
@@ -32,9 +32,11 @@ import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config";
|
||||
import {
|
||||
findActiveGrant,
|
||||
grantAllows,
|
||||
grantCoversDomain,
|
||||
touchGrantUsage,
|
||||
type GrantNeed,
|
||||
} from "./sharing/grants";
|
||||
import type { ShareScope } from "./sharing/scope";
|
||||
|
||||
/**
|
||||
* HTTP methods a read-only credential may use on the REST surface. A request
|
||||
@@ -770,10 +772,20 @@ export interface RecordAuthContext extends AuthContext {
|
||||
* method must BOTH be satisfiable, so a delegable GET handler that grows a
|
||||
* POST export cannot quietly inherit the read grant's permission. An unknown
|
||||
* method (no event context) counts as a write and is refused, the same
|
||||
* fail-closed posture as the MCP audience guard above.
|
||||
* fail-closed posture as the MCP audience guard above. A route declaring
|
||||
* `"manage"` refuses everything below it regardless of method: the method
|
||||
* can only escalate what the declaration asked for, never satisfy it.
|
||||
* @param domain v1.37.0 — which section of the record the route touches, or
|
||||
* `"record"` when it reads across sections. Required, and the requirement is
|
||||
* the fail-closed lever: a delegable route without a classification does not
|
||||
* typecheck, so the set of routes carrying one cannot fall behind the set of
|
||||
* routes that are delegable. The value is frozen per module by
|
||||
* `src/__tests__/delegable-surface-guard.test.ts` and reviewed against the
|
||||
* design's clustering table; this function only enforces what was declared.
|
||||
*/
|
||||
export async function requireRecordAuth(
|
||||
need: GrantNeed,
|
||||
domain: ShareScope,
|
||||
): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
const carrier = await readActingCarrier(auth);
|
||||
@@ -789,6 +801,101 @@ export async function requireRecordAuth(
|
||||
throw refuseUndeclaredMode(auth, carrier, "misplaced_selector");
|
||||
}
|
||||
|
||||
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
|
||||
const effectiveNeed: GrantNeed =
|
||||
need !== "read" || !READ_HTTP_METHODS.has(method) ? escalate(need) : "read";
|
||||
|
||||
return resolveSwitchedRecord(auth, carrier, (grant) => {
|
||||
if (!grantAllows(grant, effectiveNeed)) return "insufficient_access";
|
||||
// Level first, then scope, and the order is visible on the wire in exactly
|
||||
// one way: not at all. Both refusals are the same error with the same
|
||||
// bytes, and only the audit reason differs — so the ordering is a choice
|
||||
// about what the operator's trail says happened, not about what the caller
|
||||
// learns. It says the bigger thing: a delegate who was never given this
|
||||
// level is a different story from one whose sections do not reach here.
|
||||
if (!grantCoversDomain(grant, domain)) return "out_of_scope";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The need a non-safe method implies, for a route that declared `need`.
|
||||
*
|
||||
* A PUT on a route declaring `"read"` needs a write grant, which is what this
|
||||
* has always done. A PUT on a route declaring `"manage"` needs a MANAGE grant
|
||||
* and must not be reduced to a write — so the escalation raises the floor to
|
||||
* `"write"` and never lowers a declaration that already sits above it.
|
||||
*/
|
||||
function escalate(need: GrantNeed): GrantNeed {
|
||||
return need === "manage" ? "manage" : "write";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.0 — a guardian surface: this route administers a managed profile.
|
||||
*
|
||||
* The other declaration, and a separate function rather than a flag on the one
|
||||
* above, for the reason `requireActorAuth` is also separate: the guard freezes
|
||||
* each declaration as its own list, so a route moving between them is a diff a
|
||||
* human reviews rather than an argument about a parameter.
|
||||
*
|
||||
* What it admits is narrower than it looks. An active MANAGE grant is
|
||||
* necessary and NOT sufficient — the record must also carry the managed-profile
|
||||
* marker. That second condition is the identity fence, and it is the line
|
||||
* between "manage my data" and "own my account": an adult who granted
|
||||
* management of their own record reaches none of this, at any level, by any
|
||||
* argument, because their record has no marker and never will while they hold
|
||||
* it. A managed profile has no self to reserve these surfaces for, so its
|
||||
* guardian holds them.
|
||||
*
|
||||
* The fence is gated on the marker rather than on the grant precisely so that
|
||||
* no future change to what MANAGE means can reach through it. Widening MANAGE
|
||||
* would widen `requireRecordAuth`; it would not touch this function.
|
||||
*/
|
||||
export async function requireGuardianAuth(): Promise<RecordAuthContext> {
|
||||
const auth = await authenticateCaller();
|
||||
const carrier = await readActingCarrier(auth);
|
||||
|
||||
if (carrier.kind === "none") {
|
||||
// The owner of an ordinary record, reaching their own settings. Same
|
||||
// do-no-harm posture as the record resolver: without a carrier this is the
|
||||
// request it always was.
|
||||
return { ...auth, actor: auth.user, grantId: null };
|
||||
}
|
||||
if (carrier.kind === "misplaced-header") {
|
||||
throw refuseUndeclaredMode(auth, carrier, "misplaced_selector");
|
||||
}
|
||||
|
||||
return resolveSwitchedRecord(auth, carrier, (grant, owner) => {
|
||||
if (!grantAllows(grant, "manage")) return "insufficient_access";
|
||||
if (owner.managedProfileAt === null) return "guardian_only";
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared body of both record resolvers: turn a selector into an owner, or
|
||||
* refuse.
|
||||
*
|
||||
* Everything either resolver does with a carrier lives here except the
|
||||
* admission rule itself, which arrives as `admit` — a function of the grant
|
||||
* and the owner returning an audit reason to refuse with, or null to proceed.
|
||||
* The split is where it is because the two resolvers differ in exactly that
|
||||
* one decision and in nothing else, and two copies of the grant lookup, the
|
||||
* refusal shape, the usage stamp and the owner's access row would be two
|
||||
* copies to keep in step.
|
||||
*
|
||||
* The owner row is loaded BEFORE the admission runs, which is a change from
|
||||
* v1.36.0's ordering and a deliberate one: the guardian rule is a fact about
|
||||
* the owner, so it cannot be decided before the owner is known. Nothing about
|
||||
* the refusal moved — every arm below is the same error with the same bytes,
|
||||
* and an admission that refuses after the load costs one query it does not use
|
||||
* and tells the caller nothing extra.
|
||||
*/
|
||||
async function resolveSwitchedRecord(
|
||||
auth: AuthContext,
|
||||
carrier: Extract<ActingCarrier, { accountId: string }>,
|
||||
admit: (grant: AccountGrant, owner: User) => string | null,
|
||||
): Promise<RecordAuthContext> {
|
||||
/** Every refusal below is this one error, with the reason kept off the wire. */
|
||||
const denied = (reason: string): SharingAccessDeniedError => {
|
||||
annotate({ meta: { sharing_refusal: reason } });
|
||||
@@ -800,10 +907,6 @@ export async function requireRecordAuth(
|
||||
throw denied("malformed_selector");
|
||||
}
|
||||
|
||||
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
|
||||
const effectiveNeed: GrantNeed =
|
||||
need === "write" || !READ_HTTP_METHODS.has(method) ? "write" : "read";
|
||||
|
||||
// Loaded here, on this request, every request. Not memoised on the session,
|
||||
// not cached in the process, not carried on the token: a revocation has to
|
||||
// land on the delegate's NEXT request, not on their next login. That is the
|
||||
@@ -818,7 +921,6 @@ export async function requireRecordAuth(
|
||||
granteeId: auth.user.id,
|
||||
});
|
||||
if (!grant) throw denied("no_active_grant");
|
||||
if (!grantAllows(grant, effectiveNeed)) throw denied("insufficient_access");
|
||||
|
||||
const owner = await prisma.user.findUnique({
|
||||
where: { id: grant.grantorId },
|
||||
@@ -828,6 +930,9 @@ export async function requireRecordAuth(
|
||||
// to a person is not one to act on.
|
||||
if (!owner) throw denied("owner_missing");
|
||||
|
||||
const refusal = admit(grant, owner);
|
||||
if (refusal !== null) throw denied(refusal);
|
||||
|
||||
getEvent()?.setActingAs(owner.id);
|
||||
// Fire-and-forget, the `ApiToken.lastUsedAt` posture: the read must not wait
|
||||
// on the bookkeeping, and the bookkeeping failing must not fail the read.
|
||||
|
||||
@@ -379,6 +379,8 @@ export const USER_KEPT_FIELDS: Readonly<Record<string, string>> = {
|
||||
oidcSub: "a sign-in credential — the external identity binding",
|
||||
createdAt: "when the account was created; the account is preserved",
|
||||
updatedAt: "row bookkeeping maintained by Prisma",
|
||||
managedProfileAt:
|
||||
"whether this record is one somebody else runs — account shape, not record content. Clearing it would strip the guardian's reach from a record that has no credentials of its own, leaving an account nobody can open, administer or delete. Emptying a record is not emancipating the person in it.",
|
||||
totpSecretEncrypted: "a second-factor credential",
|
||||
totpConfirmedAt: "second-factor enrolment state",
|
||||
totpLastStep:
|
||||
|
||||
@@ -15,12 +15,16 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
grantAllows,
|
||||
grantCoversDomain,
|
||||
grantState,
|
||||
inviteGrant,
|
||||
isGrantActive,
|
||||
normaliseScope,
|
||||
type GrantLifecycle,
|
||||
} from "@/lib/sharing/grants";
|
||||
import type { AccountGrant, Prisma } from "@/generated/prisma/client";
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type { AccountGrant } from "@/generated/prisma/client";
|
||||
|
||||
const NOW = new Date("2026-08-02T12:00:00.000Z");
|
||||
const EARLIER = new Date("2026-08-01T12:00:00.000Z");
|
||||
@@ -151,17 +155,28 @@ describe("grantAllows", () => {
|
||||
describe("inviteGrant", () => {
|
||||
function captureDb(): {
|
||||
db: Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
writes: Array<{ access: string; grantorId: string; granteeId: string }>;
|
||||
writes: Array<{
|
||||
access: string;
|
||||
grantorId: string;
|
||||
granteeId: string;
|
||||
scopeJson: unknown;
|
||||
}>;
|
||||
} {
|
||||
const writes: Array<{
|
||||
access: string;
|
||||
grantorId: string;
|
||||
granteeId: string;
|
||||
scopeJson: unknown;
|
||||
}> = [];
|
||||
const db = {
|
||||
accountGrant: {
|
||||
create: async (args: {
|
||||
data: { access: string; grantorId: string; granteeId: string };
|
||||
data: {
|
||||
access: string;
|
||||
grantorId: string;
|
||||
granteeId: string;
|
||||
scopeJson: unknown;
|
||||
};
|
||||
}) => {
|
||||
writes.push(args.data);
|
||||
return { id: "grant-1", ...args.data } as unknown as AccountGrant;
|
||||
@@ -175,7 +190,7 @@ describe("inviteGrant", () => {
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
const { db, writes } = captureDb();
|
||||
const row = await inviteGrant(
|
||||
{ grantorId: "owner", granteeId: "delegate", access },
|
||||
{ grantorId: "owner", granteeId: "delegate", access, scope: null },
|
||||
db,
|
||||
);
|
||||
expect(writes).toHaveLength(1);
|
||||
@@ -188,7 +203,7 @@ describe("inviteGrant", () => {
|
||||
const { db, writes } = captureDb();
|
||||
await expect(
|
||||
inviteGrant(
|
||||
{ grantorId: "solo", granteeId: "solo", access: "WRITE" },
|
||||
{ grantorId: "solo", granteeId: "solo", access: "WRITE", scope: null },
|
||||
db,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "self_grant" });
|
||||
@@ -212,3 +227,241 @@ describe("inviteGrant", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.37.0 — the third level.
|
||||
*
|
||||
* `grantAllows` is a total order over three levels now, and the one comparison
|
||||
* that carries the level's meaning is the one that must NOT hold: an accepted
|
||||
* WRITE grant carries a consent to add, and nothing about it carries a consent
|
||||
* to rewrite or remove.
|
||||
*/
|
||||
describe("grantAllows, at MANAGE", () => {
|
||||
it("lets a MANAGE grant do all three", () => {
|
||||
const manage = { ...grant(), access: "MANAGE" as const };
|
||||
expect(grantAllows(manage, "read", NOW)).toBe(true);
|
||||
expect(grantAllows(manage, "write", NOW)).toBe(true);
|
||||
expect(grantAllows(manage, "manage", NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a WRITE grant the manage need", () => {
|
||||
const write = { ...grant(), access: "WRITE" as const };
|
||||
expect(grantAllows(write, "write", NOW)).toBe(true);
|
||||
expect(grantAllows(write, "manage", NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a READ grant the manage need", () => {
|
||||
expect(grantAllows({ ...grant(), access: "READ" }, "manage", NOW)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses every need on a MANAGE grant that is not active", () => {
|
||||
// The state outranks the level, at the top of the order as at the bottom.
|
||||
for (const ended of [
|
||||
grant({ revokedAt: EARLIER }),
|
||||
grant({ acceptedAt: null }),
|
||||
grant({ expiresAt: EARLIER }),
|
||||
]) {
|
||||
const dead = { ...ended, access: "MANAGE" as const };
|
||||
expect(grantAllows(dead, "read", NOW)).toBe(false);
|
||||
expect(grantAllows(dead, "write", NOW)).toBe(false);
|
||||
expect(grantAllows(dead, "manage", NOW)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The fail-closed normaliser.
|
||||
*
|
||||
* This is the one function in the feature whose failure mode is silent and
|
||||
* wide: a value it misreads as permissive opens sections the owner never
|
||||
* ticked, on every request, with nothing to see. So the tests below are
|
||||
* mostly about garbage, and every one of them asserts the same thing — that
|
||||
* a value the function cannot read opens NOTHING.
|
||||
*/
|
||||
describe("normaliseScope", () => {
|
||||
it("reads NULL as the entire record", () => {
|
||||
// Not a default standing in for a missing answer: it is the answer every
|
||||
// grant written before the column existed was consented as.
|
||||
expect(normaliseScope(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads a well-formed array as exactly those sections", () => {
|
||||
const scope = normaliseScope(["labs", "medications"]);
|
||||
expect(scope).not.toBeNull();
|
||||
expect([...(scope as ReadonlySet<string>)].sort()).toEqual([
|
||||
"labs",
|
||||
"medications",
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts every member of the production vocabulary", () => {
|
||||
// Non-zero proof for the negatives below: the function CAN say yes, to
|
||||
// each of the eight, so a wholesale refusal would fail here rather than
|
||||
// read as caution.
|
||||
expect(SHARE_DOMAINS.length).toBeGreaterThan(0);
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect([...(normaliseScope([domain]) as ReadonlySet<string>)]).toEqual([
|
||||
domain,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves every malformed value to the empty set", () => {
|
||||
const garbage: Prisma.JsonValue[] = [
|
||||
"garbage",
|
||||
"",
|
||||
42,
|
||||
true,
|
||||
false,
|
||||
{},
|
||||
{ measurements: true },
|
||||
[],
|
||||
["measurements", "not-a-domain"],
|
||||
["not-a-domain"],
|
||||
["record"],
|
||||
[null],
|
||||
[1],
|
||||
[["measurements"]],
|
||||
[{ key: "measurements" }],
|
||||
];
|
||||
|
||||
for (const blob of garbage) {
|
||||
const scope = normaliseScope(blob);
|
||||
// Not null — null is "everything", and that is the answer this function
|
||||
// must never give to something it could not read.
|
||||
expect(scope, JSON.stringify(blob)).not.toBeNull();
|
||||
expect((scope as ReadonlySet<string>).size, JSON.stringify(blob)).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("gives no partial credit to a half-recognisable set", () => {
|
||||
// The tempting reading is "honour what we understand". It is the wrong
|
||||
// one: a set written in a shape this file does not understand is not a
|
||||
// consent to guess at.
|
||||
const scope = normaliseScope(["labs", "medications", "unknown"]);
|
||||
expect((scope as ReadonlySet<string>).has("labs")).toBe(false);
|
||||
expect((scope as ReadonlySet<string>).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("grantCoversDomain", () => {
|
||||
it("covers every section, and the record, when the scope is NULL", () => {
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(grantCoversDomain({ scopeJson: null }, domain)).toBe(true);
|
||||
}
|
||||
expect(grantCoversDomain({ scopeJson: null }, "record")).toBe(true);
|
||||
});
|
||||
|
||||
it("covers the sections it names and no others", () => {
|
||||
const scoped = { scopeJson: ["medications"] };
|
||||
expect(grantCoversDomain(scoped, "medications")).toBe(true);
|
||||
expect(grantCoversDomain(scoped, "measurements")).toBe(false);
|
||||
expect(grantCoversDomain(scoped, "labs")).toBe(false);
|
||||
});
|
||||
|
||||
it("never covers the record, however wide the scope", () => {
|
||||
// The invariant. A route declaring `record` reads across sections, and
|
||||
// there is no honest answer to 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. Even a scope naming
|
||||
// all eight sections is refused: it is a narrowing, and a narrowing is
|
||||
// not the whole.
|
||||
expect(grantCoversDomain({ scopeJson: [...SHARE_DOMAINS] }, "record")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("covers nothing at all when the stored scope is unreadable", () => {
|
||||
const broken = { scopeJson: "garbage" };
|
||||
for (const domain of SHARE_DOMAINS) {
|
||||
expect(grantCoversDomain(broken, domain)).toBe(false);
|
||||
}
|
||||
expect(grantCoversDomain(broken, "record")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not cover a section that postdates the invitation", () => {
|
||||
// A grant written when the vocabulary was shorter opens what it named and
|
||||
// nothing added since, which is what makes a new section a consent
|
||||
// question rather than a schema question.
|
||||
expect(grantCoversDomain({ scopeJson: ["labs"] }, "cycle")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inviteGrant, on scope", () => {
|
||||
function captureScopeDb(): {
|
||||
db: Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
writes: Array<{ scopeJson: unknown }>;
|
||||
} {
|
||||
const writes: Array<{ scopeJson: unknown }> = [];
|
||||
const db = {
|
||||
accountGrant: {
|
||||
create: async (args: { data: { scopeJson: unknown } }) => {
|
||||
writes.push(args.data);
|
||||
return { id: "grant-1", ...args.data } as unknown as AccountGrant;
|
||||
},
|
||||
},
|
||||
} as unknown as Pick<Prisma.TransactionClient, "accountGrant">;
|
||||
return { db, writes };
|
||||
}
|
||||
|
||||
const pair = { grantorId: "owner", granteeId: "delegate" } as const;
|
||||
|
||||
it("writes SQL NULL, not the JSON value null, for the entire record", () => {
|
||||
// One keystroke apart and opposite in meaning: a stored JSON `null` is not
|
||||
// an array, so it would resolve to the empty set — a grant that opens
|
||||
// nothing, written by the path meant to open everything.
|
||||
const { db, writes } = captureScopeDb();
|
||||
return inviteGrant({ ...pair, access: "READ", scope: null }, db).then(
|
||||
() => {
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].scopeJson).toBe(Prisma.DbNull);
|
||||
expect(writes[0].scopeJson).not.toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("writes the sections it was given, deduplicated", async () => {
|
||||
const { db, writes } = captureScopeDb();
|
||||
await inviteGrant(
|
||||
{ ...pair, access: "WRITE", scope: ["labs", "medications", "labs"] },
|
||||
db,
|
||||
);
|
||||
expect(writes[0].scopeJson).toEqual(["labs", "medications"]);
|
||||
});
|
||||
|
||||
it("refuses a scope that cannot mean anything, before anything is written", async () => {
|
||||
const refused = [
|
||||
{ access: "READ" as const, scope: [] },
|
||||
{
|
||||
access: "READ" as const,
|
||||
scope: ["not-a-domain"] as unknown as ["labs"],
|
||||
},
|
||||
{ access: "READ" as const, scope: ["record"] as unknown as ["labs"] },
|
||||
// Whole-record by construction: management of part of a record is a
|
||||
// boundary the product does not promise, so this file does not store it.
|
||||
{ access: "MANAGE" as const, scope: ["labs" as const] },
|
||||
];
|
||||
|
||||
for (const input of refused) {
|
||||
const { db, writes } = captureScopeDb();
|
||||
await expect(
|
||||
inviteGrant({ ...pair, ...input }, db),
|
||||
JSON.stringify(input),
|
||||
).rejects.toMatchObject({ code: "invalid_scope" });
|
||||
// The refusal did not apply: nothing reached the table.
|
||||
expect(writes, JSON.stringify(input)).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("still mints a MANAGE grant when no scope is named", async () => {
|
||||
// The negative above is about the pairing, not about the level. Without
|
||||
// this leg the four refusals could all be "MANAGE is refused", which is
|
||||
// not the rule.
|
||||
const { db, writes } = captureScopeDb();
|
||||
await inviteGrant({ ...pair, access: "MANAGE", scope: null }, db);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0].scopeJson).toBe(Prisma.DbNull);
|
||||
});
|
||||
});
|
||||
+176
-11
@@ -37,11 +37,13 @@
|
||||
* the shape it does. Re-inviting the same person mints a NEW row; the
|
||||
* partial unique index in migration 0292 keeps at most one LIVE row per
|
||||
* pair while the history piles up underneath.
|
||||
* * **The level never widens.** A row is READ or WRITE from the moment it is
|
||||
* offered, and no transition here raises it. Widening a grant somebody has
|
||||
* already accepted would change what they agreed to without asking them
|
||||
* again, and the delegate's consent is half of what makes a write grant
|
||||
* legitimate. {@link inviteGrant} carries the reasoning.
|
||||
* * **The level never widens, and neither does the scope.** A row is READ,
|
||||
* WRITE or MANAGE from the moment it is offered, over the sections it
|
||||
* named at that moment, and no transition here raises either. Widening a
|
||||
* grant somebody has already accepted would change what they agreed to
|
||||
* without asking them again, and the delegate's consent is half of what
|
||||
* makes a write grant legitimate. {@link inviteGrant} carries the
|
||||
* reasoning.
|
||||
*
|
||||
* Every write below is a CONDITIONAL update — the state the transition
|
||||
* requires is in the `where`, not in a read the caller performed a moment
|
||||
@@ -52,10 +54,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { clearActingSessions } from "@/lib/sharing/acting-session";
|
||||
import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope";
|
||||
import type { ShareDomain, ShareScope } from "@/lib/sharing/scope";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import type {
|
||||
AccountGrant,
|
||||
AccountGrantAccess,
|
||||
Prisma,
|
||||
} from "@/generated/prisma/client";
|
||||
|
||||
/** The slice of a grant row the pure predicates read. */
|
||||
@@ -77,7 +81,7 @@ export interface GrantLifecycle {
|
||||
export type GrantState = "REVOKED" | "EXPIRED" | "PENDING" | "ACTIVE";
|
||||
|
||||
/** What a caller needs the grant to permit. */
|
||||
export type GrantNeed = "read" | "write";
|
||||
export type GrantNeed = "read" | "write" | "manage";
|
||||
|
||||
export type GrantErrorCode =
|
||||
/** An account cannot share its record with itself. */
|
||||
@@ -95,7 +99,13 @@ export type GrantErrorCode =
|
||||
/** Accept ran against an invitation that had already lapsed. */
|
||||
| "expired"
|
||||
/** Revoke or renounce ran against a grant that was already ended. */
|
||||
| "already_revoked";
|
||||
| "already_revoked"
|
||||
/**
|
||||
* v1.37.0 — the invitation named a scope that cannot mean anything: an
|
||||
* empty array, a key outside the vocabulary, or any scope at all on a
|
||||
* MANAGE invitation, which is whole-record by construction.
|
||||
*/
|
||||
| "invalid_scope";
|
||||
|
||||
/**
|
||||
* A refused transition, with a stable code.
|
||||
@@ -146,13 +156,42 @@ export function isGrantActive(
|
||||
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. The state outranks the level in both directions: a
|
||||
* revoked WRITE grant permits nothing at all, and no level survives an expiry.
|
||||
* 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 },
|
||||
@@ -160,7 +199,95 @@ export function grantAllows(
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!isGrantActive(grant, now)) return false;
|
||||
return need === "read" ? true : grant.access === "WRITE";
|
||||
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;
|
||||
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 ───────────────────────────────────────────────────
|
||||
@@ -178,6 +305,22 @@ export interface InviteGrantInput {
|
||||
* how they disagree later.
|
||||
*/
|
||||
access: AccountGrantAccess;
|
||||
/**
|
||||
* v1.37.0 — which sections the invitation opens. `null` is the entire
|
||||
* record, and it is a choice rather than an omission: same no-default
|
||||
* posture as `access` above, for the same reason. A scope nobody named is a
|
||||
* scope nobody chose, and two modules that both default it are two modules
|
||||
* that will disagree about it later.
|
||||
*
|
||||
* Refused here, as `invalid_scope`: an empty array (a grant that opens
|
||||
* nothing is not a grant, it is a mistake wearing one), a key outside the
|
||||
* vocabulary, and any scope at all beside MANAGE. The request-shape
|
||||
* validator refuses the same three earlier and more legibly; this is the
|
||||
* floor under it, because the column's meaning is decided in this file and
|
||||
* a caller that never went through a Zod schema must not be able to write a
|
||||
* row the resolver would have to interpret.
|
||||
*/
|
||||
scope: ShareDomain[] | null;
|
||||
/** Optional lapse date. Null = the grant runs until somebody ends it. */
|
||||
expiresAt?: Date | null;
|
||||
}
|
||||
@@ -209,6 +352,17 @@ export async function inviteGrant(
|
||||
throw new GrantError("self_grant");
|
||||
}
|
||||
|
||||
const scope = input.scope;
|
||||
if (scope !== null) {
|
||||
// Whole-record by construction, and the refusal is here rather than in a
|
||||
// comment: "they can do anything, but only to part of you" promises a
|
||||
// boundary that cannot survive an edit, so the product does not offer it
|
||||
// and this file does not store it.
|
||||
if (input.access === "MANAGE") throw new GrantError("invalid_scope");
|
||||
if (scope.length === 0) throw new GrantError("invalid_scope");
|
||||
if (!scope.every(isShareDomain)) throw new GrantError("invalid_scope");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
try {
|
||||
return await db.accountGrant.create({
|
||||
@@ -216,6 +370,17 @@ export async function inviteGrant(
|
||||
grantorId: input.grantorId,
|
||||
granteeId: input.granteeId,
|
||||
access: input.access,
|
||||
// `DbNull` and not `null`: on a nullable Json column Prisma reads a
|
||||
// bare `null` as the JSON value `null`, which is a stored blob that
|
||||
// is not an array and would resolve through `normaliseScope` to the
|
||||
// empty set — a grant that opens nothing, written by the path meant
|
||||
// to open everything. The two nulls are one keystroke apart and mean
|
||||
// opposite things here.
|
||||
//
|
||||
// Deduplicated on the way in: a set the owner ticked twice is one
|
||||
// section, and storing it twice would make two rows with the same
|
||||
// meaning look different to anything that reads them back.
|
||||
scopeJson: scope === null ? Prisma.DbNull : [...new Set(scope)],
|
||||
invitedAt: now,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* v1.37.0 — the vocabulary a scoped grant is written in.
|
||||
*
|
||||
* A grant may open the whole record, or it may open some of it. This file
|
||||
* holds the closed set of names the second case is allowed to use, and it
|
||||
* holds nothing else: no predicate, no storage, no route knowledge. The
|
||||
* predicates live in `grants.ts` beside the rest of what a grant means, and
|
||||
* the per-route classification lives on the call sites, frozen by
|
||||
* `src/__tests__/delegable-surface-guard.test.ts`.
|
||||
*
|
||||
* Two properties are the whole reason the file exists separately.
|
||||
*
|
||||
* * **It is a sharing vocabulary, not the module vocabulary.** There is no
|
||||
* conversion to `ModuleKey`, no import from `src/lib/modules/`, and there
|
||||
* never is one. The two masks answer different questions — "did the owner
|
||||
* turn this part of the product on" and "did the owner open this part of
|
||||
* their record to that person" — and a route carrying both runs both. The
|
||||
* day they share a spelling is the day somebody makes one of them imply
|
||||
* the other, and the implication would run in the dangerous direction: a
|
||||
* module the owner enabled is not a section the owner shared.
|
||||
* * **The names are the ones a consent screen can say out loud.** They were
|
||||
* derived by clustering the delegable read surfaces the product already
|
||||
* has, not by inventing categories, so each one is a thing a person
|
||||
* recognises as one thing. Finer splits subjects nobody separates (lab
|
||||
* results from the analyte list); coarser re-merges the exact split
|
||||
* somebody asked for.
|
||||
*
|
||||
* A stored scope never grows by release. A grant written when this list held
|
||||
* eight names opens those of the eight it named and nothing added later: the
|
||||
* set is stored as the keys the owner ticked, and a key that did not exist
|
||||
* when they ticked cannot be in it. That is why a new domain is a consent
|
||||
* question rather than a schema question.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The sections of a record a grant can be narrowed to.
|
||||
*
|
||||
* Order is the consent screen's reading order, not an ordering of weight —
|
||||
* nothing here ranks above anything else, and no code may treat the index as
|
||||
* meaning anything.
|
||||
*/
|
||||
export const SHARE_DOMAINS = [
|
||||
/** Readings: weight, blood pressure, pulse, glucose, sleep, workouts, nutrients, tracked values. */
|
||||
"measurements",
|
||||
/** The medication list, its schedules, doses, side effects and inventory. */
|
||||
"medications",
|
||||
/** Lab results and the biomarker catalogue behind them. */
|
||||
"labs",
|
||||
/** Health background: allergies, family history, anamnesis facts. */
|
||||
"profile",
|
||||
/** Illness episodes and the day logs under them. */
|
||||
"illness",
|
||||
/** Mood entries and mental-health screener history. */
|
||||
"mind",
|
||||
/** Cycle tracking. */
|
||||
"cycle",
|
||||
/** The document vault. */
|
||||
"documents",
|
||||
] as const;
|
||||
|
||||
/** One section of a record. */
|
||||
export type ShareDomain = (typeof SHARE_DOMAINS)[number];
|
||||
|
||||
/**
|
||||
* The distinguished value for "the entire record", which is what every grant
|
||||
* written before this release was consented as.
|
||||
*
|
||||
* It is deliberately NOT a member of {@link SHARE_DOMAINS}: a scoped grant
|
||||
* naming it would be a scope that means "no scope", and the one thing a
|
||||
* narrowing must never be able to express is its own absence. A route
|
||||
* declaring `record` is a route that reads across sections, and a scoped
|
||||
* grant never reaches one.
|
||||
*/
|
||||
export const ENTIRE_RECORD = "record";
|
||||
|
||||
/**
|
||||
* What a delegable route declares it touches: one section, or the record as a
|
||||
* whole.
|
||||
*/
|
||||
export type ShareScope = ShareDomain | typeof ENTIRE_RECORD;
|
||||
|
||||
const DOMAIN_SET: ReadonlySet<string> = new Set<string>(SHARE_DOMAINS);
|
||||
|
||||
/**
|
||||
* Is this a section name?
|
||||
*
|
||||
* The one place an untrusted string becomes a {@link ShareDomain}. `record`
|
||||
* answers false here on purpose — see {@link ENTIRE_RECORD}.
|
||||
*/
|
||||
export function isShareDomain(value: unknown): value is ShareDomain {
|
||||
return typeof value === "string" && DOMAIN_SET.has(value);
|
||||
}
|
||||
@@ -100,6 +100,7 @@ async function acceptedGrant() {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
@@ -123,6 +124,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
expect(invited.acceptedAt).toBeNull();
|
||||
expect(invited.access).toBe("READ");
|
||||
@@ -162,6 +164,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "WRITE",
|
||||
scope: null,
|
||||
});
|
||||
const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({
|
||||
where: { id: invited.id },
|
||||
@@ -185,6 +188,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: solo.id,
|
||||
granteeId: solo.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "self_grant" });
|
||||
expect(await getPrismaClient().accountGrant.count()).toBe(0);
|
||||
@@ -199,6 +203,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -238,6 +243,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
});
|
||||
|
||||
@@ -253,6 +259,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
||||
@@ -373,6 +380,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -380,6 +388,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(GrantError);
|
||||
await expect(
|
||||
@@ -387,6 +396,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "duplicate_live_grant" });
|
||||
expect(await getPrismaClient().accountGrant.count()).toBe(1);
|
||||
@@ -403,6 +413,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(second.acceptedAt).toBeNull();
|
||||
@@ -436,6 +447,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
await revokeGrant({ grantId: invited.id, grantorId: owner.id });
|
||||
}
|
||||
@@ -443,6 +455,7 @@ describe("account grant lifecycle", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
expect(
|
||||
await getPrismaClient().accountGrant.count({
|
||||
@@ -541,6 +554,7 @@ describe("account grant lifecycle paths", () => {
|
||||
grantorId: admin.id,
|
||||
granteeId: live.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
const liveGrant = await acceptGrant({
|
||||
grantId: liveInvite.id,
|
||||
@@ -550,6 +564,7 @@ describe("account grant lifecycle paths", () => {
|
||||
grantorId: admin.id,
|
||||
granteeId: dropped.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id });
|
||||
const revoked = await revokeGrant({
|
||||
|
||||
@@ -130,7 +130,10 @@ async function seedWeight(userId: string, value: number) {
|
||||
*/
|
||||
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { user, actor, grantId } = await requireRecordAuth("read");
|
||||
const { user, actor, grantId } = await requireRecordAuth(
|
||||
"read",
|
||||
"measurements",
|
||||
);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { value: "asc" },
|
||||
@@ -163,6 +166,7 @@ async function household() {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
@@ -262,6 +266,7 @@ describe("acting-account resolver — the decision is never cached", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
expiresAt: new Date(Date.now() + 400),
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
||||
@@ -281,6 +286,7 @@ describe("acting-account resolver — the decision is never cached", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
const session = await signIn(delegate.id);
|
||||
await switchTo(session.id, owner.id);
|
||||
|
||||
@@ -109,7 +109,7 @@ let handlerRuns = 0;
|
||||
const delegableWrite: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
withIdempotency<[NextRequest]>(async () => {
|
||||
handlerRuns += 1;
|
||||
const { user } = await requireRecordAuth("write");
|
||||
const { user } = await requireRecordAuth("write", "measurements");
|
||||
const row = await prisma.measurement.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -76,7 +76,7 @@ async function signIn(userId: string) {
|
||||
*/
|
||||
const delegableAction: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
await auditLog("measurement.read", {
|
||||
userId: user.id,
|
||||
details: { via: "test" },
|
||||
@@ -99,6 +99,7 @@ async function household() {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: delegate.id });
|
||||
const session = await signIn(delegate.id);
|
||||
@@ -162,7 +163,7 @@ describe("who the trail says did it", () => {
|
||||
|
||||
const handler: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { actor } = await requireRecordAuth("read");
|
||||
const { actor } = await requireRecordAuth("read", "measurements");
|
||||
await auditLog("delegate.own.act", { userId: actor.id });
|
||||
return NextResponse.json({ data: { ok: true }, error: null });
|
||||
},
|
||||
@@ -225,6 +226,7 @@ describe("delegated reads coalesce to one row a day", () => {
|
||||
grantorId: owner.id,
|
||||
granteeId: second.id,
|
||||
access: "READ",
|
||||
scope: null,
|
||||
});
|
||||
await acceptGrant({ grantId: invited.id, granteeId: second.id });
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ async function switchInto(
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access,
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
|
||||
@@ -98,6 +98,7 @@ async function switchInto(
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access,
|
||||
scope: null,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
|
||||
@@ -155,7 +155,7 @@ async function switchTo(accountId: string | null): Promise<Response> {
|
||||
/** A delegable read, exactly as a migrated route builds one. */
|
||||
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
|
||||
async () => {
|
||||
const { user, actor } = await requireRecordAuth("read");
|
||||
const { user, actor } = await requireRecordAuth("read", "measurements");
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { value: "asc" },
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* v1.37.0 — the section fence, driven as routes against real Postgres.
|
||||
*
|
||||
* A scoped grant opens the sections it names and leaves the rest unanswered.
|
||||
* That sentence is one line of code in the resolver and fifty-seven
|
||||
* classifications on the call sites, and the classifications are the part a
|
||||
* machine cannot check: the guard freezes the domain a module declares against
|
||||
* a literal transcribed from the same table a human read, so a domain assigned
|
||||
* wrongly in both places agrees with itself perfectly. It would look like
|
||||
* nothing, anywhere, while a delegate scoped to medications quietly read the
|
||||
* labs.
|
||||
*
|
||||
* So this file drives one representative route per section, in-scope and
|
||||
* out-of-scope, through the shipped GET exports and the shipped grant
|
||||
* transitions. Sixteen legs plus the record-wide pair. What that buys is not
|
||||
* proof that every one of the fifty-seven is right — it is proof that the
|
||||
* mechanism separates the sections at all, per section, so a wholesale failure
|
||||
* (the resolver ignoring the scope, the normaliser reading a set as
|
||||
* permissive, one section's routes classified into another's) cannot be
|
||||
* silent. The per-module classification stays a review question and says so in
|
||||
* the guard.
|
||||
*
|
||||
* Evidence over verdict, throughout: the in-scope leg asserts the route was
|
||||
* REACHED, the out-of-scope leg asserts the refusal AND its audit reason. A
|
||||
* file that only asserted the refusals would pass against a resolver that
|
||||
* refused everything.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
let counter = 0;
|
||||
|
||||
async function makeUser(label: string) {
|
||||
const suffix = `${label}-${counter++}`;
|
||||
const user = await getPrismaClient().user.create({
|
||||
data: {
|
||||
username: `scope-${suffix}`,
|
||||
email: `scope-${suffix}@example.test`,
|
||||
displayName: `Scope ${suffix}`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
// The cycle probe sits behind its own module gate, which derives from
|
||||
// the account's gender when the toggle is NULL. Opted in explicitly so
|
||||
// that a 403 from that route means the section fence and not the module
|
||||
// gate — two masks, both real, and this file is about one of them.
|
||||
gender: "FEMALE",
|
||||
},
|
||||
});
|
||||
await getPrismaClient().cycleProfile.create({
|
||||
data: { userId: user.id, cycleTrackingEnabled: true },
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
async function signIn(userId: string) {
|
||||
return getPrismaClient().session.create({
|
||||
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A live, accepted grant with the delegate's browser already inside the
|
||||
* owner's record.
|
||||
*
|
||||
* Minted through the shipped `inviteGrant` / `acceptGrant` rather than by
|
||||
* writing the row: the scope column's write path is part of what is under
|
||||
* test, and a row this file wrote itself would keep passing after the invite
|
||||
* path stopped storing what it was given.
|
||||
*/
|
||||
async function switchInto(
|
||||
ownerId: string,
|
||||
delegateId: string,
|
||||
scope: ShareDomain[] | null,
|
||||
) {
|
||||
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
|
||||
const invited = await inviteGrant({
|
||||
grantorId: ownerId,
|
||||
granteeId: delegateId,
|
||||
access: "READ",
|
||||
scope,
|
||||
});
|
||||
const grant = await acceptGrant({
|
||||
grantId: invited.id,
|
||||
granteeId: delegateId,
|
||||
});
|
||||
const session = await signIn(delegateId);
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: ownerId },
|
||||
});
|
||||
return grant;
|
||||
}
|
||||
|
||||
type Handler = (
|
||||
request: NextRequest,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
context: { params: Promise<any> },
|
||||
) => Promise<Response>;
|
||||
|
||||
async function drive(handler: Handler, url: string): Promise<Response> {
|
||||
return handler(new NextRequest(`http://localhost${url}`, { method: "GET" }), {
|
||||
params: Promise.resolve({}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The sharing refusals filed for this caller so far.
|
||||
*
|
||||
* The resolver writes them fire-and-forget — the 403 is what protects the
|
||||
* record and an audit write that failed must never turn it into a 500 — so
|
||||
* the row lands a moment after the response does. Everything here therefore
|
||||
* settles first. Without the settle the positive legs below would read an
|
||||
* empty table and agree with themselves, which is the exact failure this
|
||||
* repository keeps rediscovering: a check that cannot fail.
|
||||
*/
|
||||
async function refusals(callerId: string): Promise<{ reason?: string }[]> {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
const rows = await getPrismaClient().auditLog.findMany({
|
||||
where: { userId: callerId, action: "sharing.access.denied" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
// `details` is stored as a JSON STRING on this table, not a JSON object —
|
||||
// matching it as an object would compare against a string and fail, or, with
|
||||
// a looser matcher, pass against anything.
|
||||
return rows.map((row) =>
|
||||
typeof row.details === "string"
|
||||
? (JSON.parse(row.details) as { reason?: string })
|
||||
: ((row.details ?? {}) as { reason?: string }),
|
||||
);
|
||||
}
|
||||
|
||||
/** The reason on the most recent one, or null when there is none. */
|
||||
async function lastRefusal(
|
||||
callerId: string,
|
||||
): Promise<{ reason?: string } | null> {
|
||||
const rows = await refusals(callerId);
|
||||
return rows.length === 0 ? null : rows[rows.length - 1];
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* One representative route per section */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface Probe {
|
||||
url: string;
|
||||
call: () => Promise<Response>;
|
||||
}
|
||||
|
||||
const PROBES: Record<ShareDomain | "record", Probe> = {
|
||||
measurements: {
|
||||
url: "/api/measurements",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/measurements/route")).GET as Handler,
|
||||
"/api/measurements",
|
||||
),
|
||||
},
|
||||
medications: {
|
||||
url: "/api/medications",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/medications/route")).GET as Handler,
|
||||
"/api/medications",
|
||||
),
|
||||
},
|
||||
labs: {
|
||||
url: "/api/labs",
|
||||
call: async () =>
|
||||
drive((await import("@/app/api/labs/route")).GET as Handler, "/api/labs"),
|
||||
},
|
||||
profile: {
|
||||
url: "/api/allergies",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/allergies/route")).GET as Handler,
|
||||
"/api/allergies",
|
||||
),
|
||||
},
|
||||
illness: {
|
||||
url: "/api/illness/episodes",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/illness/episodes/route")).GET as Handler,
|
||||
"/api/illness/episodes",
|
||||
),
|
||||
},
|
||||
mind: {
|
||||
url: "/api/mood-entries",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/mood-entries/route")).GET as Handler,
|
||||
"/api/mood-entries",
|
||||
),
|
||||
},
|
||||
cycle: {
|
||||
url: "/api/cycle/cycles",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/cycle/cycles/route")).GET as Handler,
|
||||
"/api/cycle/cycles",
|
||||
),
|
||||
},
|
||||
documents: {
|
||||
url: "/api/documents/inbound",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/documents/inbound/route")).GET as Handler,
|
||||
"/api/documents/inbound",
|
||||
),
|
||||
},
|
||||
record: {
|
||||
url: "/api/dashboard/widgets",
|
||||
call: async () =>
|
||||
drive(
|
||||
(await import("@/app/api/dashboard/widgets/route")).GET as Handler,
|
||||
"/api/dashboard/widgets",
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("the probe set covers the vocabulary", () => {
|
||||
it("has one route per section, plus the record-wide case", () => {
|
||||
// The non-zero half. Every leg below iterates this table, so a table that
|
||||
// lost a section would silently stop testing it — and a section added to
|
||||
// the production enum without a probe would go untested from birth.
|
||||
expect(SHARE_DOMAINS.length).toBeGreaterThan(0);
|
||||
expect(Object.keys(PROBES).sort()).toEqual(
|
||||
[...SHARE_DOMAINS, "record"].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a scoped grant opens the sections it names", () => {
|
||||
it.each(SHARE_DOMAINS)("%s answers when the grant names it", async (open) => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await switchInto(owner.id, delegate.id, [open]);
|
||||
|
||||
const response = await PROBES[open].call();
|
||||
|
||||
// Reached, not merely "not 403": a route that answered 500 would satisfy
|
||||
// an inequality against 403 and prove nothing about the fence.
|
||||
expect(response.status, PROBES[open].url).toBe(200);
|
||||
expect(await lastRefusal(delegate.id)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(SHARE_DOMAINS)(
|
||||
"%s is the only section that answers",
|
||||
async (open) => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await switchInto(owner.id, delegate.id, [open]);
|
||||
|
||||
const closed = [...SHARE_DOMAINS, "record" as const].filter(
|
||||
(d) => d !== open,
|
||||
);
|
||||
// Non-zero: the loop below must actually run.
|
||||
expect(closed.length).toBeGreaterThan(0);
|
||||
|
||||
for (const domain of closed) {
|
||||
const response = await PROBES[domain].call();
|
||||
expect(response.status, PROBES[domain].url).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.meta?.errorCode, PROBES[domain].url).toBe(
|
||||
"sharing.access.denied",
|
||||
);
|
||||
// The reason is the audit row's, never the caller's. If the section
|
||||
// ever reaches the wire this assertion is the one that has to move,
|
||||
// which is the point of asserting it here rather than on the body.
|
||||
expect(
|
||||
await lastRefusal(delegate.id),
|
||||
PROBES[domain].url,
|
||||
).toMatchObject({ reason: "out_of_scope" });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("a NULL scope still opens the whole record", () => {
|
||||
it.each([...SHARE_DOMAINS, "record" as const])(
|
||||
"%s answers exactly as it did before scoping existed",
|
||||
async (domain) => {
|
||||
// The no-regression leg. Every grant in the product today is this one,
|
||||
// and the release is byte-identical for all of them.
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await switchInto(owner.id, delegate.id, null);
|
||||
|
||||
const response = await PROBES[domain].call();
|
||||
expect(response.status, PROBES[domain].url).toBe(200);
|
||||
expect(await lastRefusal(delegate.id)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("an unreadable scope opens nothing", () => {
|
||||
it("refuses every section, including one the garbage names", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const grant = await switchInto(owner.id, delegate.id, ["medications"]);
|
||||
|
||||
// Planted directly, because the invite path refuses to write it — which is
|
||||
// the point: the read side has to hold on its own, against a value that
|
||||
// reached the column by a route this application does not have (a restore
|
||||
// from an older shape, a hand-edited row, a future writer with a bug).
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`UPDATE account_grants SET scope_json = '"medications"'::jsonb WHERE id = $1`,
|
||||
grant.id,
|
||||
);
|
||||
|
||||
for (const domain of [...SHARE_DOMAINS, "record" as const]) {
|
||||
const response = await PROBES[domain].call();
|
||||
expect(response.status, PROBES[domain].url).toBe(403);
|
||||
expect(await lastRefusal(delegate.id), PROBES[domain].url).toMatchObject({
|
||||
reason: "out_of_scope",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses even the section a well-formed set would have opened", async () => {
|
||||
// The sharpest form of the same claim, and the one that separates
|
||||
// fail-closed from fail-open: the string spells a real section name. A
|
||||
// normaliser that reached for anything it recognised inside a value it
|
||||
// could not parse would let this through.
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const grant = await switchInto(owner.id, delegate.id, ["medications"]);
|
||||
|
||||
// Evidence first: the grant works before the column is corrupted, so the
|
||||
// refusal below is the corruption and not the fixture.
|
||||
expect((await PROBES.medications.call()).status).toBe(200);
|
||||
|
||||
await getPrismaClient().$executeRawUnsafe(
|
||||
`UPDATE account_grants SET scope_json = '["medications", "not-a-section"]'::jsonb WHERE id = $1`,
|
||||
grant.id,
|
||||
);
|
||||
|
||||
expect((await PROBES.medications.call()).status).toBe(403);
|
||||
expect(await lastRefusal(delegate.id)).toMatchObject({
|
||||
reason: "out_of_scope",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the refusal does not distinguish itself", () => {
|
||||
it("says the same bytes as a caller who was granted nothing", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await switchInto(owner.id, delegate.id, ["medications"]);
|
||||
const outOfScope = await PROBES.labs.call();
|
||||
const outOfScopeBody = await outOfScope.text();
|
||||
|
||||
// A stranger naming the same record. Different story, same answer.
|
||||
const stranger = await makeUser("stranger");
|
||||
const session = await signIn(stranger.id);
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: owner.id },
|
||||
});
|
||||
const noGrant = await PROBES.labs.call();
|
||||
|
||||
expect(outOfScope.status).toBe(noGrant.status);
|
||||
expect(outOfScopeBody).toBe(await noGrant.text());
|
||||
expect(outOfScopeBody).not.toContain("scope");
|
||||
|
||||
// …and the operator's trail tells them apart, which is the half that has
|
||||
// to keep working while the wire stays silent.
|
||||
expect(await lastRefusal(stranger.id)).toMatchObject({
|
||||
reason: "no_active_grant",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user