feat(sharing): open the lab and clinical-record reads to a delegate

Ten routes: lab results and their detail, the biomarker catalogue and its
detail, allergies, family history, the mental-health screener history, and
the health-profile facts. All GET arms; every write arm in the same files
keeps `requireAuth()` and refuses under a switch.

The family-history payload is the one admitted read that carries third-party
health information by design — it describes the owner's relatives — and it is
admitted because a caregiver reading a family history is what the feature is
for and the rows are the owner's own.

The mental-health module gate resolves against the record, not the caller.
That is the recorded decision: the module map says which parts of a record
exist, so a delegate who enabled the screener on their own account gains no
screener view inside somebody else's. The integration test asserts both
directions.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 16:38:39 +02:00
1 parent 255cbf5fe0
commit af0f749740
13 files changed
+416 -21

No files matched your search

+21 -1
View File
@@ -260,6 +260,26 @@ const DELEGABLE_ROUTES: Record<string, string> = {
"The record's own reminder schedule — what the owner asked to be reminded of, not a notification channel or a device. The GET reads `userId: user.id`; creating a reminder stays on `requireAuth()`.",
"app/api/measurement-reminders/[id]/route.ts":
"One reminder of the record, fetched by id then guarded against the resolved user. The PUT and DELETE keep `requireAuth()`.",
"app/api/labs/route.ts":
"The record's lab results. The only nested include is the biomarker's own reference range — no third party, no credential. Creating a result stays on `requireAuth()`.",
"app/api/labs/[id]/route.ts":
"One lab result of the record, fetched by id then guarded against the resolved user before anything is serialised.",
"app/api/biomarkers/route.ts":
"The record's biomarker catalogue: the analytes this account tracks and the ranges it tracks them against. No writes on the read arm.",
"app/api/biomarkers/[id]/route.ts":
"One biomarker of the record, fetch-then-guard against the resolved user.",
"app/api/allergies/route.ts":
"The record's allergy list — the single most useful thing a caregiver can read, and a plain list of the owner's own rows.",
"app/api/allergies/[id]/route.ts":
"One allergy of the record, fetch-then-guard against the resolved user.",
"app/api/family-history/route.ts":
"The record's family history. The payload describes the owner's relatives, so it is the one admitted read where third-party health information is present by design rather than by accident; a caregiver reading it is the use the feature exists for, and the row is stored as the owner's.",
"app/api/family-history/[id]/route.ts":
"One family-history entry of the record, fetch-then-guard against the resolved user.",
"app/api/mental-health/assessments/route.ts":
"The record's screener history. The module gate resolves against the record, so a delegate sees the surface only where the owner switched it on; the rate limit on the POST arm is untouched and that arm still refuses under a switch.",
"app/api/anamnesis/facts/route.ts":
"The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
};
/**
@@ -283,7 +303,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* a formality by accident: every addition has to be counted here as well as
* listed above, which is one more place a careless admission has to pass.
*/
const FROZEN_ENTRY_COUNT = 7;
const FROZEN_ENTRY_COUNT = 17;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+2 -2
View File
@@ -12,7 +12,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -31,7 +31,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.allergy.findUnique({ where: { id } });
+2 -2
View File
@@ -11,7 +11,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -29,7 +29,7 @@ import {
import { toAllergyDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = new URL(request.url).searchParams;
const parsed = allergyListQuerySchema.safeParse({
@@ -12,6 +12,13 @@ const mocks = vi.hoisted(() => ({
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireAuth: vi.fn(async () => ({ user: { id: "user-1" } })),
// v1.36.0 — the GET resolves a record rather than a caller. Same shape when
// nobody has switched, which is the case this file exercises.
requireRecordAuth: vi.fn(async () => ({
user: { id: "user-1" },
actor: { id: "user-1" },
grantId: null,
})),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency: (handler: unknown) => handler,
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -19,7 +19,7 @@ import {
import { healthProfileFactWriteSchema } from "@/lib/validations/health-profile-facts";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const payload = await readHealthProfileFacts(user.id);
annotate({
action: { name: "anamnesis.facts.get" },
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -64,7 +64,7 @@ function serialiseBiomarker(row: {
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.biomarker.findFirst({ where: { id } });
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -71,7 +71,7 @@ function serialiseBiomarker(row: {
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// Visible markers first, then hidden — the manager renders them in two
// sections and the lab-entry pickers drop the hidden tail client-side.
+2 -2
View File
@@ -10,7 +10,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -29,7 +29,7 @@ type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.familyHistoryEntry.findUnique({ where: { id } });
+2 -2
View File
@@ -12,7 +12,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -30,7 +30,7 @@ import {
import { toFamilyHistoryEntryDTO } from "@/lib/records/dto";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = new URL(request.url).searchParams;
const parsed = familyHistoryListQuerySchema.safeParse({
+2 -2
View File
@@ -1,6 +1,6 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -65,7 +65,7 @@ function toResolved(
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const { id } = await params;
const row = await prisma.labResult.findFirst({
+2 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -70,7 +70,7 @@ function toResolved(
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listLabResultsSchema.safeParse(params);
@@ -18,7 +18,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { invalidateUserHealthScore } from "@/lib/cache/invalidate";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -99,7 +99,7 @@ function respondExisting(row: Parameters<typeof shapeRow>[0]): Response {
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { user } = await requireRecordAuth("read");
// 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).
@@ -472,3 +472,371 @@ describe("GET /api/measurement-reminders/[id]", () => {
});
});
});
/* -------------------------------------------------------------------------- */
/* Labs and biomarkers */
/* -------------------------------------------------------------------------- */
async function seedLab(userId: string, analyte: string) {
await getPrismaClient().labResult.create({
data: {
userId,
analyte,
value: 5.2,
unit: "mmol/L",
takenAt: new Date("2026-07-10T09:00:00Z"),
},
});
}
contract<string>("GET /api/labs", {
call: async () => {
const { GET } = await import("@/app/api/labs/route");
return call(GET as Handler, "/api/labs");
},
seed: seedLab,
read: (payload: { results: { analyte: string }[] }) =>
payload.results.map((r) => r.analyte),
ownerMarker: "owner-hba1c",
delegateMarker: "delegate-hba1c",
});
async function seedBiomarker(userId: string, name: string) {
await getPrismaClient().biomarker.create({
data: { userId, name, unit: "mmol/L" },
});
}
contract<string>("GET /api/biomarkers", {
call: async () => {
const { GET } = await import("@/app/api/biomarkers/route");
return call(GET as Handler, "/api/biomarkers");
},
seed: seedBiomarker,
read: (payload: { biomarkers: { name: string }[] }) =>
payload.biomarkers.map((b) => b.name),
ownerMarker: "owner-ferritin",
delegateMarker: "delegate-ferritin",
});
describe("GET /api/labs/[id] and /api/biomarkers/[id]", () => {
it("resolve by id against the owner, not the caller", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedLab(owner.id, "owner-hba1c");
await seedLab(delegate.id, "delegate-hba1c");
await seedBiomarker(owner.id, "owner-ferritin");
const ownerLab = await getPrismaClient().labResult.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateLab = await getPrismaClient().labResult.findFirstOrThrow({
where: { userId: delegate.id },
});
const ownerMarker = await getPrismaClient().biomarker.findFirstOrThrow({
where: { userId: owner.id },
});
const labs = await import("@/app/api/labs/[id]/route");
const markers = await import("@/app/api/biomarkers/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const lab = await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, {
id: ownerLab.id,
});
expect(lab.status).toBe(200);
expect((await body(lab)).data).toMatchObject({ analyte: "owner-hba1c" });
// The delegate's own result is not reachable from inside the owner's
// record. The fetch-then-guard arm now guards against the owner.
const theirs = await call(
labs.GET as Handler,
`/api/labs/${delegateLab.id}`,
{ id: delegateLab.id },
);
expect(theirs.status).toBe(404);
const marker = await call(
markers.GET as Handler,
`/api/biomarkers/${ownerMarker.id}`,
{ id: ownerMarker.id },
);
expect(marker.status).toBe(200);
expect((await body(marker)).data).toMatchObject({ name: "owner-ferritin" });
await revokeGrant(grant.id);
await expectDenied(
await call(labs.GET as Handler, `/api/labs/${ownerLab.id}`, {
id: ownerLab.id,
}),
);
await expectDenied(
await call(markers.GET as Handler, `/api/biomarkers/${ownerMarker.id}`, {
id: ownerMarker.id,
}),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedLab(plain.id, "plain-hba1c");
const row = await getPrismaClient().labResult.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/labs/[id]/route");
const response = await call(GET as Handler, `/api/labs/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
analyte: "plain-hba1c",
});
});
});
/* -------------------------------------------------------------------------- */
/* Clinical record */
/* -------------------------------------------------------------------------- */
async function seedAllergy(userId: string, substance: string) {
await getPrismaClient().allergy.create({ data: { userId, substance } });
}
contract<string>("GET /api/allergies", {
call: async () => {
const { GET } = await import("@/app/api/allergies/route");
return call(GET as Handler, "/api/allergies");
},
seed: seedAllergy,
read: (payload: { substance: string }[]) => payload.map((a) => a.substance),
ownerMarker: "owner-penicillin",
delegateMarker: "delegate-penicillin",
});
async function seedFamilyHistory(userId: string, condition: string) {
await getPrismaClient().familyHistoryEntry.create({
data: { userId, relationship: "MOTHER", condition },
});
}
contract<string>("GET /api/family-history", {
call: async () => {
const { GET } = await import("@/app/api/family-history/route");
return call(GET as Handler, "/api/family-history");
},
seed: seedFamilyHistory,
read: (payload: { condition: string }[]) => payload.map((e) => e.condition),
ownerMarker: "owner-condition",
delegateMarker: "delegate-condition",
});
describe("GET /api/allergies/[id] and /api/family-history/[id]", () => {
it("resolve by id against the owner and refuse once the grant ends", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedAllergy(owner.id, "owner-penicillin");
await seedAllergy(delegate.id, "delegate-penicillin");
await seedFamilyHistory(owner.id, "owner-condition");
const ownerAllergy = await getPrismaClient().allergy.findFirstOrThrow({
where: { userId: owner.id },
});
const delegateAllergy = await getPrismaClient().allergy.findFirstOrThrow({
where: { userId: delegate.id },
});
const ownerEntry =
await getPrismaClient().familyHistoryEntry.findFirstOrThrow({
where: { userId: owner.id },
});
const allergies = await import("@/app/api/allergies/[id]/route");
const history = await import("@/app/api/family-history/[id]/route");
const { grant } = await switchInto(owner.id, delegate.id);
const mine = await call(
allergies.GET as Handler,
`/api/allergies/${ownerAllergy.id}`,
{ id: ownerAllergy.id },
);
expect(mine.status).toBe(200);
expect((await body(mine)).data).toMatchObject({
substance: "owner-penicillin",
});
const theirs = await call(
allergies.GET as Handler,
`/api/allergies/${delegateAllergy.id}`,
{ id: delegateAllergy.id },
);
expect(theirs.status).toBe(404);
const entry = await call(
history.GET as Handler,
`/api/family-history/${ownerEntry.id}`,
{ id: ownerEntry.id },
);
expect(entry.status).toBe(200);
expect((await body(entry)).data).toMatchObject({
condition: "owner-condition",
});
await revokeGrant(grant.id);
await expectDenied(
await call(
allergies.GET as Handler,
`/api/allergies/${ownerAllergy.id}`,
{ id: ownerAllergy.id },
),
);
});
it("are unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await seedAllergy(plain.id, "plain-penicillin");
const row = await getPrismaClient().allergy.findFirstOrThrow({
where: { userId: plain.id },
});
await signIn(plain.id);
const { GET } = await import("@/app/api/allergies/[id]/route");
const response = await call(GET as Handler, `/api/allergies/${row.id}`, {
id: row.id,
});
expect(response.status).toBe(200);
expect((await body(response)).data).toMatchObject({
substance: "plain-penicillin",
});
});
});
async function seedFact(userId: string, value: string) {
// Through the real writer, because the revision's value is an encrypted
// column and a hand-built row would read back as `unreadable`.
const { createHealthProfileFact } =
await import("@/lib/profile/health-facts");
await createHealthProfileFact(userId, "SMOKING_STATUS", value as never);
}
contract<string>("GET /api/anamnesis/facts", {
call: async () => {
const { GET } = await import("@/app/api/anamnesis/facts/route");
return call(GET as Handler, "/api/anamnesis/facts");
},
seed: seedFact,
read: (payload: {
current: Record<string, { value: string | null } | null>;
}) =>
Object.values(payload.current)
.map((row) => row?.value ?? null)
.filter((v): v is string => v !== null),
ownerMarker: "CURRENT",
delegateMarker: "NEVER",
});
/* -------------------------------------------------------------------------- */
/* Mental health — the module gate follows the record */
/* -------------------------------------------------------------------------- */
async function seedAssessment(userId: string, totalScore: number) {
await getPrismaClient().mentalHealthAssessment.create({
data: {
userId,
instrument: "PHQ9",
locale: "en",
responsesEncrypted: Buffer.from("sealed"),
totalScore,
severityBand: "mild",
takenAt: new Date("2026-07-12T10:00:00Z"),
},
});
}
async function setModulePreference(userId: string, enabled: boolean) {
await getPrismaClient().user.update({
where: { id: userId },
data: { modulePreferencesJson: { mentalHealth: enabled } },
});
}
describe("GET /api/mental-health/assessments", () => {
it("reads the owner's screener history when the OWNER enabled the module", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await setModulePreference(owner.id, true);
await seedAssessment(owner.id, 11);
await seedAssessment(delegate.id, 3);
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const response = await call(
GET as Handler,
"/api/mental-health/assessments",
);
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
assessments: { totalScore: number }[];
};
expect(payload.assessments.map((a) => a.totalScore)).toEqual([11]);
});
it("refuses when the OWNER switched the module off, whatever the delegate set", async () => {
// The decision recorded in the classification: the module map governs
// which parts of a RECORD exist, so it is read off the record. A delegate
// who enabled the screener on their own account does not thereby gain a
// screener view inside somebody else's.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await setModulePreference(owner.id, false);
await setModulePreference(delegate.id, true);
await seedAssessment(owner.id, 11);
await switchInto(owner.id, delegate.id);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const response = await call(
GET as Handler,
"/api/mental-health/assessments",
);
expect(response.status).toBe(403);
expect((await body(response)).meta?.errorCode).toBe("module.disabled");
});
it("refuses a caller with no grant, and refuses the request after revocation", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await setModulePreference(owner.id, true);
await seedAssessment(owner.id, 11);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const { grant } = await switchInto(owner.id, delegate.id);
expect(
(await call(GET as Handler, "/api/mental-health/assessments")).status,
).toBe(200);
await revokeGrant(grant.id);
await expectDenied(
await call(GET as Handler, "/api/mental-health/assessments"),
);
});
it("is unchanged for a caller who has not switched", async () => {
const plain = await makeUser("plain");
await setModulePreference(plain.id, true);
await seedAssessment(plain.id, 7);
await signIn(plain.id);
const { GET } = await import("@/app/api/mental-health/assessments/route");
const response = await call(
GET as Handler,
"/api/mental-health/assessments",
);
expect(response.status).toBe(200);
const payload = (await body(response)).data as {
assessments: { totalScore: number }[];
};
expect(payload.assessments.map((a) => a.totalScore)).toEqual([7]);
});
});