From 095aba095eddae83f3c686972eca4ff4a0a23127 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 12:10:59 +0200 Subject: [PATCH] Let a delegate add to the clinical record of a shared account An allergy, a family-history entry, an illness episode and a custom-metric value now resolve the record rather than the caller. Two of them carry a consequence worth naming. The family-history row describes the record's relatives, so it is the one admitted write where third-party health information is present by design; it is stored as the record's own, exactly as the read arm already serves it. The illness module gate runs against the record before the write, so a delegate cannot open an episode inside a record whose owner switched the module off, whatever they set on their own account. The custom-metric entry reaches its metric through the ownership hop, which now resolves against the record: a delegate can only feed a definition the owner made, and their own metric of the same name is unreachable from in there. Its validation breadcrumb moves to auditLog(). --- src/app/api/allergies/route.ts | 7 +++-- .../api/custom-metrics/[id]/entries/route.ts | 26 +++++++++---------- src/app/api/family-history/route.ts | 8 ++++-- src/app/api/illness/episodes/route.ts | 8 ++++-- 4 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/app/api/allergies/route.ts b/src/app/api/allergies/route.ts index 8ae18805b..f29719ded 100644 --- a/src/app/api/allergies/route.ts +++ b/src/app/api/allergies/route.ts @@ -11,7 +11,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -67,7 +67,10 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postAllergy)); async function postAllergy(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.36.x — a delegated write, and the cleanest of them: the row is a plain + // statement about the record's own body, and the caller appears in the audit + // trail rather than in the row. + const { user } = await requireRecordAuth("write"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/custom-metrics/[id]/entries/route.ts b/src/app/api/custom-metrics/[id]/entries/route.ts index b6963086c..dd9d45344 100644 --- a/src/app/api/custom-metrics/[id]/entries/route.ts +++ b/src/app/api/custom-metrics/[id]/entries/route.ts @@ -1,6 +1,6 @@ import { NextRequest } from "next/server"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, apiSuccess, @@ -89,7 +89,11 @@ async function postCustomMetricEntry( request: NextRequest, { params }: RouteParams, ) { - const { user } = await requireAuth(); + // v1.36.x — a delegated write. The ownership hop runs first and now resolves + // against the record, so a delegate can only add a value to a metric the + // OWNER defined; their own metric of the same name is unreachable from in + // here and 404s. + const { user } = await requireRecordAuth("write"); const { id } = await params; const metric = await prisma.customMetric.findFirst({ @@ -114,17 +118,13 @@ async function postCustomMetricEntry( const auditIssues = sanitiseZodIssues(parsed.error.issues, { stripValuesFromMessage: true, }); - prisma.auditLog - .create({ - data: { - userId: user.id, - action: "custom-metric.entry.create.validation-failed", - details: JSON.stringify({ issues: auditIssues, customMetricId: id }), - }, - }) - .catch(() => { - /* swallow — the 422 response is the contract */ - }); + // v1.36.x — through `auditLog()`, the only writer that stamps the actor. + void auditLog("custom-metric.entry.create.validation-failed", { + userId: user.id, + details: { issues: auditIssues, customMetricId: id }, + }).catch(() => { + /* swallow — the 422 response is the contract */ + }); return returnAllZodIssues(parsed.error, 422); } diff --git a/src/app/api/family-history/route.ts b/src/app/api/family-history/route.ts index d58cb44d4..d560de87e 100644 --- a/src/app/api/family-history/route.ts +++ b/src/app/api/family-history/route.ts @@ -12,7 +12,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -62,7 +62,11 @@ export const POST = apiHandler( ); async function postFamilyHistory(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.36.x — a delegated write, and the one where third-party health data is + // present by design: the row describes the record's relatives. It is stored + // as the record's own, exactly as the delegable READ arm above already + // serves it, and the audit trail names who entered it. + const { user } = await requireRecordAuth("write"); const { data: rawBody, error: jsonError } = await safeJson(request, { maxBytes: 16 * 1024, diff --git a/src/app/api/illness/episodes/route.ts b/src/app/api/illness/episodes/route.ts index a4a0d0d11..fcb41029c 100644 --- a/src/app/api/illness/episodes/route.ts +++ b/src/app/api/illness/episodes/route.ts @@ -13,7 +13,7 @@ import { NextRequest } from "next/server"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -74,7 +74,11 @@ export const GET = apiHandler(async (request: NextRequest) => { export const POST = apiHandler(withIdempotency<[NextRequest]>(postEpisode)); async function postEpisode(request: NextRequest): Promise { - const { user } = await requireAuth(); + // v1.36.x — a delegated write. The module gate below follows the RECORD, not + // 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 gate = await requireIllnessEnabled(user.id); if (!gate.enabled) return gate.response;