Let a delegate enter a lab result and the marker it needs

POST /api/labs and POST /api/biomarkers resolve the record. The free-text
lab path mints its biomarker under the record's catalogue rather than the
caller's — a result added to somebody's record whose marker landed on the
helper's account would leave the owner with a reading they cannot chart.

Adding a marker the record already tracks stays the ordinary 409 from the
same per-account uniqueness the owner would hit themselves. The PUT and
DELETE on the sibling routes are unchanged and still refuse under a switch.

Both validation breadcrumbs move to auditLog(), the only writer that stamps
the acting account.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 12:10:49 +02:00
1 parent 17c4e76165
commit c6be0778dc
4 files changed
+38 -26

No files matched your search

@@ -34,6 +34,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
const SESSION = {
@@ -53,6 +54,10 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.x — the create arm's validation breadcrumb goes through the
// `auditLog` helper, the only writer that stamps `actorUserId`. The route
// chains `.catch()` onto it, so the stub has to return a promise.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
describe("biomarker create error contract", () => {
+13 -13
View File
@@ -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,
@@ -91,7 +91,11 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBiomarker));
async function postBiomarker(request: NextRequest) {
const { user } = await requireAuth();
// v1.36.x — a delegated write: the marker joins the record's catalogue. The
// 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 { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -107,17 +111,13 @@ async function postBiomarker(request: NextRequest) {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.biomarker.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
// v1.36.x — through `auditLog()`, the only writer that stamps the actor.
void auditLog("labs.biomarker.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.create.invalid",
});
@@ -50,6 +50,7 @@ vi.mock("next/headers", () => ({
import { POST } from "../route";
import { PUT } from "../[id]/route";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getSession } from "@/lib/auth/session";
const SESSION = {
@@ -93,6 +94,10 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
// v1.36.x — the create arm's validation breadcrumb goes through the
// `auditLog` helper, the only writer that stamps `actorUserId`. The route
// chains `.catch()` onto it, so the stub has to return a promise.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
describe("lab result error contract", () => {
+15 -13
View File
@@ -1,7 +1,7 @@
import { NextRequest } from "next/server";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -139,7 +139,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(withIdempotency<[NextRequest]>(postLabResult));
async function postLabResult(request: NextRequest) {
const { user } = await requireAuth();
// v1.36.x — a delegated write. The free-text path may MINT a biomarker, and
// 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 { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
@@ -157,17 +161,15 @@ async function postLabResult(request: NextRequest) {
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "labs.create.validation-failed",
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
/* swallow — the 422 response is the contract */
});
// v1.36.x — through `auditLog()`, the only writer that stamps
// `actorUserId`. A bare `prisma.auditLog.create` files a delegate's
// malformed payload as if the owner had typed it.
void auditLog("labs.create.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — the 422 response is the contract */
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.create.invalid",
});