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).