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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-28 23:27:02 +02:00
1 parent ca1c52ffcb
commit af2249644b
3 files changed
+49 -15

No files matched your search

@@ -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);
@@ -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({
+23 -4
View File
@@ -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);
}