From af2249644b03eb1168e4471d0284f989d3ee0c81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 28 May 2026 23:27:02 +0200 Subject: [PATCH] fix(meds): route every detail-page sub-route through assertMedicationOwnership The intake purge endpoint, the parent medication PUT, the parent DELETE and the CSV-import POST each hand-rolled the same `findUnique + userId !== id ? 404` block instead of using the shared `assertMedicationOwnership` helper that every other `src/app/api/medications/[id]/**` handler already uses. The hand-rolled checks read slightly different fields and skipped the helper's narrow `select`, which leaked extra column data into routes that never needed it. Routing each of the four through the helper keeps the 404 leak shape on a single line, prepares the purge endpoint for promotion to a user-discoverable surface on the new detail page, and removes one of the open audit findings against the medications API. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../medications/[id]/intake/import/route.ts | 10 +++---- .../medications/[id]/intake/purge/route.ts | 27 ++++++++++++++----- src/app/api/medications/[id]/route.ts | 27 ++++++++++++++++--- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/app/api/medications/[id]/intake/import/route.ts b/src/app/api/medications/[id]/intake/import/route.ts index 4c10957af..d739cb365 100644 --- a/src/app/api/medications/[id]/intake/import/route.ts +++ b/src/app/api/medications/[id]/intake/import/route.ts @@ -4,12 +4,12 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, - apiError, getClientIp, returnAllZodIssues, safeJson, sanitiseZodIssues, } from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { recomputeMedicationComplianceForDay, dayKeyForScheduledFor, @@ -32,10 +32,10 @@ export const POST = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + // v1.5.5 C-E3-3 — route ownership through the shared helper so the + // 404 leak shape stays identical across every `[id]/**` handler. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; const { data: body, error: jsonError } = await safeJson(request); diff --git a/src/app/api/medications/[id]/intake/purge/route.ts b/src/app/api/medications/[id]/intake/purge/route.ts index 411d83080..496dcd0f0 100644 --- a/src/app/api/medications/[id]/intake/purge/route.ts +++ b/src/app/api/medications/[id]/intake/purge/route.ts @@ -2,7 +2,8 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; -import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; +import { apiSuccess, getClientIp } from "@/lib/api-response"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -12,10 +13,20 @@ export const DELETE = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + // v1.5.5 C-E3-3 — route purge through the same ownership predicate + // every other `src/app/api/medications/[id]/**` handler uses. The + // hand-rolled `findUnique + userId !== id ? 404` block returned the + // medication row only to read its name for the audit details. The + // audit row still wants the name; re-read it after the guard so the + // ownership check stays the single source of truth on the 404 leak + // shape. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + + const medication = await prisma.medication.findUnique({ + where: { id }, + select: { name: true }, + }); const { count } = await prisma.medicationIntakeEvent.deleteMany({ where: { medicationId: id, userId: user.id }, @@ -31,7 +42,11 @@ export const DELETE = apiHandler( await auditLog("medication.intake.purge", { userId: user.id, ipAddress: getClientIp(request), - details: { medicationId: id, name: medication.name, deletedCount: count }, + details: { + medicationId: id, + name: medication?.name ?? "", + deletedCount: count, + }, }); annotate({ diff --git a/src/app/api/medications/[id]/route.ts b/src/app/api/medications/[id]/route.ts index 5e40076a4..fa45ef740 100644 --- a/src/app/api/medications/[id]/route.ts +++ b/src/app/api/medications/[id]/route.ts @@ -17,6 +17,7 @@ import { setMedicationCategory, } from "@/lib/medication-category"; import { serializeScheduleRecurrence } from "@/lib/medication-schedule"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -60,8 +61,18 @@ export const PUT = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const existing = await prisma.medication.findUnique({ where: { id } }); - if (!existing || existing.userId !== user.id) { + // v1.5.5 C-E3-3 — route ownership check through the shared helper + // so the 404 leak shape stays identical across every + // `[id]/**` handler. The PUT branch still needs `existing.active` + // for the pausedAt-derivation step below, so re-read the narrow + // field after the guard passes. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + const existing = await prisma.medication.findUnique({ + where: { id }, + select: { active: true }, + }); + if (!existing) { return apiError("Medication not found", 404); } @@ -263,8 +274,16 @@ export const DELETE = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const existing = await prisma.medication.findUnique({ where: { id } }); - if (!existing || existing.userId !== user.id) { + // v1.5.5 C-E3-3 — ownership via the shared helper. The DELETE + // branch needs the medication name for the audit-row detail; pull + // it after the guard passes. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + const existing = await prisma.medication.findUnique({ + where: { id }, + select: { name: true }, + }); + if (!existing) { return apiError("Medication not found", 404); }