fix(api): bound every JSON body read

Raising the middleware body cap to 512 MB for archive imports removed
the implicit ceiling that protected JSON routes, so any bulk endpoint
would buffer an arbitrarily large body before validation ran. Every
JSON body read under /api now carries an explicit byte cap sized to its
legitimate payload (64 KB for single-record routes up to 16 MB for the
measurement import), webhooks cap before signature work, and oversized
bodies return 413.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 11:36:51 +02:00
1 parent 225ae90a86
commit 65677c2112
107 files changed
+689 -226

No files matched your search

+3 -1
View File
@@ -38,7 +38,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const rl = await checkRateLimit("admin-ai-settings", 10, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const { apiKey, model, baseUrl } = body as {
@@ -120,7 +120,11 @@ const handler = apiHandler(
let body: { confirm?: string } = {};
try {
body = (await request.json()) as { confirm?: string };
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw) as { confirm?: string };
} catch {
return apiError("Invalid JSON body", 400);
}
+5 -1
View File
@@ -27,7 +27,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
const body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
@@ -44,10 +44,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.drain.cumulative" } });
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(request);
const { data: rawBody, error: jsonError } = await safeJson<DrainBody>(
request,
{ maxBytes: 64 * 1024 },
);
if (jsonError) return jsonError;
const body: DrainBody = (rawBody && typeof rawBody === "object") ? rawBody : {};
const body: DrainBody = rawBody && typeof rawBody === "object" ? rawBody : {};
const userId =
typeof body.userId === "string" && body.userId.length > 0
? body.userId
@@ -88,7 +91,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
if (summary.totals.bucketsCollapsed === 0 && summary.totals.usersScanned === 0 && userId) {
if (
summary.totals.bucketsCollapsed === 0 &&
summary.totals.usersScanned === 0 &&
userId
) {
return apiError("User not found", 404);
}
+3 -1
View File
@@ -21,7 +21,9 @@ export const PATCH = apiHandler(
const { user } = await requireAdmin();
const { id } = await ctx.params;
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateFeedbackSchema.safeParse(body);
+4 -2
View File
@@ -48,8 +48,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.rollups.recompute" } });
const { data: rawBody, error: jsonError } =
await safeJson<RecomputeBody>(request);
const { data: rawBody, error: jsonError } = await safeJson<RecomputeBody>(
request,
{ maxBytes: 64 * 1024 },
);
if (jsonError) return jsonError;
const body: RecomputeBody =
@@ -120,7 +120,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.assistant-flags.update" } });
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = assistantFlagsSchema.safeParse(body);
+3 -1
View File
@@ -67,7 +67,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.settings.update" } });
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = adminSettingsSchema.safeParse(body);
@@ -39,7 +39,9 @@ export const POST = apiHandler(
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const { password } = body as { password?: string };
+3 -1
View File
@@ -34,7 +34,9 @@ export const PUT = apiHandler(
},
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateUserSchema.safeParse(body);
+5 -2
View File
@@ -39,7 +39,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
let override: AITestOverride = {};
const contentLength = Number(request.headers.get("content-length") ?? "0");
if (contentLength > 0) {
const { data, error } = await safeJson<unknown>(request);
const { data, error } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = overrideSchema.safeParse(data);
if (!parsed.success) {
@@ -140,7 +142,8 @@ function classifyTestFailure(
if (status === 401 || status === 403 || (status >= 500 && looksLikeAuth)) {
return {
reasonCode: "credentials",
reason: "Provider rejected the credentials — re-authenticate in AI settings.",
reason:
"Provider rejected the credentials — re-authenticate in AI settings.",
};
}
if (status === 429) {
+8 -6
View File
@@ -42,10 +42,7 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
checkAuthSurfaceRateLimit,
rateLimitHeaders,
} from "@/lib/rate-limit";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
@@ -77,7 +74,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = bodySchema.safeParse(body);
@@ -109,7 +108,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
const identifierHash = hashToken(identifier);
if (!user) {
annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } });
annotate({
action: { name: "auth.check-user" },
meta: { branch: "not_found" },
});
await auditLog("auth.check-user", {
ipAddress: ip,
details: { branch: "not_found", identifier_hash: identifierHash },
+4 -6
View File
@@ -4,11 +4,7 @@ import { verifyPassword } from "@/lib/auth/password";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import {
apiSuccess,
apiError,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
@@ -45,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = loginPasswordSchema.safeParse(body);
+6 -2
View File
@@ -54,7 +54,9 @@ export const PATCH = apiHandler(
const { user } = await requireAuth();
const { id } = await context.params;
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = devicePatchSchema.safeParse(body);
if (!parsed.success) {
@@ -82,7 +84,9 @@ export const PATCH = apiHandler(
entity_type: "device",
entity_id: id,
},
meta: { medication_delivery: parsed.data.medicationDelivery ?? "inherit" },
meta: {
medication_delivery: parsed.data.medicationDelivery ?? "inherit",
},
});
return apiSuccess({
@@ -91,7 +91,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
return response;
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = patchBodySchema.safeParse(body);
+3 -1
View File
@@ -34,7 +34,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.timezone.update" } });
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const tz =
@@ -1,11 +1,7 @@
import { verifyAuthentication } from "@/lib/auth/passkey";
import { createSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import {
apiSuccess,
apiError,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
@@ -35,8 +31,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } = await safeJson<
Record<string, unknown>
>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
@@ -14,8 +14,9 @@ import { annotate } from "@/lib/logging/context";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } = await safeJson<
Record<string, unknown>
>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const challengeId = body.challengeId as string | undefined;
+3 -1
View File
@@ -32,7 +32,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many attempts. Please wait 15 minutes.", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = changePasswordSchema.safeParse(body);
+3 -1
View File
@@ -12,7 +12,9 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
+2 -6
View File
@@ -4,11 +4,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import {
apiSuccess,
apiError,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
@@ -42,7 +38,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { data: body, error: jsonError } = await safeJson<{
refreshToken?: string;
revoke?: boolean;
}>(request);
}>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
if (!body.refreshToken || typeof body.refreshToken !== "string") {
return apiError("refreshToken required", 422);
+3 -1
View File
@@ -61,7 +61,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Registration is disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = registerSchema.safeParse(body);
+3 -1
View File
@@ -80,7 +80,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024 * 1024,
});
if (jsonError) return jsonError;
const parsed = bugReportSchema.safeParse(body);
+4 -6
View File
@@ -9,11 +9,7 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { consentPostBody } from "@/lib/validations/consent";
@@ -22,7 +18,9 @@ import { createReceipt } from "@/lib/consent/receipts";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
const { data: body, error } = await safeJson(request, {
maxBytes: 128 * 1024,
});
if (error) return error;
const parsed = consentPostBody.safeParse(body);
+3 -1
View File
@@ -56,7 +56,9 @@ export const PATCH = apiHandler(
return apiError("Day-log not found", 404);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = cycleDayLogPatchSchema.safeParse(rawBody);
@@ -0,0 +1,96 @@
/**
* POST /api/cycle/day-logs/bulk — body-size cap.
*
* The bulk drain accepts up to 500 entries; the `safeJson` cap (2 MB)
* rejects an oversized body with 413 before `JSON.parse` builds an
* object graph.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/idempotency", () => ({
withIdempotency:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...args: Args) =>
fn(...args),
}));
vi.mock("@/lib/cycle/gate", () => ({
requireCycleEnabled: vi.fn(),
}));
vi.mock("@/lib/cycle/day-log-write", () => ({
upsertCycleDayLog: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
function postReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/cycle/day-logs/bulk", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(requireCycleEnabled).mockResolvedValue({
enabled: true,
} as never);
});
describe("POST /api/cycle/day-logs/bulk — body cap", () => {
it("rejects a body over the 2 MB cap with 413 before parsing", async () => {
const res = await POST(
postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }),
);
expect(res.status).toBe(413);
expect(upsertCycleDayLog).not.toHaveBeenCalled();
});
it("still rejects an over-cap entries array with 422 below the byte cap", async () => {
const entries = Array.from({ length: 501 }, (_, i) => ({
date: "2026-01-01",
externalId: `e${i}`,
}));
const res = await POST(postReq({ entries }));
expect(res.status).toBe(422);
expect(upsertCycleDayLog).not.toHaveBeenCalled();
});
});
+3 -1
View File
@@ -64,7 +64,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 2 * 1024 * 1024,
});
if (jsonError) return jsonError;
if (
+3 -1
View File
@@ -77,7 +77,9 @@ async function postDayLog(request: NextRequest): Promise<Response> {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
+3 -1
View File
@@ -42,7 +42,9 @@ async function postPeriod(request: NextRequest): Promise<Response> {
const gate = await requireCycleEnabled(user.id, user.gender);
if (!gate.enabled) return gate.response;
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = cyclePeriodSchema.safeParse(rawBody);
@@ -14,6 +14,7 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -52,7 +53,11 @@ export const PATCH = apiHandler(
const { key } = await params;
if (!isCustomSymptomKey(key)) return apiError("Not a custom symptom", 404);
const parsed = updateCustomSymptomSchema.safeParse(await request.json());
const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateCustomSymptomSchema.safeParse(rawJsonBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.symptom.custom.invalid",
+11 -2
View File
@@ -22,6 +22,7 @@ import {
apiError,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -74,12 +75,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
// Cap mutation rate so a single session can't flood the encrypted-label
// catalogue (each create runs an encrypt + audit write).
const rl = await checkRateLimit(`cycle:symptom:custom:${user.id}`, 30, 60_000);
const rl = await checkRateLimit(
`cycle:symptom:custom:${user.id}`,
30,
60_000,
);
if (!rl.allowed) {
return apiError("Too many requests, try again later", 429);
}
const parsed = createCustomSymptomSchema.safeParse(await request.json());
const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createCustomSymptomSchema.safeParse(rawJsonBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.symptom.custom.invalid",
@@ -50,7 +50,9 @@ const prefsSchema = z.object({
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = prefsSchema.safeParse(body);
+7 -4
View File
@@ -143,7 +143,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
// v1.7.0 W1 — accept-and-ignore widget ids OUTSIDE the 27-id catalogue
@@ -165,8 +167,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const droppedIds = widgetsBody.widgets
.map((w) => w?.id)
.filter(
(id): id is string =>
typeof id === "string" && !knownWidgetIds.has(id),
(id): id is string => typeof id === "string" && !knownWidgetIds.has(id),
);
if (droppedIds.length > 0) {
widgetsBody.widgets = widgetsBody.widgets.filter(
@@ -208,7 +209,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: {
+3 -1
View File
@@ -100,7 +100,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many device registrations, please slow down", 429);
}
const { data: body, error } = await safeJson(request);
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = deviceSchema.safeParse(body);
@@ -36,7 +36,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.availability" } });
const { data: body, error } = await safeJson(request);
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const range = normaliseDateRange(body ?? undefined);
const { start, end } = range;
+4 -1
View File
@@ -146,7 +146,10 @@ async function readOptionalJsonBody(
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return null;
try {
return (await request.json()) as PdfRequestBody;
const raw = await request.text();
// Small options object — treat an oversized body as no body.
if (raw.length > 64 * 1024) return null;
return JSON.parse(raw) as PdfRequestBody;
} catch {
return null;
}
+3 -1
View File
@@ -44,7 +44,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 10 reports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const range = normaliseDateRange(body);
+3 -1
View File
@@ -41,7 +41,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Bug reports are disabled by the administrator", 503);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createFeedbackSchema.safeParse(body);
+3 -1
View File
@@ -37,7 +37,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "fitbit.credentials.update" } });
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(fitbitCredentialsSchema, body);
+6 -1
View File
@@ -31,7 +31,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
let fullSync = false;
try {
const body = await request.json();
const raw = await request.text();
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// no body provided -> default incremental sync
@@ -106,6 +106,21 @@ describe("POST /api/import — rate-limit guard", () => {
expect(response.status).toBeLessThan(400);
});
it("rejects a body over the 16 MB cap with 413 before parsing", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
const response = await POST(
new NextRequest("http://localhost/api/import", {
method: "POST",
body: JSON.stringify({
measurements: [],
pad: "x".repeat(16 * 1024 * 1024),
}),
headers: { "content-type": "application/json" },
}),
);
expect(response.status).toBe(413);
});
it("returns the standard apiError envelope for invalid payloads", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
+3 -1
View File
@@ -76,7 +76,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Maximum 5 imports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024 * 1024,
});
if (jsonError) return jsonError;
const parsed = importSchema.safeParse(body);
+3 -1
View File
@@ -89,7 +89,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
data: { lastUsedAt: new Date() },
});
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = externalIntakeSchema.safeParse(body);
@@ -79,7 +79,9 @@ async function handlePost(request: NextRequest, ctx: RouteContext) {
return apiError("Too many feedback submissions, try again later", 429);
}
const { data: rawBody, error } = await safeJson<unknown>(request);
const { data: rawBody, error } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = coachMessageFeedbackSchema.safeParse(rawBody);
if (!parsed.success) {
+7 -2
View File
@@ -157,8 +157,13 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
const raw = await request.text();
if (raw.length > 64 * 1024) {
throw new HttpError(413, `Request body exceeds ${64 * 1024} bytes`);
}
body = JSON.parse(raw);
} catch (err) {
if (err instanceof HttpError) throw err;
throw new HttpError(400, "Invalid JSON body");
}
const parsed = coachChatRequestSchema.safeParse(body);
+3 -1
View File
@@ -55,7 +55,9 @@ async function handlePost(request: NextRequest) {
return apiError("Too many feedback submissions, try again later", 429);
}
const { data: rawBody, error } = await safeJson<unknown>(request);
const { data: rawBody, error } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = recommendationFeedbackRequestSchema.safeParse(rawBody);
@@ -99,10 +99,9 @@ vi.mock("@/lib/logging/context", () => ({
}));
vi.mock("@/lib/insights/features", async () => {
const actual =
await vi.importActual<typeof import("@/lib/insights/features")>(
"@/lib/insights/features",
);
const actual = await vi.importActual<
typeof import("@/lib/insights/features")
>("@/lib/insights/features");
return {
...actual,
extractFeatures: vi.fn(async () => ({ stub: true })),
@@ -193,6 +192,13 @@ function makeProviderThatThrows(
}
describe("POST /api/insights/generate — provider error mapping", () => {
it("rejects a body over the 16 KB cap with 413 before parsing", async () => {
const res = await POST(
jsonRequest({ force: true, pad: "x".repeat(16 * 1024) }) as never,
);
expect(res.status).toBe(413);
});
it("maps a 401 from the provider to 422 with a readable message", async () => {
const err = Object.assign(new Error("OpenAI request failed (401)"), {
httpStatus: 401,
@@ -421,7 +427,10 @@ describe("POST /api/insights/generate — payload-size hard downgrade (H1)", ()
(call[0] as { meta?: Record<string, unknown> })?.meta
?.insights_payload_too_large === true,
);
expect(annotated, "annotate event with insights_payload_too_large").toBeTruthy();
expect(
annotated,
"annotate event with insights_payload_too_large",
).toBeTruthy();
});
});
+11 -4
View File
@@ -1,6 +1,11 @@
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import {
extractFeatures,
FeaturesPayloadTooLargeError,
@@ -267,8 +272,7 @@ export const GET = apiHandler(async () => {
const cachedAt = dbUser?.insightsCachedAt ?? null;
const isFresh =
cachedAt !== null &&
Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
cachedAt !== null && Date.now() - cachedAt.getTime() < BRIEFING_FRESH_MS;
// Read-only: never block on the provider. Warm out of band only when the
// cached briefing is stale / missing AND a provider is configured (a
@@ -341,7 +345,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
userLocale: dbUser?.locale ?? user.locale ?? null,
});
const body = await request.json().catch(() => ({}));
const { data: body, error: jsonError } = await safeJson<{
force?: unknown;
}>(request, { maxBytes: 16 * 1024 });
if (jsonError) return jsonError;
const forceRefresh = body.force === true;
if (
+6 -2
View File
@@ -117,7 +117,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = layoutSchema.safeParse(body);
@@ -127,7 +129,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// round-trip; the wide-event line carries the redacted payload
// shape for operator debugging without leaking the raw body.
const issues = sanitiseZodIssues(parsed.error.issues);
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
const payloadDiagnostic = buildPayloadDiagnostic(
redactSensitiveFields(body),
);
annotate({
action: { name: "insights.layout.validation-failed" },
meta: {
+3 -1
View File
@@ -100,7 +100,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "insights.provider_chain.update" } });
const { data: body, error } = await safeJson<unknown>(request);
const { data: body, error } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = chainBodySchema.safeParse(body);
+3 -2
View File
@@ -42,8 +42,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } =
await safeJson<Record<string, unknown>>(request);
const { data: body, error: jsonError } = await safeJson<
Record<string, unknown>
>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const data: Record<string, unknown> = {};
+3 -1
View File
@@ -139,7 +139,9 @@ export const GET = apiHandler(async () => {
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = patchSchema.safeParse(body);
@@ -22,7 +22,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
let fullSync = false;
try {
const body = await request.json();
const raw = await request.text();
// Flag-only payload — cap the parse cost (mirrors safeJson maxBytes).
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
fullSync = body?.fullSync === true;
} catch {
// No body or invalid JSON — use defaults
@@ -74,7 +74,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
// 5. Parse body
let body: Record<string, unknown>;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 256 * 1024) {
return apiError(`Request body exceeds ${256 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON", 400);
}
+1 -1
View File
@@ -176,7 +176,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
getEvent()?.setAuth({ auth_method: "webhook_secret" });
const { data: payload, error: jsonError } =
await safeJson<CoolifyDeployPayload>(request);
await safeJson<CoolifyDeployPayload>(request, { maxBytes: 64 * 1024 });
if (jsonError) return jsonError;
const event = normalizePayload(payload);
+13 -7
View File
@@ -57,7 +57,13 @@ const webVitalsBodySchema = z.object({
delta: z.number().finite().optional(),
rating: z.enum(["good", "needs-improvement", "poor"]).optional(),
navigationType: z
.enum(["navigate", "reload", "back-forward", "back-forward-cache", "prerender"])
.enum([
"navigate",
"reload",
"back-forward",
"back-forward-cache",
"prerender",
])
.optional(),
});
@@ -90,11 +96,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
// ≤ 7 beacons / page-load and well below a flood. 429 on hit; the
// beacon contract still accepts 4xx without retry on the client.
const ip = getClientIp(request);
const rl = await checkRateLimit(
`web-vitals:${ip ?? "unknown"}`,
60,
60_000,
);
const rl = await checkRateLimit(`web-vitals:${ip ?? "unknown"}`, 60, 60_000);
if (!rl.allowed) {
return NextResponse.json(
{ error: "rate_limited" },
@@ -104,7 +106,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let raw: unknown;
try {
raw = await request.json();
const text = await request.text();
if (text.length > 64 * 1024) {
return NextResponse.json({ error: "payload_too_large" }, { status: 413 });
}
raw = JSON.parse(text);
} catch {
// Malformed payload — 400 so the beacon never retries. Crucially:
// we do NOT log the raw body (log-injection defence).
+3 -1
View File
@@ -66,7 +66,9 @@ export const PUT = apiHandler(
return apiError("Measurement not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateMeasurementSchema.safeParse(body);
@@ -173,6 +173,15 @@ describe("POST /api/measurements/bulk-delete", () => {
expect(res.status).toBe(422);
});
it("rejects a body over the 256 KB cap with 413 before parsing", async () => {
const res = await POST(
postReq({ ids: ["m1"], pad: "x".repeat(256 * 1024) }),
);
expect(res.status).toBe(413);
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
expect(prisma.measurement.updateMany).not.toHaveBeenCalled();
});
it("429s when the rate limit is exceeded", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(postReq({ ids: ["m1"] }));
@@ -59,7 +59,9 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
return apiError("Too many bulk deletions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = bulkDeleteSchema.safeParse(rawBody);
@@ -55,7 +55,9 @@ export const DELETE = apiHandler(deleteByExternalIds);
async function deleteByExternalIds(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the iOS
+6 -14
View File
@@ -336,13 +336,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// the live `date_trunc` path so a brand-new account whose
// populator hasn't caught up still sees a correct chart on its
// first render.
if (
source === "rollup" &&
aggregate === "daily" &&
type &&
from &&
to
) {
if (source === "rollup" && aggregate === "daily" && type && from && to) {
const cap = Math.min(limit, BUCKET_CAP.daily);
// v1.11.1 — rollup rows are per source; collapse overlapping sources to the
// ladder-canonical reading per day before the chart consumes them, so a
@@ -422,9 +416,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
// Cheap probe — one indexed range scan over
// `(user_id, type, measured_at)`. Returns the count of distinct
// calendar days the live table holds for the window.
const liveDayCountRows = await prisma.$queryRaw<
Array<{ days: bigint }>
>`
const liveDayCountRows = await prisma.$queryRaw<Array<{ days: bigint }>>`
SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days
FROM measurements m
WHERE m."user_id" = ${user.id}
@@ -504,9 +496,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
const measurements = effectiveRows.map((r) => ({
type: r.type,
value: useSum
? (r.sumValue ?? r.mean * r.count)
: r.mean,
value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
// v1.8.5 — per-day min / max so the chart can shade an
@@ -847,7 +837,9 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
async function postMeasurement(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
@@ -74,7 +74,11 @@ export const PUT = apiHandler(
let enabled = false;
try {
const body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
enabled = body?.enabled === true;
} catch {
return apiError("Invalid request", 422);
+3 -1
View File
@@ -150,7 +150,9 @@ export const POST = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = glp1PostBodySchema.safeParse(body);
@@ -40,7 +40,9 @@ export const PUT = apiHandler(
return apiError("Intake not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateIntakeEventSchema.safeParse(body);
@@ -77,7 +77,9 @@ export const POST = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = bulkDeleteIntakeEventsSchema.safeParse(body);
if (!parsed.success) {
@@ -71,6 +71,11 @@ beforeEach(() => {
});
describe("POST /api/medications/[id]/intake/import — 422 multi-issue (v1.4.43 W6)", () => {
it("rejects a body over the 1 MB cap with 413 before parsing", async () => {
const res = await POST(postReq(["x".repeat(1024 * 1024)]), ROUTE_CTX);
expect(res.status).toBe(413);
});
it("surfaces TWO simultaneous validation errors", async () => {
// Two entries with malformed datum + malformed uhrzeit.
const res = await POST(
@@ -37,7 +37,9 @@ export const POST = apiHandler(
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024 * 1024,
});
if (jsonError) return jsonError;
+10 -4
View File
@@ -44,7 +44,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = intakeSchema.safeParse({
@@ -126,9 +128,13 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: id, site: resolution.site },
});
return apiError("Injection site is not allowed for this medication", 422, {
errorCode: "medications.intake.injection_site.disallowed",
});
return apiError(
"Injection site is not allowed for this medication",
422,
{
errorCode: "medications.intake.injection_site.disallowed",
},
);
}
resolvedInjectionSite = resolution.site;
}
@@ -56,7 +56,9 @@ export const PATCH = apiHandler(
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateInventoryItemSchema.safeParse(body);
@@ -79,7 +79,9 @@ export const POST = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
@@ -1,11 +1,7 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { phaseConfigSchema } from "@/lib/validations/phase-config";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
@@ -58,7 +54,9 @@ export const PUT = apiHandler(
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = phaseConfigSchema.safeParse(body);
+24 -21
View File
@@ -49,21 +49,22 @@ export const GET = apiHandler(
// cadences re-anchor on the latest non-skipped intake, so fetch it
// once (no-op for calendar cadences but cheap + keeps the value
// correct for rolling injections).
const lastIntake =
medication.schedules.some((s) => s.rollingIntervalDays !== null)
? await prisma.medicationIntakeEvent.findFirst({
// v1.7.0 sync — a tombstoned intake no longer anchors the
// rolling-interval next-due computation.
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: { not: null },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
})
: null;
const lastIntake = medication.schedules.some(
(s) => s.rollingIntervalDays !== null,
)
? await prisma.medicationIntakeEvent.findFirst({
// v1.7.0 sync — a tombstoned intake no longer anchors the
// rolling-interval next-due computation.
where: {
userId: user.id,
medicationId: id,
deletedAt: null,
takenAt: { not: null },
},
orderBy: { takenAt: "desc" },
select: { takenAt: true },
})
: null;
// v1.15.10 — slots the user has already acted on near now, so the
// next-due search skips them and advances to the next genuinely-open
// slot. Bound the read to [now-1d, now+2d] — the lookahead only needs the
@@ -136,7 +137,9 @@ export const PUT = apiHandler(
return apiError("Medication not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateMedicationSchema.safeParse(body);
@@ -223,8 +226,7 @@ export const PUT = apiHandler(
// Normalise endsOn for one-shot. `oneShot === true` + `startsOn`
// means the dose is the start date; endsOn auto-matches.
const normalisedEndsOn =
oneShot === true && startsOn ? startsOn : endsOn;
const normalisedEndsOn = oneShot === true && startsOn ? startsOn : endsOn;
const pausedAtPatch =
active === undefined
@@ -321,7 +323,9 @@ export const PUT = apiHandler(
// v1.15.18 — per-dose configurable on-time windows. A `schedules`
// replace re-creates the rows, so the column is re-written each
// time; absent leaves it NULL (default ±1h derivation).
...(s.doseWindows !== undefined && { doseWindows: s.doseWindows }),
...(s.doseWindows !== undefined && {
doseWindows: s.doseWindows,
}),
};
}),
},
@@ -373,8 +377,7 @@ export const PUT = apiHandler(
await prisma.medicationSchedule.update({
where: { id: primaryScheduleGracePatch.scheduleId },
data: {
reminderGraceMinutes:
primaryScheduleGracePatch.reminderGraceMinutes,
reminderGraceMinutes: primaryScheduleGracePatch.reminderGraceMinutes,
},
});
const refreshed = await prisma.medication.findUnique({
@@ -111,7 +111,9 @@ export const POST = apiHandler(
});
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
@@ -85,6 +85,13 @@ beforeEach(() => {
});
describe("POST /api/medications/intake/bulk — 422 multi-issue (v1.4.43 W6)", () => {
it("rejects a body over the 2 MB cap with 413 before parsing", async () => {
const res = await POST(
postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }),
);
expect(res.status).toBe(413);
});
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
@@ -319,7 +326,10 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => {
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { updated: number; entries: Array<{ status: string; id?: string }> };
data: {
updated: number;
entries: Array<{ status: string; id?: string }>;
};
};
// The dose is APPLIED to the existing row, not silently dropped.
expect(body.data.updated).toBe(1);
@@ -491,7 +501,10 @@ describe("POST /api/medications/intake/bulk — v1.15.19 resolver-null convergen
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { duplicates: number; entries: Array<{ status: string; id?: string }> };
data: {
duplicates: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(body.data.duplicates).toBe(1);
expect(body.data.entries[0].status).toBe("duplicate");
+11 -3
View File
@@ -112,7 +112,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 2 * 1024 * 1024,
});
if (jsonError) return jsonError;
if (
@@ -207,7 +209,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
// touched by the batch so one rollup recompute fires per pair after
// all inserts complete. Per-row recompute would balloon a 500-entry
// batch into 500 sequential rollup hits.
const touchedDays = new Map<string, { medicationId: string; dayKey: string }>();
const touchedDays = new Map<
string,
{ medicationId: string; dayKey: string }
>();
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
@@ -409,7 +414,10 @@ async function postBulk(request: NextRequest): Promise<Response> {
results.push({ index: i, status: "inserted", id: row.id });
}
}
const dayKey = dayKeyForScheduledFor(effectiveScheduledFor, user.timezone);
const dayKey = dayKeyForScheduledFor(
effectiveScheduledFor,
user.timezone,
);
touchedDays.set(`${entry.medicationId}|${dayKey}`, {
medicationId: entry.medicationId,
dayKey,
+29 -10
View File
@@ -277,7 +277,9 @@ async function buildScheduleAnchoredComplianceBuckets(
const dayKeys: string[] = [];
const dayBounds = new Map<string, { start: Date; end: Date }>();
for (let i = days - 1; i >= 0; i--) {
const representative = new Date(nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000);
const representative = new Date(
nowMs - i * 86_400_000 - 12 * 60 * 60 * 1000,
);
const { start: dayStart, end: dayEndInclusive } = getUserTodayBounds(
representative,
userTz,
@@ -297,7 +299,12 @@ async function buildScheduleAnchoredComplianceBuckets(
// Group events by medication so each med's engine context is built once.
const eventsByMed = new Map<
string,
{ scheduledFor: Date; takenAt: Date | null; skipped: boolean; autoMissed: boolean }[]
{
scheduledFor: Date;
takenAt: Date | null;
skipped: boolean;
autoMissed: boolean;
}[]
>();
for (const e of events) {
const list = eventsByMed.get(e.medicationId) ?? [];
@@ -363,7 +370,9 @@ async function buildScheduleAnchoredComplianceBuckets(
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const parsed = updateSchema.safeParse(body);
@@ -393,8 +402,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
return returnAllZodIssues(parsed.error, 422);
}
const { intakeId, status, takenAt, snoozedUntil, injectionSite, forceSlotInstant } =
parsed.data;
const {
intakeId,
status,
takenAt,
snoozedUntil,
injectionSite,
forceSlotInstant,
} = parsed.data;
// v1.7.0 sync — a tombstoned intake 404s on a status toggle; the
// `deletedAt: null` filter refuses to mutate a soft-deleted row.
@@ -428,8 +443,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
taken: status === "taken",
deliveryForm: existing.medication.deliveryForm,
trackInjectionSites: existing.medication.trackInjectionSites,
allowedInjectionSites:
existing.medication.allowedInjectionSites as InjectionSiteValue[],
allowedInjectionSites: existing.medication
.allowedInjectionSites as InjectionSiteValue[],
globalExcludedInjectionSites: (userRow?.globalExcludedInjectionSites ??
[]) as InjectionSiteValue[],
});
@@ -438,9 +453,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "medication.intake.injection_site.disallowed" },
meta: { medication_id: existing.medicationId, site: resolution.site },
});
return apiError("Injection site is not allowed for this medication", 422, {
errorCode: "medications.intake.injection_site.disallowed",
});
return apiError(
"Injection site is not allowed for this medication",
422,
{
errorCode: "medications.intake.injection_site.disallowed",
},
);
}
resolvedInjectionSite = resolution.site;
}
+9 -3
View File
@@ -38,7 +38,9 @@ async function buildMedicationsList(
// today-end + 2d] — the next-due lookahead only needs the slots adjacent to
// now, and a tight window keeps the read cheap. The 60s list cache covers
// the rest.
const resolvedWindowEnd = new Date(todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000);
const resolvedWindowEnd = new Date(
todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
);
const [medications, latestIntakes, todayEvents, resolvedEvents] =
await Promise.all([
@@ -174,7 +176,9 @@ export const GET = apiHandler(async () => {
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createMedicationSchema.safeParse(body);
@@ -319,7 +323,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
rollingIntervalDays: s.rollingIntervalDays,
}),
// v1.7.0 — schedule type + cyclic weeks, field-by-field.
...(s.scheduleType !== undefined && { scheduleType: s.scheduleType }),
...(s.scheduleType !== undefined && {
scheduleType: s.scheduleType,
}),
...(s.cyclicOnWeeks !== undefined && {
cyclicOnWeeks: s.cyclicOnWeeks,
}),
+5 -1
View File
@@ -30,7 +30,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 256 * 1024) {
return apiError(`Request body exceeds ${256 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
+3 -1
View File
@@ -73,7 +73,9 @@ export const PUT = apiHandler(
return apiError("Mood entry not found", 404);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateMoodEntrySchema.safeParse(body);
@@ -54,7 +54,9 @@ async function postBulkDelete(request: NextRequest): Promise<Response> {
return apiError("Too many bulk deletions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 256 * 1024,
});
if (jsonError) return jsonError;
const parsed = bulkDeleteSchema.safeParse(rawBody);
@@ -101,6 +101,13 @@ beforeEach(() => {
});
describe("POST /api/mood-entries/bulk — 422 multi-issue (v1.4.43 W6)", () => {
it("rejects a body over the 2 MB cap with 413 before parsing", async () => {
const res = await POST(
postReq({ entries: [], pad: "x".repeat(2 * 1024 * 1024) }),
);
expect(res.status).toBe(413);
});
it("surfaces TWO simultaneous validation errors", async () => {
const res = await POST(
postReq({
+7 -2
View File
@@ -78,7 +78,10 @@ const bulkEntrySchema = z.object({
*/
ratedFactors: z
.array(
z.object({ key: z.string().max(60), rating: z.number().int().min(1).max(5) }),
z.object({
key: z.string().max(60),
rating: z.number().int().min(1).max(5),
}),
)
.max(30)
.optional(),
@@ -126,7 +129,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
return apiError("Too many bulk submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request);
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 2 * 1024 * 1024,
});
if (jsonError) return jsonError;
if (
+7 -2
View File
@@ -130,7 +130,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
// form re-renders the sliders without a refetch.
ratedFactors: tagLinks
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
.map((link) => ({ key: link.moodTag.key, rating: link.rating as number })),
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
}));
return apiSuccess({
@@ -144,7 +147,9 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
async function postMoodEntry(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createMoodEntrySchema.safeParse(body);
+11 -2
View File
@@ -1,7 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { hideCatalogueTagSchema, isCustomTagKey } from "@/lib/mood/custom-tags";
@@ -29,7 +34,11 @@ export const PUT = apiHandler(
);
}
const parsed = hideCatalogueTagSchema.safeParse(await request.json());
const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = hideCatalogueTagSchema.safeParse(rawJsonBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
// Resolve against the catalogue (user_id NULL) only.
+11 -2
View File
@@ -1,7 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -30,7 +35,11 @@ export const PATCH = apiHandler(
const { key } = await params;
if (!isCustomTagKey(key)) return apiError("Not a custom tag", 404);
const parsed = updateCustomTagSchema.safeParse(await request.json());
const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = updateCustomTagSchema.safeParse(rawJsonBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const owned = await prisma.moodTag.findFirst({
+23 -4
View File
@@ -1,7 +1,12 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import {
apiSuccess,
apiError,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
@@ -24,7 +29,11 @@ export const dynamic = "force-dynamic";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = createCustomTagSchema.safeParse(await request.json());
const { data: rawJsonBody, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createCustomTagSchema.safeParse(rawJsonBody);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const activeCount = await prisma.moodTag.count({
@@ -52,10 +61,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
sortOrder: activeCount,
userId: user.id,
},
select: { key: true, icon: true, kind: true, scaleMin: true, scaleMax: true, inverse: true },
select: {
key: true,
icon: true,
kind: true,
scaleMin: true,
scaleMax: true,
inverse: true,
},
});
annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon } });
annotate({
action: { name: "mood.tag.custom.create" },
meta: { icon: created.icon },
});
return apiSuccess(
{
@@ -96,7 +96,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
+5 -1
View File
@@ -150,7 +150,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
+10 -2
View File
@@ -28,7 +28,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
@@ -90,7 +94,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON data", 422);
}
+3 -1
View File
@@ -21,7 +21,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.complete" } });
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(onboardingSchema, body);
+4 -5
View File
@@ -61,7 +61,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Too many onboarding writes, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson<unknown>(request);
const { data: body, error: jsonError } = await safeJson<unknown>(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = stepBodySchema.safeParse(body);
@@ -102,10 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "onboarding.step" },
meta: { outcome: "out_of_order", current, requested: step },
});
return apiError(
`Step out of order — current step is ${current}`,
409,
);
return apiError(`Step out of order — current step is ${current}`, 409);
}
const completing = step === 4;
+3 -1
View File
@@ -33,7 +33,9 @@ const tourBodySchema = z.object({
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = z.safeParse(tourBodySchema, body);
+5 -1
View File
@@ -17,7 +17,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
const body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
+5 -1
View File
@@ -15,7 +15,11 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
let confirm = "";
try {
const body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
const body = JSON.parse(raw);
confirm = typeof body?.confirm === "string" ? body.confirm : "";
} catch {
return apiError("Invalid request", 422);
+5 -1
View File
@@ -17,7 +17,11 @@ export const PUT = apiHandler(async (request: NextRequest) => {
let body: unknown;
try {
body = await request.json();
const raw = await request.text();
if (raw.length > 64 * 1024) {
return apiError(`Request body exceeds ${64 * 1024} bytes`, 413);
}
body = JSON.parse(raw);
} catch {
return apiError("Invalid JSON", 400);
}
+3 -1
View File
@@ -47,7 +47,9 @@ export const GET = apiHandler(async () => {
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = ntfySettingsSchema.safeParse(body);
+3 -1
View File
@@ -49,7 +49,9 @@ export const PUT = apiHandler(async (request: NextRequest) => {
});
if (!current) return apiError("User not found", 404);
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = z.safeParse(telegramSettingsSchema, body);
+7 -1
View File
@@ -684,7 +684,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
let update: TelegramUpdate;
try {
update = (await request.json()) as TelegramUpdate;
const raw = await request.text();
if (raw.length > 256 * 1024) {
// Oversized payload — acknowledge with 200 like invalid JSON so the
// sender does not retry-loop the update.
return NextResponse.json({ status: "invalid json" }, { status: 200 });
}
update = JSON.parse(raw) as TelegramUpdate;
} catch {
return NextResponse.json({ status: "invalid json" }, { status: 200 });
}
+3 -1
View File
@@ -52,7 +52,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("API is globally disabled", 403);
}
const { data: body, error: jsonError } = await safeJson(request);
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const parsed = createTokenSchema.safeParse(body);
+4 -2
View File
@@ -46,8 +46,10 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.ai-provider.update" } });
const { data: body, error } =
await safeJson<Record<string, unknown>>(request);
const { data: body, error } = await safeJson<Record<string, unknown>>(
request,
{ maxBytes: 64 * 1024 },
);
if (error) return error;
const updates: Record<string, unknown> = {};
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff. Show more