From c7371e619a69bc102191651d60fb61201c6396cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:58:28 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20pf=2048?= =?UTF-8?q?=EB=B0=94=ED=80=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yb77jeCcRxZfC8EHziz4Gg From 4080a10d36388798610d06ba228164f56207c1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:03:36 +0000 Subject: [PATCH 2/3] web: community write routes answer a null body with a 400 null parses as JSON, so it passed every route's catch and threw on the first field read. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yb77jeCcRxZfC8EHziz4Gg --- web/src/app/api/community/atlas/route.ts | 9 +- web/src/app/api/community/builds/route.ts | 13 +- .../app/api/community/comments/[id]/route.ts | 11 +- web/src/app/api/community/decks/[id]/route.ts | 10 +- web/src/app/api/community/decks/route.ts | 10 +- web/src/app/api/community/featured/route.ts | 11 +- web/src/app/api/community/nullBody.test.ts | 133 ++++++++++++++++++ .../community/patterns/[id]/comments/route.ts | 11 +- .../community/patterns/[id]/header/route.ts | 10 +- .../patterns/[id]/performance/route.ts | 10 +- .../app/api/community/patterns/[id]/route.ts | 10 +- web/src/app/api/community/patterns/route.ts | 10 +- web/src/app/api/community/ports/[id]/route.ts | 10 +- .../community/posts/[id]/comments/route.ts | 11 +- .../app/api/community/posts/[id]/pin/route.ts | 11 +- web/src/app/api/community/posts/[id]/route.ts | 10 +- web/src/app/api/community/posts/route.ts | 11 +- web/src/app/api/community/presence/route.ts | 9 +- .../app/api/community/reports/[id]/route.ts | 11 +- web/src/app/api/community/reports/route.ts | 10 +- .../api/community/territories/[code]/route.ts | 10 +- .../app/api/community/territories/route.ts | 10 +- web/src/lib/community/jsonBody.test.ts | 47 +++++++ web/src/lib/community/jsonBody.ts | 21 +++ 24 files changed, 272 insertions(+), 147 deletions(-) create mode 100644 web/src/app/api/community/nullBody.test.ts create mode 100644 web/src/lib/community/jsonBody.test.ts create mode 100644 web/src/lib/community/jsonBody.ts diff --git a/web/src/app/api/community/atlas/route.ts b/web/src/app/api/community/atlas/route.ts index 2cd779fa1..f13c690a3 100644 --- a/web/src/app/api/community/atlas/route.ts +++ b/web/src/app/api/community/atlas/route.ts @@ -3,6 +3,7 @@ import { ENTRY_BY_ID } from "@/lib/atlas/data"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { listAtlasPins } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -84,12 +85,8 @@ async function handleWrite(request: Request, mode: "place" | "remove") { return Response.json({ error: "Too many moves — let the pin settle a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const patternId = typeof payload.patternId === "string" ? payload.patternId : ""; if (!patternId) { diff --git a/web/src/app/api/community/builds/route.ts b/web/src/app/api/community/builds/route.ts index d12411762..e8aa6f459 100644 --- a/web/src/app/api/community/builds/route.ts +++ b/web/src/app/api/community/builds/route.ts @@ -8,6 +8,7 @@ import { type BuildPatternInput, } from "@/lib/community/server/builds"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled } from "@/lib/community/server/db"; import { rateLimit } from "@/lib/community/ratelimit"; import { CPP_MAX } from "@/lib/community/validate"; @@ -70,12 +71,8 @@ async function handlePost(request: Request) { ); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; // "bin" asked for a whole firmware image. The community built those from 25 // July 2026 (c5e0907) until 4c13474 removed them on 12 August, and since @@ -84,7 +81,7 @@ async function handlePost(request: Request) { // for an image, sent no format, and a missing format meant "bin" until 12 // August. The 410 is for a caller outside this repository that names the // format, as this route's header told it to from 27 July (d7cd193). - const rawFormat = (body as Record).format ?? "pfm"; + const rawFormat = body.format ?? "pfm"; if (rawFormat === "bin") { return Response.json( { @@ -100,7 +97,7 @@ async function handlePost(request: Request) { } const format: BuildFormat = rawFormat; - const raw = (body as Record).patterns; + const raw = body.patterns; if (!Array.isArray(raw) || raw.length === 0) { return Response.json({ error: "Send at least one pattern to build." }, { status: 400 }); } diff --git a/web/src/app/api/community/comments/[id]/route.ts b/web/src/app/api/community/comments/[id]/route.ts index 8a9307705..cf3b030bb 100644 --- a/web/src/app/api/community/comments/[id]/route.ts +++ b/web/src/app/api/community/comments/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getCommentStub } from "@/lib/community/server/queries"; @@ -64,14 +65,10 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own comments." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const next = cleanComment((body as Record).body); + const next = cleanComment(body.body); if (!next) { return Response.json( { error: `A comment cannot be empty, and must be under ${COMMENT_MAX} characters.` }, diff --git a/web/src/app/api/community/decks/[id]/route.ts b/web/src/app/api/community/decks/[id]/route.ts index 6e9984c7c..992ef7ddc 100644 --- a/web/src/app/api/community/decks/[id]/route.ts +++ b/web/src/app/api/community/decks/[id]/route.ts @@ -7,6 +7,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -101,13 +102,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own decks." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (moderating) { if (!moderatorVisibilityPatchOnly(raw)) { diff --git a/web/src/app/api/community/decks/route.ts b/web/src/app/api/community/decks/route.ts index 3aa0ab1fb..99b6f6c40 100644 --- a/web/src/app/api/community/decks/route.ts +++ b/web/src/app/api/community/decks/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { DECK_MAX, PUBLIC_DECKS_MAX } from "@/lib/community/deck"; import { checkDeckPattern, cleanPatternIds } from "@/lib/community/deckShare"; @@ -42,13 +43,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many deck updates — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); diff --git a/web/src/app/api/community/featured/route.ts b/web/src/app/api/community/featured/route.ts index e80d3a825..4ef3bc9c4 100644 --- a/web/src/app/api/community/featured/route.ts +++ b/web/src/app/api/community/featured/route.ts @@ -2,6 +2,7 @@ import { inArray } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { featuredPatterns, patterns } from "@/lib/community/server/schema"; @@ -35,14 +36,10 @@ async function handlePut(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const raw = (payload as { patternIds?: unknown }).patternIds; + const raw = payload.patternIds; if (!Array.isArray(raw) || raw.some((id) => typeof id !== "string")) { return Response.json({ error: "Expected patternIds: string[]." }, { status: 400 }); } diff --git a/web/src/app/api/community/nullBody.test.ts b/web/src/app/api/community/nullBody.test.ts new file mode 100644 index 000000000..0c4a4c807 --- /dev/null +++ b/web/src/app/api/community/nullBody.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterAll, beforeAll, expect, it } from "vitest"; + +// `null` is valid JSON, so it got past the `catch` around every write route's +// `request.json()` and threw a TypeError on the first field read. Next.js +// answered that with a bare 500: no JSON saying why, and no CORS headers, so a +// Pattern Lab on another origin could not read even the status. Every route +// below is driven with a signed-in moderator and the Lab's Origin, on a +// database of its own, with something for each id in the path to find. +// +// territories/[code]/pin is not here: its body is optional, and a `null` one +// already pins with no note. + +const LAB_ORIGIN = "https://lab.patternflow.test"; +const ENV_KEYS = [ + "COMMUNITY_DB_PATH", + "COMMUNITY_ENABLED", + "BETTER_AUTH_SECRET", + "COMMUNITY_ADMIN_USERNAMES", + "COMMUNITY_ALLOWED_ORIGINS", + "BUILD_ENABLED", +] as const; +const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); +let tmp = ""; +let cookie = ""; + +type Handler = (request: Request, context: { params: Promise> }) => Promise; +type RouteModule = Record; + +// [method, path, module loader, path params] +const ROUTES: [string, string, () => Promise, Record][] = [ + ["POST", "atlas", () => import("./atlas/route"), {}], + ["DELETE", "atlas", () => import("./atlas/route"), {}], + ["POST", "builds", () => import("./builds/route"), {}], + ["PATCH", "comments/c1?on=pattern", () => import("./comments/[id]/route"), { id: "c1" }], + ["POST", "decks", () => import("./decks/route"), {}], + ["PATCH", "decks/d1", () => import("./decks/[id]/route"), { id: "d1" }], + ["PUT", "featured", () => import("./featured/route"), {}], + ["POST", "patterns", () => import("./patterns/route"), {}], + ["PATCH", "patterns/p1", () => import("./patterns/[id]/route"), { id: "p1" }], + ["POST", "patterns/p1/comments", () => import("./patterns/[id]/comments/route"), { id: "p1" }], + ["POST", "patterns/p1/header", () => import("./patterns/[id]/header/route"), { id: "p1" }], + ["POST", "patterns/p1/performance", () => import("./patterns/[id]/performance/route"), { id: "p1" }], + ["PATCH", "ports/h1", () => import("./ports/[id]/route"), { id: "h1" }], + ["POST", "posts", () => import("./posts/route"), {}], + ["PATCH", "posts/s1", () => import("./posts/[id]/route"), { id: "s1" }], + ["POST", "posts/s1/comments", () => import("./posts/[id]/comments/route"), { id: "s1" }], + ["POST", "posts/s1/pin", () => import("./posts/[id]/pin/route"), { id: "s1" }], + ["POST", "presence", () => import("./presence/route"), {}], + ["POST", "reports", () => import("./reports/route"), {}], + ["PATCH", "reports/r1", () => import("./reports/[id]/route"), { id: "r1" }], + ["POST", "territories", () => import("./territories/route"), {}], + ["PATCH", "territories/A1", () => import("./territories/[code]/route"), { code: "A1" }], +]; + +beforeAll(async () => { + // The database is a lazy singleton keyed off these variables, so they are + // set before anything imports it. + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pf-null-body-")); + process.env.COMMUNITY_DB_PATH = path.join(tmp, "test.db"); + process.env.COMMUNITY_ENABLED = "1"; + process.env.BETTER_AUTH_SECRET = "null-body-test-secret-not-a-real-one"; + process.env.COMMUNITY_ADMIN_USERNAMES = "ada"; + process.env.COMMUNITY_ALLOWED_ORIGINS = LAB_ORIGIN; + process.env.BUILD_ENABLED = "1"; + + const { eq } = await import("drizzle-orm"); + const { getAuth } = await import("@/lib/community/server/auth"); + const { getDb } = await import("@/lib/community/server/db"); + const schema = await import("@/lib/community/server/schema"); + + const response = await getAuth().api.signUpEmail({ + body: { email: "ada@patternflow.local", password: "null-body-test-password", name: "ada", username: "ada" }, + asResponse: true, + }); + const setCookie = response.headers.get("set-cookie"); + if (!setCookie) throw new Error("no session cookie for ada"); + cookie = setCookie.split(";")[0]; + + const db = getDb(); + const [ada] = await db + .select({ id: schema.user.id }) + .from(schema.user) + .where(eq(schema.user.username, "ada")) + .limit(1); + const userId = ada!.id; + const now = new Date(); + await db.insert(schema.patterns).values({ + id: "p1", userId, title: "ada's pattern", code: "// js", visibility: "public", createdAt: now, updatedAt: now, + }); + await db.insert(schema.decks).values({ id: "d1", userId, title: "ada's deck", createdAt: now, updatedAt: now }); + await db.insert(schema.comments).values({ id: "c1", patternId: "p1", userId, body: "first", createdAt: now }); + await db.insert(schema.patternHeaders).values({ + id: "h1", patternId: "p1", userId, codeCpp: "#pragma once\n", createdAt: now, + }); + await db.insert(schema.territories).values({ id: "t1", code: "A1", title: "Workshop", createdAt: now }); + await db.insert(schema.posts).values({ + id: "s1", territoryId: "t1", userId, title: "ada's post", body: "hello", createdAt: now, updatedAt: now, + }); + await db.insert(schema.reports).values({ + id: "r1", targetType: "pattern", targetId: "p1", reporterId: userId, reason: "other", createdAt: now, + }); +}, 30_000); + +afterAll(() => { + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + if (tmp) fs.rmSync(tmp, { recursive: true, force: true }); +}); + +it.each(ROUTES)("%s /api/community/%s answers a null body with a 400 the Lab can read", async ( + method, + route, + load, + params, +) => { + const handler = (await load())[method] as Handler; + const response = await handler( + new Request(`http://localhost:3000/api/community/${route}`, { + method, + headers: { "Content-Type": "application/json", Origin: LAB_ORIGIN, cookie }, + body: "null", + }), + { params: Promise.resolve(params) }, + ); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Expected a JSON object." }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe(LAB_ORIGIN); +}); diff --git a/web/src/app/api/community/patterns/[id]/comments/route.ts b/web/src/app/api/community/patterns/[id]/comments/route.ts index cf794febe..16bf38756 100644 --- a/web/src/app/api/community/patterns/[id]/comments/route.ts +++ b/web/src/app/api/community/patterns/[id]/comments/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPatternStub, newId } from "@/lib/community/server/queries"; @@ -46,14 +47,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Pattern not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const body = await readJsonObject(request); + if (body instanceof Response) return body; - const text = cleanComment((body as Record).body); + const text = cleanComment(body.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/patterns/[id]/header/route.ts b/web/src/app/api/community/patterns/[id]/header/route.ts index 026df279d..ffc6b3a47 100644 --- a/web/src/app/api/community/patterns/[id]/header/route.ts +++ b/web/src/app/api/community/patterns/[id]/header/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { notifyPortAdded } from "@/lib/community/server/notify"; @@ -106,13 +107,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (!codeCpp) { diff --git a/web/src/app/api/community/patterns/[id]/performance/route.ts b/web/src/app/api/community/patterns/[id]/performance/route.ts index d903fa64d..c14c712bf 100644 --- a/web/src/app/api/community/patterns/[id]/performance/route.ts +++ b/web/src/app/api/community/patterns/[id]/performance/route.ts @@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyPerformanceAdded } from "@/lib/community/server/notify"; import { @@ -129,13 +130,8 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "No such pattern." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (typeof raw.performanceJson !== "string" || raw.performanceJson.length === 0) { return Response.json({ error: "performanceJson (a Director show, as its JSON string) is required." }, { status: 400 }); diff --git a/web/src/app/api/community/patterns/[id]/route.ts b/web/src/app/api/community/patterns/[id]/route.ts index 482dff73c..e06f95816 100644 --- a/web/src/app/api/community/patterns/[id]/route.ts +++ b/web/src/app/api/community/patterns/[id]/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { @@ -128,13 +129,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "You can only edit your own patterns." }, { status: 403 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // A moderator's reach into someone else's pattern is its visibility or its // .h, never both at once. Every other field below simply never runs for diff --git a/web/src/app/api/community/patterns/route.ts b/web/src/app/api/community/patterns/route.ts index 4ab900e46..3f954aee1 100644 --- a/web/src/app/api/community/patterns/route.ts +++ b/web/src/app/api/community/patterns/route.ts @@ -1,6 +1,7 @@ import { and, eq, gte } from "drizzle-orm"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { MAX_FEED_PAGE_SIZE } from "@/lib/community/feedView"; import { notifyForkPublished } from "@/lib/community/server/notify"; @@ -124,13 +125,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many uploads — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) return Response.json({ error: "Title is required (max 80 chars)." }, { status: 400 }); diff --git a/web/src/app/api/community/ports/[id]/route.ts b/web/src/app/api/community/ports/[id]/route.ts index 63d15bc4c..a20b1727b 100644 --- a/web/src/app/api/community/ports/[id]/route.ts +++ b/web/src/app/api/community/ports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { bakePatternHeader } from "@/lib/community/server/moduleCache"; import { clearNotificationsFor, notifyHeaderModerated } from "@/lib/community/server/notify"; @@ -96,13 +97,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st const port = await getPortStub(id); if (!port) return Response.json({ error: "Port not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const codeCpp = cleanCpp(raw.codeCpp); if (codeCpp === null) { diff --git a/web/src/app/api/community/posts/[id]/comments/route.ts b/web/src/app/api/community/posts/[id]/comments/route.ts index 3c2e45ac7..e38d30af3 100644 --- a/web/src/app/api/community/posts/[id]/comments/route.ts +++ b/web/src/app/api/community/posts/[id]/comments/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { notifyCommentAdded } from "@/lib/community/server/notify"; import { getPostStub, newId } from "@/lib/community/server/queries"; @@ -40,14 +41,10 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str return Response.json({ error: "Post not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; - const text = cleanComment((payload as Record).body); + const text = cleanComment(payload.body); if (!text) { return Response.json({ error: "Comment is empty or over 2000 chars." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/pin/route.ts b/web/src/app/api/community/posts/[id]/pin/route.ts index b39f0ee18..10c216b56 100644 --- a/web/src/app/api/community/posts/[id]/pin/route.ts +++ b/web/src/app/api/community/posts/[id]/pin/route.ts @@ -2,6 +2,7 @@ import { eq, isNotNull } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { getPostStub } from "@/lib/community/server/queries"; import { posts } from "@/lib/community/server/schema"; @@ -36,13 +37,9 @@ async function handlePost(request: Request, context: { params: Promise<{ id: str const post = await getPostStub(id); if (!post) return Response.json({ error: "Post not found." }, { status: 404 }); - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const pinned = (body as Record).pinned; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const pinned = body.pinned; if (typeof pinned !== "boolean") { return Response.json({ error: "Expected { pinned: true | false }." }, { status: 400 }); } diff --git a/web/src/app/api/community/posts/[id]/route.ts b/web/src/app/api/community/posts/[id]/route.ts index 5a607e70c..36ee7b0eb 100644 --- a/web/src/app/api/community/posts/[id]/route.ts +++ b/web/src/app/api/community/posts/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { clearNotificationsFor } from "@/lib/community/server/notify"; import { getPostStub } from "@/lib/community/server/queries"; @@ -79,13 +80,8 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Too many edits — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Partial update: only the fields present in the body change. const patch: { title?: string; body?: string; updatedAt: Date } = { updatedAt: new Date() }; diff --git a/web/src/app/api/community/posts/route.ts b/web/src/app/api/community/posts/route.ts index 9ce0d9f9e..45f3d970b 100644 --- a/web/src/app/api/community/posts/route.ts +++ b/web/src/app/api/community/posts/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanPinNote, cleanTerritoryCode } from "@/lib/community/workshop"; import { notifyNewThread } from "@/lib/community/server/notify"; @@ -43,14 +44,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many posts — wait a minute and try again." }, { status: 429 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const title = cleanTitle(raw.title); if (!title) { return Response.json({ error: "Title is empty or too long." }, { status: 400 }); diff --git a/web/src/app/api/community/presence/route.ts b/web/src/app/api/community/presence/route.ts index f5c26ae0c..0385ef7df 100644 --- a/web/src/app/api/community/presence/route.ts +++ b/web/src/app/api/community/presence/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { STAGE_HEIGHT, @@ -69,12 +70,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many moves — stand still a moment." }, { status: 429 }); } - let payload: Record; - try { - payload = (await request.json()) as Record; - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } + const payload = await readJsonObject(request); + if (payload instanceof Response) return payload; const hasPosition = payload.x !== undefined || payload.y !== undefined; const hasStatus = payload.status !== undefined; diff --git a/web/src/app/api/community/reports/[id]/route.ts b/web/src/app/api/community/reports/[id]/route.ts index b747578a4..d1d9940af 100644 --- a/web/src/app/api/community/reports/[id]/route.ts +++ b/web/src/app/api/community/reports/[id]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { reports } from "@/lib/community/server/schema"; @@ -34,13 +35,9 @@ async function handlePatch(request: Request, context: { params: Promise<{ id: st return Response.json({ error: "Not found." }, { status: 404 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const status = (body as Record).status; + const body = await readJsonObject(request); + if (body instanceof Response) return body; + const status = body.status; if (typeof status !== "string" || !STATUSES.includes(status as never)) { return Response.json({ error: "Unknown status." }, { status: 400 }); } diff --git a/web/src/app/api/community/reports/route.ts b/web/src/app/api/community/reports/route.ts index dd2c24d0b..7f80c5666 100644 --- a/web/src/app/api/community/reports/route.ts +++ b/web/src/app/api/community/reports/route.ts @@ -1,5 +1,6 @@ import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { newId, reportTarget } from "@/lib/community/server/queries"; import { rateLimit } from "@/lib/community/ratelimit"; @@ -35,13 +36,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Too many reports — wait a minute and try again." }, { status: 429 }); } - let body: unknown; - try { - body = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = body as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; if (!isReportTargetType(raw.targetType)) { return Response.json({ error: "Unknown report target." }, { status: 400 }); diff --git a/web/src/app/api/community/territories/[code]/route.ts b/web/src/app/api/community/territories/[code]/route.ts index cec3a0083..7e554b835 100644 --- a/web/src/app/api/community/territories/[code]/route.ts +++ b/web/src/app/api/community/territories/[code]/route.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { QUESTION_MAX, @@ -78,13 +79,8 @@ async function handlePatch(request: Request, context: Context) { if (resolved.error) return resolved.error; const { territory } = resolved; - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; // Every field is optional — the editor sends what changed, and archiving // arrives as its own one-key body. diff --git a/web/src/app/api/community/territories/route.ts b/web/src/app/api/community/territories/route.ts index 231bd7b46..eb5e3b08a 100644 --- a/web/src/app/api/community/territories/route.ts +++ b/web/src/app/api/community/territories/route.ts @@ -1,6 +1,7 @@ import { isAdminSession } from "@/lib/community/server/admin"; import { getAuth } from "@/lib/community/server/auth"; import { originBlocked, preflight, withCors } from "@/lib/community/cors"; +import { readJsonObject } from "@/lib/community/jsonBody"; import { communityEnabled, getDb } from "@/lib/community/server/db"; import { cleanQuestions, @@ -39,13 +40,8 @@ async function handlePost(request: Request) { return Response.json({ error: "Not found." }, { status: 404 }); } - let payload: unknown; - try { - payload = await request.json(); - } catch { - return Response.json({ error: "Invalid JSON body." }, { status: 400 }); - } - const raw = payload as Record; + const raw = await readJsonObject(request); + if (raw instanceof Response) return raw; const code = cleanTerritoryCode(raw.code); if (!code) { diff --git a/web/src/lib/community/jsonBody.test.ts b/web/src/lib/community/jsonBody.test.ts new file mode 100644 index 000000000..e01872344 --- /dev/null +++ b/web/src/lib/community/jsonBody.test.ts @@ -0,0 +1,47 @@ +// readJsonObject is the one place the community's write routes turn a body +// into fields. `null`, an array, a number and a string all parse as JSON, and +// none of them has fields to read. The routes themselves are driven with +// `null` in app/api/community/nullBody.test.ts. + +import { describe, expect, it } from "vitest"; +import { readJsonObject } from "./jsonBody"; + +const post = (body?: string) => + new Request("http://localhost:3000/api/community/patterns", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + +async function refusal(body?: string) { + const result = await readJsonObject(post(body)); + if (!(result instanceof Response)) throw new Error(`${body} was read as an object`); + return { status: result.status, payload: await result.json() }; +} + +describe("readJsonObject", () => { + it("hands back an object's fields", async () => { + expect(await readJsonObject(post('{"title":"Tide","knobs":[1,2]}'))).toEqual({ + title: "Tide", + knobs: [1, 2], + }); + }); + + it("hands back an empty object, which the partial edits treat as nothing to change", async () => { + expect(await readJsonObject(post("{}"))).toEqual({}); + }); + + it.each(["null", "[]", '[{"title":"Tide"}]', "1", '"Tide"', "true"])( + "answers %s with a 400 that says an object was expected", + async (body) => { + expect(await refusal(body)).toEqual({ status: 400, payload: { error: "Expected a JSON object." } }); + }, + ); + + it.each([["no body", undefined], ["an empty body", ""], ["a cut-off object", '{"title":']])( + "answers %s with a 400 that says the JSON is invalid", + async (_label, body) => { + expect(await refusal(body)).toEqual({ status: 400, payload: { error: "Invalid JSON body." } }); + }, + ); +}); diff --git a/web/src/lib/community/jsonBody.ts b/web/src/lib/community/jsonBody.ts new file mode 100644 index 000000000..e09393a96 --- /dev/null +++ b/web/src/lib/community/jsonBody.ts @@ -0,0 +1,21 @@ +// The community's write routes all take a JSON object. `request.json()` only +// says the body parsed, and `null` parses: until 22 October 2026 a body of +// `null` got past every route's `catch` and threw a TypeError on the first +// field read, so thirteen routes answered 500 with no JSON and no CORS +// headers, and a Pattern Lab on another origin could not read why. + +/** The request's body as an object, or the 400 that says why it is not one. */ +export async function readJsonObject( + request: Request, +): Promise | Response> { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "Invalid JSON body." }, { status: 400 }); + } + if (body === null || typeof body !== "object" || Array.isArray(body)) { + return Response.json({ error: "Expected a JSON object." }, { status: 400 }); + } + return body as Record; +} From b1efdebeee63800ee10c262e93366485e01f9229 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 01:06:03 +0000 Subject: [PATCH 3/3] web: say 22 handlers in the body reader's note Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Yb77jeCcRxZfC8EHziz4Gg --- web/src/lib/community/jsonBody.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/community/jsonBody.ts b/web/src/lib/community/jsonBody.ts index e09393a96..06e0f7426 100644 --- a/web/src/lib/community/jsonBody.ts +++ b/web/src/lib/community/jsonBody.ts @@ -1,8 +1,8 @@ // The community's write routes all take a JSON object. `request.json()` only // says the body parsed, and `null` parses: until 22 October 2026 a body of -// `null` got past every route's `catch` and threw a TypeError on the first -// field read, so thirteen routes answered 500 with no JSON and no CORS -// headers, and a Pattern Lab on another origin could not read why. +// `null` got past the `catch` in 22 handlers and threw a TypeError on the +// first field read. Next.js answered with a 500 that had no JSON and no CORS +// headers, so a Pattern Lab on another origin could not read why. /** The request's body as an object, or the 400 that says why it is not one. */ export async function readJsonObject(