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<string,string> } 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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:09:50 +02:00
1 parent 2b712429c0
commit 8399c47874
12 files changed
+122 -139

No files matched your search

@@ -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);
}
@@ -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);
}
@@ -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);
}
+4 -19
View File
@@ -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);
+7 -8
View File
@@ -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);
@@ -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);
@@ -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 },
@@ -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 () => {
@@ -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(),
+18 -28
View File
@@ -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;
}
+21 -3
View File
@@ -10,15 +10,33 @@ export function apiSuccess<T>(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<string, unknown>,
meta?: {
errorCode?: string;
headers?: Record<string, string>;
} & Record<string, unknown>,
) {
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 } : {}),
},
);
}
@@ -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