From 8399c47874201fc0b934b93e9e0d35b465aad728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 23:09:50 +0200 Subject: [PATCH] refactor(api): adopt assertMedicationOwnership + rate-limit option-bag across 9 medication routes Hoist the ownership-check boilerplate that nine medication sub-routes were each carrying inline into the shared route-guard helper, and align the three W19 routes that hand-wrote the rate-limit header loop on the new apiError option-bag form. Routes touched: - titration: shared guard + replaced parseDoseString with the shared parseDoseMgOrNull + replaced the inline GLP1_DRUGS reverse-lookup with findDrugIdByBrand. - side-effects: shared guard + option-bag headers + dropped the client-supplied 'category' field per code-M6 (server derives via categoryForEntry). - inventory: shared guard + option-bag headers. - cadence: shared guard layered on top of the W21 Fix-O timezone wiring. - intake (POST + GET): shared guard. - compliance: shared guard. - phase-config (GET + PUT + DELETE): shared guard. - api-endpoint (GET + PUT): shared guard. - glp1: shared guard replacing Fix-K's inline copy + option-bag headers. apiError now accepts { headers: Record } in its third argument bag; the headers flow through NextResponse and are stripped from the meta envelope so the existing { errorCode, ... } meta usage keeps its shape. --- .../medications/[id]/api-endpoint/route.ts | 14 ++++-- src/app/api/medications/[id]/cadence/route.ts | 7 ++- .../api/medications/[id]/compliance/route.ts | 7 ++- src/app/api/medications/[id]/glp1/route.ts | 23 ++-------- src/app/api/medications/[id]/intake/route.ts | 15 +++--- .../api/medications/[id]/inventory/route.ts | 23 ++-------- .../medications/[id]/phase-config/route.ts | 31 ++++--------- .../[id]/side-effects/__tests__/route.test.ts | 18 +++++++- .../medications/[id]/side-effects/route.ts | 43 +++++------------ .../api/medications/[id]/titration/route.ts | 46 ++++++++----------- src/lib/api-response.ts | 24 ++++++++-- .../medications/side-effects/validators.ts | 10 +++- 12 files changed, 122 insertions(+), 139 deletions(-) diff --git a/src/app/api/medications/[id]/api-endpoint/route.ts b/src/app/api/medications/[id]/api-endpoint/route.ts index 67f4482c4..420d16c4c 100644 --- a/src/app/api/medications/[id]/api-endpoint/route.ts +++ b/src/app/api/medications/[id]/api-endpoint/route.ts @@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -22,10 +23,9 @@ export const GET = apiHandler( } 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.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; const scope = medicationScope(id); const now = new Date(); @@ -63,8 +63,12 @@ export const PUT = apiHandler( } const { id } = await params; + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + const medication = await prisma.medication.findUnique({ where: { id } }); - if (!medication || medication.userId !== user.id) { + if (!medication) { return apiError("Medication not found", 404); } diff --git a/src/app/api/medications/[id]/cadence/route.ts b/src/app/api/medications/[id]/cadence/route.ts index c958edcb6..1f8a97b73 100644 --- a/src/app/api/medications/[id]/cadence/route.ts +++ b/src/app/api/medications/[id]/cadence/route.ts @@ -26,6 +26,7 @@ import { computeNextDose, } from "@/lib/medications/scheduling/cadence"; import { complianceChips } from "@/lib/medications/scheduling/compliance"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { resolveUserTimezone } from "@/lib/tz/resolver"; type RouteParams = { params: Promise<{ id: string }> }; @@ -39,11 +40,15 @@ export const GET = apiHandler( const { user } = await requireAuth(); const { id } = await params; + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + const med = await prisma.medication.findUnique({ where: { id }, include: { schedules: true }, }); - if (!med || med.userId !== user.id) { + if (!med) { return apiError("Medication not found", 404); } diff --git a/src/app/api/medications/[id]/compliance/route.ts b/src/app/api/medications/[id]/compliance/route.ts index fb5a14595..5eb4f1c93 100644 --- a/src/app/api/medications/[id]/compliance/route.ts +++ b/src/app/api/medications/[id]/compliance/route.ts @@ -7,6 +7,7 @@ import { classifyIntakeTiming, } from "@/lib/analytics/compliance"; import type { DailyComplianceEntry } from "@/lib/analytics/compliance"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; type RouteParams = { params: Promise<{ id: string }> }; @@ -15,12 +16,16 @@ export const GET = apiHandler( const { user } = await requireAuth(); const { id } = await params; + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + const medication = await prisma.medication.findUnique({ where: { id }, include: { schedules: true }, }); - if (!medication || medication.userId !== user.id) { + if (!medication) { return apiError("Medication not found", 404); } diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index 0f93f7b19..c2b06159f 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -26,6 +26,7 @@ import { } from "@/lib/api-response"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { glp1PostBodySchema } from "@/lib/validations/medication"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -34,20 +35,6 @@ const LOW_STOCK_DOSE_THRESHOLD = 4; const POST_RATE_LIMIT = 30; const POST_WINDOW_MS = 60_000; -async function assertMedicationOwnership( - medicationId: string, - userId: string, -) { - const med = await prisma.medication.findUnique({ - where: { id: medicationId }, - select: { id: true, userId: true }, - }); - if (!med || med.userId !== userId) { - return apiError("Medication not found", 404); - } - return null; -} - export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -148,11 +135,9 @@ export const POST = apiHandler( POST_WINDOW_MS, ); if (!rl.allowed) { - const response = apiError("Too many requests", 429); - for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { - response.headers.set(k, v); - } - return response; + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); } const { data: body, error: jsonError } = await safeJson(request); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 4cbe9afd1..06aab2174 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -14,6 +14,7 @@ import { } from "@/lib/validations/medication"; import { withIdempotency } from "@/lib/idempotency"; import { consumeOneDose } from "@/lib/medications/inventory/service"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -26,10 +27,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { 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.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; const { data: body, error: jsonError } = await safeJson(request); @@ -162,10 +162,9 @@ export const GET = 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.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; const searchParams = Object.fromEntries(request.nextUrl.searchParams); const parsed = listIntakeEventsSchema.safeParse(searchParams); diff --git a/src/app/api/medications/[id]/inventory/route.ts b/src/app/api/medications/[id]/inventory/route.ts index f9c68da38..ef3f9bbe2 100644 --- a/src/app/api/medications/[id]/inventory/route.ts +++ b/src/app/api/medications/[id]/inventory/route.ts @@ -28,26 +28,13 @@ import { import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { createInventoryItemSchema } from "@/lib/validations/medication"; import { buildCreateInventoryInput } from "@/lib/medications/inventory/service"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; type RouteParams = { params: Promise<{ id: string }> }; const POST_RATE_LIMIT = 30; const POST_WINDOW_MS = 60_000; -async function assertMedicationOwnership( - medicationId: string, - userId: string, -) { - const med = await prisma.medication.findUnique({ - where: { id: medicationId }, - select: { id: true, userId: true }, - }); - if (!med || med.userId !== userId) { - return apiError("Medication not found", 404); - } - return null; -} - export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -86,11 +73,9 @@ export const POST = apiHandler( POST_WINDOW_MS, ); if (!rl.allowed) { - const response = apiError("Too many requests", 429); - for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { - response.headers.set(k, v); - } - return response; + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); } const { data: body, error: jsonError } = await safeJson(request); diff --git a/src/app/api/medications/[id]/phase-config/route.ts b/src/app/api/medications/[id]/phase-config/route.ts index 814721763..65214be30 100644 --- a/src/app/api/medications/[id]/phase-config/route.ts +++ b/src/app/api/medications/[id]/phase-config/route.ts @@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { phaseConfigSchema } from "@/lib/validations/phase-config"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { NextRequest } from "next/server"; type RouteParams = { params: Promise<{ id: string }> }; @@ -12,13 +13,9 @@ export const GET = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - select: { userId: true }, - }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; const config = await prisma.reminderPhaseConfig.findUnique({ where: { medicationId: id }, @@ -53,13 +50,9 @@ export const PUT = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - select: { userId: true }, - }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; const { data: body, error: jsonError } = await safeJson(request); @@ -95,13 +88,9 @@ export const DELETE = apiHandler( const { user } = await requireAuth(); const { id } = await params; - const medication = await prisma.medication.findUnique({ - where: { id }, - select: { userId: true }, - }); - if (!medication || medication.userId !== user.id) { - return apiError("Medication not found", 404); - } + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; await prisma.reminderPhaseConfig.deleteMany({ where: { medicationId: id }, diff --git a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts index a2bea1fd3..2a133a2e3 100644 --- a/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/side-effects/__tests__/route.test.ts @@ -182,9 +182,20 @@ describe("POST /api/medications/[id]/side-effects", () => { expect(res.status).toBe(422); }); - it("returns 422 when entry / category mismatch", async () => { + it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => { + // Client maliciously sends category=INJECTION_SITE for a + // NAUSEA entry. The server now derives the canonical category + // from `entry`, so the row lands with category="GI" regardless + // of the client claim. Backwards-compatible with older clients + // that still send `category`. vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never); + vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({ + id: "se-new", + entry: "NAUSEA", + category: "GI", + severity: 2, + } as never); const res = await POST( jsonReq("http://localhost/api/medications/med-1/side-effects", { category: "INJECTION_SITE", @@ -193,7 +204,10 @@ describe("POST /api/medications/[id]/side-effects", () => { }), { params: Promise.resolve({ id: "med-1" }) }, ); - expect(res.status).toBe(422); + expect(res.status).toBe(201); + const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock + .calls[0][0] as { data: { category: string } }; + expect(callArgs.data.category).toBe("GI"); }); it("creates a row with the server-derived category", async () => { diff --git a/src/app/api/medications/[id]/side-effects/route.ts b/src/app/api/medications/[id]/side-effects/route.ts index c19cfc060..32b0e7b0e 100644 --- a/src/app/api/medications/[id]/side-effects/route.ts +++ b/src/app/api/medications/[id]/side-effects/route.ts @@ -34,26 +34,13 @@ import { listSideEffectsSchema, } from "@/lib/medications/side-effects/validators"; import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; type RouteParams = { params: Promise<{ id: string }> }; const POST_RATE_LIMIT = 30; const POST_WINDOW_MS = 60_000; -async function assertMedicationOwnership( - medicationId: string, - userId: string, -) { - const med = await prisma.medication.findUnique({ - where: { id: medicationId }, - select: { id: true, userId: true }, - }); - if (!med || med.userId !== userId) { - return apiError("Medication not found", 404); - } - return null; -} - export const GET = apiHandler( async (request: NextRequest, { params }: RouteParams) => { const { user } = await requireAuth(); @@ -117,11 +104,9 @@ export const POST = apiHandler( POST_WINDOW_MS, ); if (!rl.allowed) { - const response = apiError("Too many requests", 429); - for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { - response.headers.set(k, v); - } - return response; + return apiError("Too many requests", 429, { + headers: rateLimitHeaders(rl), + }); } const { data: body, error: jsonError } = await safeJson(request); @@ -132,24 +117,20 @@ export const POST = apiHandler( return apiError(parsed.error.issues[0].message, 422); } - const { category, entry, severity, occurredAt, notes } = parsed.data; + const { entry, severity, occurredAt, notes } = parsed.data; - // Authoritative category derivation. The client-supplied category - // is sanity-checked, never trusted — a NAUSEA row tagged as - // INJECTION_SITE would poison the Coach aggregator. - const expectedCategory = categoryForEntry(entry); - if (expectedCategory !== category) { - return apiError( - `Entry ${entry} belongs to category ${expectedCategory}, not ${category}`, - 422, - ); - } + // v1.4.25 W21 Fix-N (code-M6) — category is derived server-side + // from the entry via the authoritative taxonomy mapping. The wire + // schema no longer accepts `category`; older clients that still + // send it now have it ignored by Zod's strict drop, and the row + // lands with the correct (entry-derived) category every time. + const category = categoryForEntry(entry); const created = await prisma.medicationSideEffect.create({ data: { userId: user.id, medicationId: id, - category: expectedCategory, + category, entry, severity, occurredAt: occurredAt ?? new Date(), diff --git a/src/app/api/medications/[id]/titration/route.ts b/src/app/api/medications/[id]/titration/route.ts index e0cab8a41..55ee026cb 100644 --- a/src/app/api/medications/[id]/titration/route.ts +++ b/src/app/api/medications/[id]/titration/route.ts @@ -25,7 +25,12 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { apiError, apiSuccess } from "@/lib/api-response"; -import { findDrugByBrand, GLP1_DRUGS, type Glp1DrugId } from "@/lib/medications/glp1-knowledge"; +import { + findDrugByBrand, + findDrugIdByBrand, +} from "@/lib/medications/glp1-knowledge"; +import { parseDoseMgOrNull } from "@/lib/medications/dose-string"; +import { assertMedicationOwnership } from "@/lib/medications/route-guards"; import { escalationDue, findCurrentStep, @@ -41,13 +46,19 @@ export const GET = apiHandler( const { user } = await requireAuth(); const { id } = await params; + // v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so + // the 404 leak shape stays consistent across every medication + // sub-route. + const guard = await assertMedicationOwnership(id, user.id); + if (guard) return guard; + const med = await prisma.medication.findUnique({ where: { id }, include: { doseChanges: { orderBy: { effectiveFrom: "asc" } }, }, }); - if (!med || med.userId !== user.id) { + if (!med) { return apiError("Medication not found", 404); } if (med.treatmentClass !== "GLP1") { @@ -61,19 +72,11 @@ export const GET = apiHandler( // Resolve the GLP-1 drug record from the medication name. Same // path the DrugLevelChart uses; null when the medication name // doesn't map to a catalog brand (e.g. user typed a generic - // INN instead of a brand). + // INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go + // through the shared helpers in glp1-knowledge. const record = findDrugByBrand(med.name); - if (!record) { - return apiError("Medication not found", 404); - } - let drugId: Glp1DrugId | null = null; - for (const [id, r] of Object.entries(GLP1_DRUGS)) { - if (r === record) { - drugId = id as Glp1DrugId; - break; - } - } - if (!drugId) { + const drugId = findDrugIdByBrand(med.name); + if (!record || !drugId) { return apiError("Medication not found", 404); } @@ -88,7 +91,7 @@ export const GET = apiHandler( : null; const latestDoseMg = latestChange ? latestChange.doseValue - : parseDoseString(med.dose); + : parseDoseMgOrNull(med.dose); const currentStep = findCurrentStep(drugId, latestDoseMg); const next = nextStep(drugId, currentStep); @@ -131,16 +134,3 @@ export const GET = apiHandler( }, ); -/** - * Parse the legacy free-text `medication.dose` ("0.5 mg", "5 mg", - * "0.25") into a numeric mg value. Returns null when the string - * doesn't carry a parseable number — the helper above treats null - * as "no current step" rather than 0. - */ -function parseDoseString(dose: string): number | null { - const match = dose.match(/[-+]?\d*\.?\d+/); - if (!match) return null; - const n = Number(match[0]); - if (!Number.isFinite(n)) return null; - return n; -} diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 68590c4c2..a45a8a5cb 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -10,15 +10,33 @@ export function apiSuccess(data: T, status = 200) { * `{ errorCode: "credentials_rejected" }` so the UI translates the message * via `t("settings.testConnection.errors." + errorCode)` instead of * displaying the server's English fallback. + * + * v1.4.25 W21 Fix-N — the third argument may also carry a `headers` + * record (e.g. `{ headers: rateLimitHeaders(rl) }`) so 429 responses + * can attach the `X-RateLimit-*` triple in the same call. The `headers` + * key is *not* echoed into the JSON body — it is consumed by the + * NextResponse constructor and stripped from the meta envelope. */ export function apiError( message: string, status = 400, - meta?: { errorCode?: string } & Record, + meta?: { + errorCode?: string; + headers?: Record; + } & Record, ) { + const { headers, ...rest } = meta ?? {}; + const metaKeys = Object.keys(rest); return NextResponse.json( - { data: null, error: message, ...(meta ? { meta } : {}) }, - { status }, + { + data: null, + error: message, + ...(metaKeys.length > 0 ? { meta: rest } : {}), + }, + { + status, + ...(headers ? { headers } : {}), + }, ); } diff --git a/src/lib/medications/side-effects/validators.ts b/src/lib/medications/side-effects/validators.ts index 50b6cabc3..b5aac8af8 100644 --- a/src/lib/medications/side-effects/validators.ts +++ b/src/lib/medications/side-effects/validators.ts @@ -49,8 +49,16 @@ export const SIDE_EFFECT_ENTRY_VALUES = [ export const SIDE_EFFECT_NOTES_MAX = 280; +/** + * v1.4.25 W21 Fix-N (code-M6) — `category` was dropped from the wire + * schema. The route now derives the canonical category from `entry` + * via `categoryForEntry`. Older clients that still send `category` see + * it silently ignored (Zod strips unknown fields by default), and the + * row lands with the correct mapping regardless. No iOS DTO consumes + * the old shape, so this is backwards-compatible without a versioned + * envelope. + */ export const createSideEffectSchema = z.object({ - category: z.enum(SIDE_EFFECT_CATEGORY_VALUES), entry: z.enum(SIDE_EFFECT_ENTRY_VALUES), severity: z.number().int().min(1).max(5), occurredAt: z.iso