[object Object]

← back to Norma

fix(cycle5 Cody-gate): truly-empty-body probe + readJson across 93 routes

d26f6c66ae8f071add4bb41648b04cfac9075f30 · 2026-08-06 12:18:33 -0700 · Steve

Cody VERIFIED my write-smoke gave false confidence: the admin probe sent '{}' (valid
JSON), so it MISSED routes that crash on an absent body — the real prod scenario. He
reproduced no-body 500s on clients/drafts/grants/donations-insights POST.

- Suite: admin+unauth probe now sends NO body (undefined), replicating a client omitting
  the body — the case that makes unguarded request.json() throw -> 500.
- readJson() codemod across ALL 93 non-excluded routes still on raw request.json()
  (returns {} on empty; behavior-preserving for valid JSON). lib/body.ts default T=any so
  the swap is type-neutral. Cody's 4 now return 400, not 500.
- sessions.org_id FK inconsistency (references nonprofit_accounts, not organizations) is a
  data-model decision -> drafted to pending-approval (norma-sessions-org-fk-datamodel.md),
  not auto-migrated. The 23503->409 guard stays (stops the crash; feature still gated).
- pulse public topics: redirect:'manual' stops the crash but the section shows nothing
  because /api/petitions/topics is auth-gated — flagged as a follow-up (public topics
  source needed), NOT claimed as fully fixed.

Full battery green: api-smoke 195 / write-smoke 216 (no-body probe) / regression 20 / e2e 8.

Files touched

Diff

commit d26f6c66ae8f071add4bb41648b04cfac9075f30
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 12:18:33 2026 -0700

    fix(cycle5 Cody-gate): truly-empty-body probe + readJson across 93 routes
    
    Cody VERIFIED my write-smoke gave false confidence: the admin probe sent '{}' (valid
    JSON), so it MISSED routes that crash on an absent body — the real prod scenario. He
    reproduced no-body 500s on clients/drafts/grants/donations-insights POST.
    
    - Suite: admin+unauth probe now sends NO body (undefined), replicating a client omitting
      the body — the case that makes unguarded request.json() throw -> 500.
    - readJson() codemod across ALL 93 non-excluded routes still on raw request.json()
      (returns {} on empty; behavior-preserving for valid JSON). lib/body.ts default T=any so
      the swap is type-neutral. Cody's 4 now return 400, not 500.
    - sessions.org_id FK inconsistency (references nonprofit_accounts, not organizations) is a
      data-model decision -> drafted to pending-approval (norma-sessions-org-fk-datamodel.md),
      not auto-migrated. The 23503->409 guard stays (stops the crash; feature still gated).
    - pulse public topics: redirect:'manual' stops the crash but the section shows nothing
      because /api/petitions/topics is auth-gated — flagged as a follow-up (public topics
      source needed), NOT claimed as fully fixed.
    
    Full battery green: api-smoke 195 / write-smoke 216 (no-body probe) / regression 20 / e2e 8.
---
 app/api/accounts/[id]/keywords/route.ts              | 3 ++-
 app/api/admin/impersonate/route.ts                   | 3 ++-
 app/api/admin/permissions/route.ts                   | 3 ++-
 app/api/agents/org/register/route.ts                 | 3 ++-
 app/api/archives/route.ts                            | 3 ++-
 app/api/articles/link/route.ts                       | 3 ++-
 app/api/auth/login/route.ts                          | 3 ++-
 app/api/clickup/route.ts                             | 3 ++-
 app/api/clients/[id]/route.ts                        | 3 ++-
 app/api/clients/route.ts                             | 3 ++-
 app/api/collaborations/route.ts                      | 3 ++-
 app/api/contacts/[id]/route.ts                       | 3 ++-
 app/api/contacts/counts/route.ts                     | 3 ++-
 app/api/contacts/route.ts                            | 3 ++-
 app/api/datasource/[id]/route.ts                     | 3 ++-
 app/api/donations/[id]/route.ts                      | 3 ++-
 app/api/donations/insights/route.ts                  | 3 ++-
 app/api/donations/route.ts                           | 3 ++-
 app/api/drafts/[id]/route.ts                         | 3 ++-
 app/api/drafts/[id]/send-test/route.ts               | 3 ++-
 app/api/drafts/[id]/versions/route.ts                | 3 ++-
 app/api/email-analyzer/drafts/route.ts               | 3 ++-
 app/api/email-analyzer/fetch-url/route.ts            | 3 ++-
 app/api/email-analyzer/learnings/route.ts            | 5 +++--
 app/api/email-analyzer/references/route.ts           | 5 +++--
 app/api/email-analyzer/skills/route.ts               | 3 ++-
 app/api/email-analyzer/subject-lines/route.ts        | 5 +++--
 app/api/email-analyzer/universal-references/route.ts | 5 +++--
 app/api/email-sends/route.ts                         | 3 ++-
 app/api/geo/moveon/route.ts                          | 3 ++-
 app/api/grants/[id]/proposals/[pid]/route.ts         | 3 ++-
 app/api/grants/[id]/proposals/route.ts               | 3 ++-
 app/api/grants/[id]/route.ts                         | 3 ++-
 app/api/grants/route.ts                              | 3 ++-
 app/api/integrations/route.ts                        | 5 +++--
 app/api/journalists/[id]/route.ts                    | 3 ++-
 app/api/journalists/route.ts                         | 3 ++-
 app/api/journalists/scrape/route.ts                  | 3 ++-
 app/api/leads/route.ts                               | 5 +++--
 app/api/library/[id]/route.ts                        | 3 ++-
 app/api/library/route.ts                             | 3 ++-
 app/api/member-email/route.ts                        | 3 ++-
 app/api/news/[id]/route.ts                           | 3 ++-
 app/api/news/detect-mentions/route.ts                | 3 ++-
 app/api/news/route.ts                                | 3 ++-
 app/api/nonprofit/signup/route.ts                    | 3 ++-
 app/api/onboard/[id]/route.ts                        | 3 ++-
 app/api/onboard/trigger/route.ts                     | 3 ++-
 app/api/orgs/signup/route.ts                         | 3 ++-
 app/api/outreach-templates/route.ts                  | 3 ++-
 app/api/partners/posts/route.ts                      | 3 ++-
 app/api/partners/route.ts                            | 5 +++--
 app/api/petitions/[id]/campaigns/route.ts            | 3 ++-
 app/api/petitions/[id]/geo/route.ts                  | 3 ++-
 app/api/petitions/[id]/route.ts                      | 3 ++-
 app/api/petitions/[id]/signatures/route.ts           | 3 ++-
 app/api/petitions/draft/route.ts                     | 3 ++-
 app/api/petitions/route.ts                           | 3 ++-
 app/api/pipeline/[id]/approve/route.ts               | 3 ++-
 app/api/pipeline/[id]/route.ts                       | 3 ++-
 app/api/pipeline/[id]/schedule/route.ts              | 3 ++-
 app/api/pipeline/reorder/route.ts                    | 3 ++-
 app/api/pipeline/route.ts                            | 3 ++-
 app/api/price-tracker/route.ts                       | 3 ++-
 app/api/pulse/petitions/[id]/sign/route.ts           | 3 ++-
 app/api/pulse/petitions/route.ts                     | 3 ++-
 app/api/pulse/profile/route.ts                       | 3 ++-
 app/api/schedule/route.ts                            | 3 ++-
 app/api/scraper/suggestions/route.ts                 | 3 ++-
 app/api/sessions/[id]/route.ts                       | 3 ++-
 app/api/settings/api-keys/route.ts                   | 3 ++-
 app/api/settings/credentials/route.ts                | 3 ++-
 app/api/settings/intelligence/route.ts               | 3 ++-
 app/api/settings/people/route.ts                     | 3 ++-
 app/api/settings/pulse-config/route.ts               | 3 ++-
 app/api/settings/route.ts                            | 3 ++-
 app/api/settings/tier-credentials/[id]/route.ts      | 3 ++-
 app/api/settings/tier-credentials/route.ts           | 3 ++-
 app/api/social/accounts/route.ts                     | 3 ++-
 app/api/social/ai-compose/route.ts                   | 3 ++-
 app/api/social/ai-image/route.ts                     | 3 ++-
 app/api/social/bulk-schedule/route.ts                | 3 ++-
 app/api/social/competitors/crawl/route.ts            | 3 ++-
 app/api/social/connect/route.ts                      | 5 +++--
 app/api/social/posts/approve/route.ts                | 3 ++-
 app/api/social/publish/route.ts                      | 3 ++-
 app/api/social/schedule/route.ts                     | 3 ++-
 app/api/statements/[id]/route.ts                     | 3 ++-
 app/api/statements/route.ts                          | 3 ++-
 app/api/user-preferences/route.ts                    | 3 ++-
 app/api/webhooks/pulse-topic/route.ts                | 3 ++-
 app/api/xsessions/posts/route.ts                     | 3 ++-
 app/api/xsessions/route.ts                           | 3 ++-
 lib/body.ts                                          | 6 +++++-
 tests/api-write-smoke.mjs                            | 4 +++-
 95 files changed, 202 insertions(+), 103 deletions(-)

diff --git a/app/api/accounts/[id]/keywords/route.ts b/app/api/accounts/[id]/keywords/route.ts
index f27d589..9f9569a 100644
--- a/app/api/accounts/[id]/keywords/route.ts
+++ b/app/api/accounts/[id]/keywords/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -15,7 +16,7 @@ export async function PATCH(
 
   try {
     const { id } = await params;
-    const data = await request.json();
+    const data = await readJson(request);
     const keywords: string[] = Array.isArray(data.keywords) ? data.keywords : [];
 
     // Sanitize keywords
diff --git a/app/api/admin/impersonate/route.ts b/app/api/admin/impersonate/route.ts
index e90aaaa..9d6360d 100644
--- a/app/api/admin/impersonate/route.ts
+++ b/app/api/admin/impersonate/route.ts
@@ -17,6 +17,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import {
   createSession, buildAuthCookie, AUTH_COOKIE_NAME, verifyAuth, verifySessionToken,
 } from '@/lib/auth';
@@ -65,7 +66,7 @@ export async function POST(request: NextRequest) {
   if (adminSession instanceof NextResponse) return adminSession;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const targetUsername = String(body.username || '').trim();
     if (!targetUsername) {
       return NextResponse.json({ error: 'username is required' }, { status: 400 });
diff --git a/app/api/admin/permissions/route.ts b/app/api/admin/permissions/route.ts
index 2340e25..77c7053 100644
--- a/app/api/admin/permissions/route.ts
+++ b/app/api/admin/permissions/route.ts
@@ -7,6 +7,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { ALL_ROLES, type UserRole } from '@/lib/auth';
@@ -40,7 +41,7 @@ export async function PUT(request: NextRequest) {
   if (session instanceof NextResponse) return session;
 
   try {
-    const body = (await request.json()) as UpdateBody;
+    const body = (await readJson(request)) as UpdateBody;
     const updates = Array.isArray(body?.updates) ? body.updates : [];
 
     if (updates.length === 0) {
diff --git a/app/api/agents/org/register/route.ts b/app/api/agents/org/register/route.ts
index 2783cbf..bf1e878 100644
--- a/app/api/agents/org/register/route.ts
+++ b/app/api/agents/org/register/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       name,
       ein,
diff --git a/app/api/archives/route.ts b/app/api/archives/route.ts
index 4d35920..5396561 100644
--- a/app/api/archives/route.ts
+++ b/app/api/archives/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -93,7 +94,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.archive_type || !body.title) {
       return NextResponse.json({ error: 'archive_type and title are required' }, { status: 400 });
diff --git a/app/api/articles/link/route.ts b/app/api/articles/link/route.ts
index 98e7d77..97ce19a 100644
--- a/app/api/articles/link/route.ts
+++ b/app/api/articles/link/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -97,7 +98,7 @@ export async function POST(req: NextRequest) {
   const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;
 
   try {
-    const body = await req.json();
+    const body = await readJson(req);
     const { journalist_id, article, entity_ids = [] } = body as {
       journalist_id: string;
       article: {
diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 82e870f..7307cc8 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { createSession, buildAuthCookie, verifyPassword, hashPassword } from '@/lib/auth';
 import {
   checkLoginAttempt,
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
   try {
     let body: unknown;
     try {
-      body = await request.json();
+      body = await readJson(request);
     } catch {
       return NextResponse.json(
         { error: 'Invalid request body — expected JSON' },
diff --git a/app/api/clickup/route.ts b/app/api/clickup/route.ts
index a7f081c..9d613d9 100644
--- a/app/api/clickup/route.ts
+++ b/app/api/clickup/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -86,7 +87,7 @@ export async function POST(request: NextRequest) {
     return NextResponse.json({ error: 'ClickUp integration not configured' }, { status: 503 });
   }
 
-  const body = await request.json();
+  const body = await readJson(request);
   const { list_id, name, description, priority, due_date, tags, assignees, source_type, source_id } = body;
 
   if (!list_id || !name) {
diff --git a/app/api/clients/[id]/route.ts b/app/api/clients/[id]/route.ts
index 8ee752a..c26b638 100644
--- a/app/api/clients/[id]/route.ts
+++ b/app/api/clients/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -51,7 +52,7 @@ export async function PATCH(
 
   try {
     const { id } = await params;
-    const body = await request.json();
+    const body = await readJson(request);
 
     const allowedFields = [
       'first_name', 'last_name', 'email', 'phone', 'city', 'state', 'zip',
diff --git a/app/api/clients/route.ts b/app/api/clients/route.ts
index 99839e9..c032b41 100644
--- a/app/api/clients/route.ts
+++ b/app/api/clients/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -117,7 +118,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       first_name, last_name, email, phone, city, state, zip,
       client_type, status, priority,
diff --git a/app/api/collaborations/route.ts b/app/api/collaborations/route.ts
index 712071d..6df39ce 100644
--- a/app/api/collaborations/route.ts
+++ b/app/api/collaborations/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -71,7 +72,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       collab_type, name, title, organization, website_url,
       email, phone, district, state, city, party,
diff --git a/app/api/contacts/[id]/route.ts b/app/api/contacts/[id]/route.ts
index 89906a1..a3b90ae 100644
--- a/app/api/contacts/[id]/route.ts
+++ b/app/api/contacts/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -114,7 +115,7 @@ export async function PUT(
 
   try {
     const { id } = await params;
-    const data = await request.json();
+    const data = await readJson(request);
 
     const allowedFields = [
       'first_name', 'last_name', 'email', 'company', 'position',
diff --git a/app/api/contacts/counts/route.ts b/app/api/contacts/counts/route.ts
index b9af879..16a664a 100644
--- a/app/api/contacts/counts/route.ts
+++ b/app/api/contacts/counts/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -24,7 +25,7 @@ export async function POST(request: NextRequest) {
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { contact_id, skills = [], schools = [], groups = [], employers = [] } = body;
 
     const results: Record<string, number> = {};
diff --git a/app/api/contacts/route.ts b/app/api/contacts/route.ts
index a86e55a..09956f5 100644
--- a/app/api/contacts/route.ts
+++ b/app/api/contacts/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -105,7 +106,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const data = await request.json();
+    const data = await readJson(request);
     if (!data.first_name || !data.last_name) {
       return NextResponse.json({ error: 'first_name and last_name are required' }, { status: 400 });
     }
diff --git a/app/api/datasource/[id]/route.ts b/app/api/datasource/[id]/route.ts
index ddc65e1..6102372 100644
--- a/app/api/datasource/[id]/route.ts
+++ b/app/api/datasource/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -66,7 +67,7 @@ export async function PUT(
   }
 
   try {
-    const body = (await request.json()) as { status?: string; notes?: string };
+    const body = (await readJson(request)) as { status?: string; notes?: string };
     const { status, notes } = body;
 
     if (!status) {
diff --git a/app/api/donations/[id]/route.ts b/app/api/donations/[id]/route.ts
index 9511dd5..0191e1d 100644
--- a/app/api/donations/[id]/route.ts
+++ b/app/api/donations/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -44,7 +45,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = [
       'source', 'amount', 'donor_type', 'campaign_name', 'petition_id',
       'draft_id', 'channel', 'recurring', 'notes', 'tags', 'donated_at',
diff --git a/app/api/donations/insights/route.ts b/app/api/donations/insights/route.ts
index 27fd651..21ad3ad 100644
--- a/app/api/donations/insights/route.ts
+++ b/app/api/donations/insights/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -70,7 +71,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.insight_type || typeof body.insight_type !== 'string') {
       return NextResponse.json({ error: 'insight_type is required' }, { status: 400 });
diff --git a/app/api/donations/route.ts b/app/api/donations/route.ts
index 1342bcb..36a4c65 100644
--- a/app/api/donations/route.ts
+++ b/app/api/donations/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -134,7 +135,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (body.amount == null || isNaN(Number(body.amount)) || Number(body.amount) <= 0) {
       return NextResponse.json({ error: 'amount is required and must be positive' }, { status: 400 });
diff --git a/app/api/drafts/[id]/route.ts b/app/api/drafts/[id]/route.ts
index 66cac78..4a5b97d 100644
--- a/app/api/drafts/[id]/route.ts
+++ b/app/api/drafts/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query, getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -62,7 +63,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const client = await getClient();
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = [
       'subject', 'body_html', 'body_text', 'status', 'voice_mode',
       'email_type', 'from_name', 'from_email', 'reply_to',
diff --git a/app/api/drafts/[id]/send-test/route.ts b/app/api/drafts/[id]/send-test/route.ts
index 083162e..75d45d1 100644
--- a/app/api/drafts/[id]/send-test/route.ts
+++ b/app/api/drafts/[id]/send-test/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -40,7 +41,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
   }
 
   try {
-    const body = await request.json().catch(() => ({}));
+    const body = await readJson(request).catch(() => ({}));
 
     // Determine recipient
     const recipientEmail = body.to || process.env.TEST_SEND_TO;
diff --git a/app/api/drafts/[id]/versions/route.ts b/app/api/drafts/[id]/versions/route.ts
index 8861516..4b8b49e 100644
--- a/app/api/drafts/[id]/versions/route.ts
+++ b/app/api/drafts/[id]/versions/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -59,7 +60,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json().catch(() => ({}));
+    const body = await readJson(request).catch(() => ({}));
     const changeSummary = body.change_summary || 'Manual snapshot';
 
     // Fetch the current draft
diff --git a/app/api/email-analyzer/drafts/route.ts b/app/api/email-analyzer/drafts/route.ts
index 8f49989..daeb036 100644
--- a/app/api/email-analyzer/drafts/route.ts
+++ b/app/api/email-analyzer/drafts/route.ts
@@ -5,6 +5,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -85,7 +86,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       originalEmail,
       currentVersion,
diff --git a/app/api/email-analyzer/fetch-url/route.ts b/app/api/email-analyzer/fetch-url/route.ts
index f30ceb6..9e72415 100644
--- a/app/api/email-analyzer/fetch-url/route.ts
+++ b/app/api/email-analyzer/fetch-url/route.ts
@@ -4,6 +4,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { checkRateLimit } from '@/lib/rate-limit';
 import { assertPublicUrl } from '@/lib/ssrf-guard';
@@ -193,7 +194,7 @@ export async function POST(request: NextRequest) {
   }
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { url } = body;
 
     if (!url || typeof url !== 'string') {
diff --git a/app/api/email-analyzer/learnings/route.ts b/app/api/email-analyzer/learnings/route.ts
index fc080c0..67e1e22 100644
--- a/app/api/email-analyzer/learnings/route.ts
+++ b/app/api/email-analyzer/learnings/route.ts
@@ -13,6 +13,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       session_id,
       version_id,
@@ -97,7 +98,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const { id, learning, tags } = await request.json();
+    const { id, learning, tags } = await readJson(request);
     if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
     const sets: string[] = [];
     const params: unknown[] = [];
diff --git a/app/api/email-analyzer/references/route.ts b/app/api/email-analyzer/references/route.ts
index b339459..04440d2 100644
--- a/app/api/email-analyzer/references/route.ts
+++ b/app/api/email-analyzer/references/route.ts
@@ -5,6 +5,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -39,7 +40,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       session_id,
       ref_type,
@@ -137,7 +138,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id, include_in_rewrite, sort_order } = body;
 
     if (!id) {
diff --git a/app/api/email-analyzer/skills/route.ts b/app/api/email-analyzer/skills/route.ts
index 9349799..c0a734f 100644
--- a/app/api/email-analyzer/skills/route.ts
+++ b/app/api/email-analyzer/skills/route.ts
@@ -8,6 +8,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -49,7 +50,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       name,
       description = '',
diff --git a/app/api/email-analyzer/subject-lines/route.ts b/app/api/email-analyzer/subject-lines/route.ts
index 8728153..68a7871 100644
--- a/app/api/email-analyzer/subject-lines/route.ts
+++ b/app/api/email-analyzer/subject-lines/route.ts
@@ -9,6 +9,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -41,7 +42,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { session_id, version_id, subject, style, reasoning, notes } = body;
     if (!session_id || !subject) {
       return NextResponse.json({ error: 'session_id and subject are required' }, { status: 400 });
@@ -65,7 +66,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const { id, is_favorite, notes } = await request.json();
+    const { id, is_favorite, notes } = await readJson(request);
     if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
 
     const sets: string[] = [];
diff --git a/app/api/email-analyzer/universal-references/route.ts b/app/api/email-analyzer/universal-references/route.ts
index 58ae7ff..ad20afd 100644
--- a/app/api/email-analyzer/universal-references/route.ts
+++ b/app/api/email-analyzer/universal-references/route.ts
@@ -12,6 +12,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -62,7 +63,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       title,
       content,
@@ -114,7 +115,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id, is_active, title, content, tags, tone } = body as {
       id?: string;
       is_active?: boolean;
diff --git a/app/api/email-sends/route.ts b/app/api/email-sends/route.ts
index f1afeee..48f5657 100644
--- a/app/api/email-sends/route.ts
+++ b/app/api/email-sends/route.ts
@@ -4,6 +4,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
 import { query } from '@/lib/db';
@@ -93,7 +94,7 @@ export async function POST(request: NextRequest) {
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       email_type = 'general',
       subject,
diff --git a/app/api/geo/moveon/route.ts b/app/api/geo/moveon/route.ts
index 20ee3e0..0a39a08 100644
--- a/app/api/geo/moveon/route.ts
+++ b/app/api/geo/moveon/route.ts
@@ -15,6 +15,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -28,7 +29,7 @@ export async function POST(request: NextRequest) {
 
   let body: { email_text?: string; subject?: string };
   try {
-    body = await request.json();
+    body = await readJson(request);
   } catch {
     return NextResponse.json(
       { error: 'Request body must be valid JSON with an email_text field' },
diff --git a/app/api/grants/[id]/proposals/[pid]/route.ts b/app/api/grants/[id]/proposals/[pid]/route.ts
index 8010d14..9901358 100644
--- a/app/api/grants/[id]/proposals/[pid]/route.ts
+++ b/app/api/grants/[id]/proposals/[pid]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -37,7 +38,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Params }
   const { pid } = await params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const sets: string[] = [];
     const values: unknown[] = [];
     let idx = 1;
diff --git a/app/api/grants/[id]/proposals/route.ts b/app/api/grants/[id]/proposals/route.ts
index 51faf95..4357b87 100644
--- a/app/api/grants/[id]/proposals/route.ts
+++ b/app/api/grants/[id]/proposals/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -67,7 +68,7 @@ export async function POST(
       }
     }
 
-    const body = await request.json();
+    const body = await readJson(request);
 
     const result = await query(
       `INSERT INTO grant_proposals (grant_id, subject, recipient_email, recipient_name, body_html, body_text, created_by)
diff --git a/app/api/grants/[id]/route.ts b/app/api/grants/[id]/route.ts
index 57d95dd..08d1f90 100644
--- a/app/api/grants/[id]/route.ts
+++ b/app/api/grants/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -47,7 +48,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = [
       'title', 'funder', 'funder_url', 'amount_min', 'amount_max',
       'description', 'eligibility', 'focus_areas', 'application_url',
diff --git a/app/api/grants/route.ts b/app/api/grants/route.ts
index 94f9102..c614b69 100644
--- a/app/api/grants/route.ts
+++ b/app/api/grants/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -124,7 +125,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') {
       return NextResponse.json({ error: 'title is required' }, { status: 400 });
diff --git a/app/api/integrations/route.ts b/app/api/integrations/route.ts
index 694d689..1170dd9 100644
--- a/app/api/integrations/route.ts
+++ b/app/api/integrations/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -36,7 +37,7 @@ export async function PATCH(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const { service_id, status, config, metadata } = body;
   if (!service_id) return NextResponse.json({ error: 'service_id required' }, { status: 400 });
 
@@ -83,7 +84,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const { service_id, action } = await request.json();
+  const { service_id, action } = await readJson(request);
   if (!service_id || action !== 'test') {
     return NextResponse.json({ error: 'service_id and action="test" required' }, { status: 400 });
   }
diff --git a/app/api/journalists/[id]/route.ts b/app/api/journalists/[id]/route.ts
index 12626bb..98db7e1 100644
--- a/app/api/journalists/[id]/route.ts
+++ b/app/api/journalists/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -86,7 +87,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
 
   try {
     const { id } = await params;
-    const body = await req.json();
+    const body = await readJson(req);
     const allowed = ['name', 'outlet', 'beat', 'bio', 'twitter_handle', 'email', 'tags', 'is_active', 'linkedin_url', 'website', 'instagram_handle', 'facebook_url', 'youtube_url', 'substack_url', 'medium_url', 'podcast_url', 'rss_feed'];
     const sets: string[] = [];
     const vals: any[] = [];
diff --git a/app/api/journalists/route.ts b/app/api/journalists/route.ts
index f0d71d0..80876d3 100644
--- a/app/api/journalists/route.ts
+++ b/app/api/journalists/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -149,7 +150,7 @@ export async function POST(req: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await req.json();
+    const body = await readJson(req);
     const {
       name, outlet, beat, bio, twitter_handle, email, tags, linkedin_url,
       website, instagram_handle, facebook_url, youtube_url, substack_url,
diff --git a/app/api/journalists/scrape/route.ts b/app/api/journalists/scrape/route.ts
index c4c4baa..9522875 100644
--- a/app/api/journalists/scrape/route.ts
+++ b/app/api/journalists/scrape/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { createJournalistLinks } from '@/lib/journalist-links';
@@ -168,7 +169,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const { name, outlet } = await request.json();
+    const { name, outlet } = await readJson(request);
     if (!name || typeof name !== 'string' || name.trim().length < 2) {
       return NextResponse.json({ error: 'Reporter name required (min 2 chars)' }, { status: 400 });
     }
diff --git a/app/api/leads/route.ts b/app/api/leads/route.ts
index bd96ffc..6ced545 100644
--- a/app/api/leads/route.ts
+++ b/app/api/leads/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -76,7 +77,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       lead_type, name, contact_name, contact_title, organization,
       email, phone, website_url, city, state, original_interest,
@@ -123,7 +124,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id, ...updates } = body;
     if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
 
diff --git a/app/api/library/[id]/route.ts b/app/api/library/[id]/route.ts
index 4220b79..329536c 100644
--- a/app/api/library/[id]/route.ts
+++ b/app/api/library/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -45,7 +46,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = [
       'title', 'description', 'content_html', 'content_text',
       'tags', 'email_type', 'voice_mode', 'is_pinned', 'item_type',
diff --git a/app/api/library/route.ts b/app/api/library/route.ts
index 8fac874..b48af43 100644
--- a/app/api/library/route.ts
+++ b/app/api/library/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -90,7 +91,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.item_type || !body.title) {
       return NextResponse.json({ error: 'item_type and title are required' }, { status: 400 });
diff --git a/app/api/member-email/route.ts b/app/api/member-email/route.ts
index 90c8d8d..dd31ff4 100644
--- a/app/api/member-email/route.ts
+++ b/app/api/member-email/route.ts
@@ -18,6 +18,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { checkRateLimit } from '@/lib/rate-limit';
 
@@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
   }
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { emailType, topic, keyPoints, tone, orgName, orgMission } = body;
 
     if (!emailType || !topic) {
diff --git a/app/api/news/[id]/route.ts b/app/api/news/[id]/route.ts
index 3268d75..d848066 100644
--- a/app/api/news/[id]/route.ts
+++ b/app/api/news/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -18,7 +19,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = ['headline', 'outlet', 'url', 'summary', 'tags', 'is_used', 'session_id', 'is_pinned'];
 
     const setClauses: string[] = [];
diff --git a/app/api/news/detect-mentions/route.ts b/app/api/news/detect-mentions/route.ts
index d9cb308..dc6ab6b 100644
--- a/app/api/news/detect-mentions/route.ts
+++ b/app/api/news/detect-mentions/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
 import { batchDetectMentions } from '@/lib/detect-mentions';
@@ -26,7 +27,7 @@ export async function POST(request: NextRequest) {
     const contentType = request.headers.get('content-type') ?? '';
     if (contentType.includes('application/json')) {
       try {
-        const body = await request.json();
+        const body = await readJson(request);
         if (Array.isArray(body.articleIds)) {
           articleIds = body.articleIds.filter((id: unknown) => typeof id === 'string');
         }
diff --git a/app/api/news/route.ts b/app/api/news/route.ts
index e1e2789..f41ce96 100644
--- a/app/api/news/route.ts
+++ b/app/api/news/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -119,7 +120,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.headline || typeof body.headline !== 'string' || body.headline.trim() === '') {
       return NextResponse.json({ error: 'headline is required' }, { status: 400 });
diff --git a/app/api/nonprofit/signup/route.ts b/app/api/nonprofit/signup/route.ts
index 15b1a32..3f38796 100644
--- a/app/api/nonprofit/signup/route.ts
+++ b/app/api/nonprofit/signup/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { hashPassword } from '@/lib/auth';
 import { auditLog } from '@/lib/audit';
@@ -112,7 +113,7 @@ export async function GET(request: NextRequest) {
 
 export async function POST(request: NextRequest) {
   try {
-    const body: NonprofitSignupBody = await request.json();
+    const body: NonprofitSignupBody = await readJson(request);
 
     // ------------------------------------------------------------------
     // Validate required fields
diff --git a/app/api/onboard/[id]/route.ts b/app/api/onboard/[id]/route.ts
index 8408599..da5f90a 100644
--- a/app/api/onboard/[id]/route.ts
+++ b/app/api/onboard/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -67,7 +68,7 @@ export async function PUT(
   }
 
   try {
-    const body = (await request.json()) as {
+    const body = (await readJson(request)) as {
       status?: string;
       notes?: string;
       contacted_at?: string;
diff --git a/app/api/onboard/trigger/route.ts b/app/api/onboard/trigger/route.ts
index 277ad15..4911c01 100644
--- a/app/api/onboard/trigger/route.ts
+++ b/app/api/onboard/trigger/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 
 // ─── POST /api/onboard/trigger ────────────────────────────────────────────────
@@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = (await request.json()) as { topic_id?: string; topic?: string };
+    const body = (await readJson(request)) as { topic_id?: string; topic?: string };
     const { topic_id, topic } = body;
 
     if (!topic_id && !topic) {
diff --git a/app/api/orgs/signup/route.ts b/app/api/orgs/signup/route.ts
index 5c0f559..40ae289 100644
--- a/app/api/orgs/signup/route.ts
+++ b/app/api/orgs/signup/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -13,7 +14,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const data = await request.json();
+    const data = await readJson(request);
 
     if (!data.name || typeof data.name !== 'string' || data.name.trim() === '') {
       return NextResponse.json({ error: 'Organization name is required' }, { status: 400 });
diff --git a/app/api/outreach-templates/route.ts b/app/api/outreach-templates/route.ts
index b30250e..4de6d3a 100644
--- a/app/api/outreach-templates/route.ts
+++ b/app/api/outreach-templates/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -59,7 +60,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { template_type, name, subject, body: templateBody, variables, compliance_safe, org_type_safe } = body;
 
     if (!template_type || !name || !templateBody) {
diff --git a/app/api/partners/posts/route.ts b/app/api/partners/posts/route.ts
index d1a2e1b..ba6d26c 100644
--- a/app/api/partners/posts/route.ts
+++ b/app/api/partners/posts/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -57,7 +58,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { partner_id, platform, content, url, post_date, likes_count, retweets_count, replies_count } = body;
 
     if (!partner_id || !content) {
diff --git a/app/api/partners/route.ts b/app/api/partners/route.ts
index 7978c5e..11b88cb 100644
--- a/app/api/partners/route.ts
+++ b/app/api/partners/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -71,7 +72,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       partner_type, name, contact_name, contact_title, email, phone,
       website_url, city, state, partnership_since, partnership_value,
@@ -115,7 +116,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id, ...updates } = body;
     if (!id) return NextResponse.json({ error: 'id required' }, { status: 400 });
 
diff --git a/app/api/petitions/[id]/campaigns/route.ts b/app/api/petitions/[id]/campaigns/route.ts
index 723c58c..ce4465c 100644
--- a/app/api/petitions/[id]/campaigns/route.ts
+++ b/app/api/petitions/[id]/campaigns/route.ts
@@ -8,6 +8,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { getTargetLegislators, generateCampaignEmail } from '@/lib/petition-engine';
 import { requireRole } from '@/lib/require-role';
@@ -107,7 +108,7 @@ export async function POST(
   const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;
 
   try {
-    const body = await req.json();
+    const body = await readJson(req);
     const { action } = body;
 
     // Get petition data (verify org ownership)
diff --git a/app/api/petitions/[id]/geo/route.ts b/app/api/petitions/[id]/geo/route.ts
index 699aa7c..6f57221 100644
--- a/app/api/petitions/[id]/geo/route.ts
+++ b/app/api/petitions/[id]/geo/route.ts
@@ -7,6 +7,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { updatePetitionGeoStats, getTargetLegislators } from '@/lib/petition-engine';
 import { requireRole } from '@/lib/require-role';
@@ -117,7 +118,7 @@ export async function POST(
       return NextResponse.json({ error: 'Petition not found' }, { status: 404 });
     }
 
-    const body = await req.json();
+    const body = await readJson(req);
     const { action } = body;
 
     if (action === 'refresh') {
diff --git a/app/api/petitions/[id]/route.ts b/app/api/petitions/[id]/route.ts
index 80a84aa..1578b4d 100644
--- a/app/api/petitions/[id]/route.ts
+++ b/app/api/petitions/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query, getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -48,7 +49,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = [
       'title', 'url', 'description', 'status', 'signature_count',
       'signature_goal', 'target', 'category', 'tags', 'ai_suggestion',
diff --git a/app/api/petitions/[id]/signatures/route.ts b/app/api/petitions/[id]/signatures/route.ts
index 0d4a463..49ea176 100644
--- a/app/api/petitions/[id]/signatures/route.ts
+++ b/app/api/petitions/[id]/signatures/route.ts
@@ -7,6 +7,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { zipToDistrict, updatePetitionGeoStats } from '@/lib/petition-engine';
 import { createHash } from 'crypto';
@@ -107,7 +108,7 @@ export async function POST(
   const orgId = auth.role === 'admin' ? getOrgId(req) : auth.orgId;
 
   try {
-    const body = await req.json();
+    const body = await readJson(req);
     const { name, email, zip, comment, source } = body;
 
     if (!email) {
diff --git a/app/api/petitions/draft/route.ts b/app/api/petitions/draft/route.ts
index bb7d097..624468b 100644
--- a/app/api/petitions/draft/route.ts
+++ b/app/api/petitions/draft/route.ts
@@ -7,6 +7,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 
 const GEMINI_KEY = process.env.GEMINI_API_KEY || '';
@@ -18,7 +19,7 @@ export async function POST(req: NextRequest) {
 
   let topicTitle = 'Important Issue';
   try {
-    const body = await req.json();
+    const body = await readJson(req);
     topicTitle = body.topicTitle || 'Important Issue';
     const { topicDescription, category, source } = body;
 
diff --git a/app/api/petitions/route.ts b/app/api/petitions/route.ts
index 9359a58..9877841 100644
--- a/app/api/petitions/route.ts
+++ b/app/api/petitions/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -116,7 +117,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') {
       return NextResponse.json({ error: 'title is required' }, { status: 400 });
diff --git a/app/api/pipeline/[id]/approve/route.ts b/app/api/pipeline/[id]/approve/route.ts
index 2d7a7b0..bb6de2e 100644
--- a/app/api/pipeline/[id]/approve/route.ts
+++ b/app/api/pipeline/[id]/approve/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -48,7 +49,7 @@ export async function POST(
     // Parse optional review notes
     let reviewNotes: string | null = null;
     try {
-      const body = await request.json();
+      const body = await readJson(request);
       if (body.review_notes && typeof body.review_notes === 'string') {
         reviewNotes = body.review_notes.trim() || null;
       }
diff --git a/app/api/pipeline/[id]/route.ts b/app/api/pipeline/[id]/route.ts
index b17f6b7..98a7063 100644
--- a/app/api/pipeline/[id]/route.ts
+++ b/app/api/pipeline/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -70,7 +71,7 @@ export async function PUT(
       return NextResponse.json({ error: 'Invalid ID format' }, { status: 400 });
     }
 
-    const data = await request.json();
+    const data = await readJson(request);
 
     // Build dynamic SET clause from allowed fields
     const allowedFields: Record<string, (v: unknown) => unknown> = {
diff --git a/app/api/pipeline/[id]/schedule/route.ts b/app/api/pipeline/[id]/schedule/route.ts
index 8c91e7c..026056c 100644
--- a/app/api/pipeline/[id]/schedule/route.ts
+++ b/app/api/pipeline/[id]/schedule/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -28,7 +29,7 @@ export async function POST(
       return NextResponse.json({ error: 'Invalid ID format' }, { status: 400 });
     }
 
-    const body = await request.json();
+    const body = await readJson(request);
 
     // Validate platforms
     const platforms: string[] = Array.isArray(body.platforms) ? body.platforms : [body.platform].filter(Boolean);
diff --git a/app/api/pipeline/reorder/route.ts b/app/api/pipeline/reorder/route.ts
index 69f5938..bf4a2da 100644
--- a/app/api/pipeline/reorder/route.ts
+++ b/app/api/pipeline/reorder/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -17,7 +18,7 @@ export async function PATCH(request: NextRequest) {
 
   let data: { items?: unknown } = {};
   try {
-    data = await request.json();
+    data = await readJson(request);
   } catch {
     return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
   }
diff --git a/app/api/pipeline/route.ts b/app/api/pipeline/route.ts
index f9f2a31..650ff2c 100644
--- a/app/api/pipeline/route.ts
+++ b/app/api/pipeline/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -101,7 +102,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const data = await request.json();
+    const data = await readJson(request);
 
     if (!data.title || typeof data.title !== 'string' || data.title.trim() === '') {
       return NextResponse.json({ error: 'title is required' }, { status: 400 });
diff --git a/app/api/price-tracker/route.ts b/app/api/price-tracker/route.ts
index b227d31..6d01175 100644
--- a/app/api/price-tracker/route.ts
+++ b/app/api/price-tracker/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -369,7 +370,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { action, alert_ids } = body;
 
     if (action === 'mark_read' && Array.isArray(alert_ids)) {
diff --git a/app/api/pulse/petitions/[id]/sign/route.ts b/app/api/pulse/petitions/[id]/sign/route.ts
index 0e387d1..7d361d5 100644
--- a/app/api/pulse/petitions/[id]/sign/route.ts
+++ b/app/api/pulse/petitions/[id]/sign/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { createHash } from 'crypto';
 import { query, getClient } from '@/lib/db';
 import { checkRateLimit } from '@/lib/rate-limit';
@@ -31,7 +32,7 @@ export async function POST(request: NextRequest, context: RouteContext) {
   }
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     // Validate required fields
     if (!body.name || typeof body.name !== 'string' || body.name.trim().length < 1) {
diff --git a/app/api/pulse/petitions/route.ts b/app/api/pulse/petitions/route.ts
index 72326d1..c402802 100644
--- a/app/api/pulse/petitions/route.ts
+++ b/app/api/pulse/petitions/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { checkRateLimit } from '@/lib/rate-limit';
 
@@ -120,7 +121,7 @@ export async function POST(request: NextRequest) {
         { status: 429, headers: { 'Retry-After': String(Math.ceil(rl.resetIn / 1000)) } },
       );
     }
-    const body = await request.json();
+    const body = await readJson(request);
 
     // Validate required fields
     if (!body.title || typeof body.title !== 'string' || body.title.trim().length < 5) {
diff --git a/app/api/pulse/profile/route.ts b/app/api/pulse/profile/route.ts
index 3378d79..52f1ab4 100644
--- a/app/api/pulse/profile/route.ts
+++ b/app/api/pulse/profile/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import type { AuthSession } from '@/lib/auth';
@@ -46,7 +47,7 @@ export async function PUT(request: NextRequest) {
   const session = result as AuthSession;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const displayName = body.display_name;
 
     if (!displayName || typeof displayName !== 'string') {
diff --git a/app/api/schedule/route.ts b/app/api/schedule/route.ts
index 5f6847a..3594517 100644
--- a/app/api/schedule/route.ts
+++ b/app/api/schedule/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -25,7 +26,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     // Validate content_type
     const contentType = body.content_type;
diff --git a/app/api/scraper/suggestions/route.ts b/app/api/scraper/suggestions/route.ts
index c9475d7..207a8eb 100644
--- a/app/api/scraper/suggestions/route.ts
+++ b/app/api/scraper/suggestions/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -220,7 +221,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
 
     if (!body.id || typeof body.id !== 'string') {
       return NextResponse.json({ error: 'id is required' }, { status: 400 });
diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts
index 4d55b48..d718f9b 100644
--- a/app/api/sessions/[id]/route.ts
+++ b/app/api/sessions/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query, getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -56,7 +57,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedFields = ['title', 'status', 'planning_checklist', 'planning_notes', 'news_item_ids', 'x_post_ids'];
     const setClauses: string[] = [];
     const values: unknown[] = [];
diff --git a/app/api/settings/api-keys/route.ts b/app/api/settings/api-keys/route.ts
index 8d56684..ff31d3d 100644
--- a/app/api/settings/api-keys/route.ts
+++ b/app/api/settings/api-keys/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { generateApiKey, listApiKeys } from '@/lib/credentials';
 import { auditLog } from '@/lib/audit';
@@ -23,7 +24,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json().catch(() => ({}));
+  const body = await readJson(request).catch(() => ({}));
   const { name, scopes, expires_in_days } = body;
 
   if (!name || typeof name !== 'string' || name.trim().length === 0) {
diff --git a/app/api/settings/credentials/route.ts b/app/api/settings/credentials/route.ts
index b51ccc1..b5586f0 100644
--- a/app/api/settings/credentials/route.ts
+++ b/app/api/settings/credentials/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -67,7 +68,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { serviceId, credentials: serviceCreds } = body;
 
     if (!serviceId || !serviceCreds) {
diff --git a/app/api/settings/intelligence/route.ts b/app/api/settings/intelligence/route.ts
index 357a2de..195efa8 100644
--- a/app/api/settings/intelligence/route.ts
+++ b/app/api/settings/intelligence/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -83,7 +84,7 @@ export async function PUT(request: NextRequest) {
   }
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { intelligence, api_keys } = body;
 
     // Fetch current api_connections
diff --git a/app/api/settings/people/route.ts b/app/api/settings/people/route.ts
index 31277b6..ed0a5cb 100644
--- a/app/api/settings/people/route.ts
+++ b/app/api/settings/people/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { query } from '@/lib/db';
 import { getOrgId } from '@/lib/orgId';
@@ -41,7 +42,7 @@ export async function PUT(request: NextRequest) {
 
   const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
   const key = settingKey(orgId);
-  const body = await request.json();
+  const body = await readJson(request);
 
   const value = {
     employees: Array.isArray(body.employees) ? body.employees : [],
diff --git a/app/api/settings/pulse-config/route.ts b/app/api/settings/pulse-config/route.ts
index e40897f..3e5fb6c 100644
--- a/app/api/settings/pulse-config/route.ts
+++ b/app/api/settings/pulse-config/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -42,7 +43,7 @@ export async function PUT(request: NextRequest) {
   if (result instanceof NextResponse) return result;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { config } = body;
 
     if (!config || typeof config !== 'object') {
diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts
index 574047e..20892a2 100644
--- a/app/api/settings/route.ts
+++ b/app/api/settings/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { getOrgId } from '@/lib/orgId';
@@ -55,7 +56,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const allowedKeys = ['org_profile', 'email_config', 'ai_config', 'integrations', 'alert_preferences'];
     let updated = 0;
 
diff --git a/app/api/settings/tier-credentials/[id]/route.ts b/app/api/settings/tier-credentials/[id]/route.ts
index 1901940..65ad4c3 100644
--- a/app/api/settings/tier-credentials/[id]/route.ts
+++ b/app/api/settings/tier-credentials/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { hashPassword, ALL_ROLES, type UserRole } from '@/lib/auth';
 import { requireRole } from '@/lib/require-role';
@@ -18,7 +19,7 @@ export async function PUT(request: NextRequest, context: RouteContext) {
   const { id } = await context.params;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { display_name, role, org_id, password } = body;
 
     // Verify credential exists
diff --git a/app/api/settings/tier-credentials/route.ts b/app/api/settings/tier-credentials/route.ts
index 6704ddb..3d87bcf 100644
--- a/app/api/settings/tier-credentials/route.ts
+++ b/app/api/settings/tier-credentials/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { hashPassword, ALL_ROLES, type UserRole } from '@/lib/auth';
 import { requireRole } from '@/lib/require-role';
@@ -37,7 +38,7 @@ export async function POST(request: NextRequest) {
   if (session instanceof NextResponse) return session;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const {
       username,
       password,
diff --git a/app/api/social/accounts/route.ts b/app/api/social/accounts/route.ts
index e083f81..b544318 100644
--- a/app/api/social/accounts/route.ts
+++ b/app/api/social/accounts/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -29,7 +30,7 @@ export async function PATCH(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const { id, status } = await request.json();
+  const { id, status } = await readJson(request);
   if (!id || !status) {
     return NextResponse.json({ error: 'id and status required' }, { status: 400 });
   }
diff --git a/app/api/social/ai-compose/route.ts b/app/api/social/ai-compose/route.ts
index 6a79703..4eeab3d 100644
--- a/app/api/social/ai-compose/route.ts
+++ b/app/api/social/ai-compose/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 
 const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
@@ -56,7 +57,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const {
     topic,
     platform,
diff --git a/app/api/social/ai-image/route.ts b/app/api/social/ai-image/route.ts
index fb3578c..d690299 100644
--- a/app/api/social/ai-image/route.ts
+++ b/app/api/social/ai-image/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { randomUUID } from 'crypto';
 import { promises as fs } from 'fs';
 import path from 'path';
@@ -23,7 +24,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
   let body: { prompt?: string; post_id?: string };
   try {
-    body = await request.json();
+    body = await readJson(request);
   } catch {
     return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
   }
diff --git a/app/api/social/bulk-schedule/route.ts b/app/api/social/bulk-schedule/route.ts
index 03bcdae..f84edc5 100644
--- a/app/api/social/bulk-schedule/route.ts
+++ b/app/api/social/bulk-schedule/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query, getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -93,7 +94,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
   let body: BulkScheduleBody;
   try {
-    body = await request.json();
+    body = await readJson(request);
   } catch {
     return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
   }
diff --git a/app/api/social/competitors/crawl/route.ts b/app/api/social/competitors/crawl/route.ts
index 5af4a82..716fe7f 100644
--- a/app/api/social/competitors/crawl/route.ts
+++ b/app/api/social/competitors/crawl/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { crawlSocialLinks, type FoundHandle } from '@/lib/social-crawl';
@@ -21,7 +22,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json();
+  const body = await readJson(request);
   const { url, category = 'competitor', expertise_area } = body as {
     url: string;
     category?: string;
diff --git a/app/api/social/connect/route.ts b/app/api/social/connect/route.ts
index 7500b80..0d653ca 100644
--- a/app/api/social/connect/route.ts
+++ b/app/api/social/connect/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { storeSocialCredentials, disconnectSocial, PLATFORM_FIELDS } from '@/lib/credentials';
 import { auditLog } from '@/lib/audit';
@@ -14,7 +15,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json().catch(() => ({}));
+  const body = await readJson(request).catch(() => ({}));
   const { platform, credentials } = body;
 
   if (!platform || !VALID_PLATFORMS.includes(platform)) {
@@ -57,7 +58,7 @@ export async function DELETE(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const body = await request.json().catch(() => ({}));
+  const body = await readJson(request).catch(() => ({}));
   const { platform } = body;
 
   if (!platform || !VALID_PLATFORMS.includes(platform)) {
diff --git a/app/api/social/posts/approve/route.ts b/app/api/social/posts/approve/route.ts
index 3f3b6c3..dcad443 100644
--- a/app/api/social/posts/approve/route.ts
+++ b/app/api/social/posts/approve/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query, getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -19,7 +20,7 @@ export async function POST(request: NextRequest) {
     scheduled_at?: string;
   };
   try {
-    body = await request.json();
+    body = await readJson(request);
   } catch {
     return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
   }
diff --git a/app/api/social/publish/route.ts b/app/api/social/publish/route.ts
index 67a2215..63e3287 100644
--- a/app/api/social/publish/route.ts
+++ b/app/api/social/publish/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { buildInternalAuthHeader } from '@/lib/cron-auth';
@@ -12,7 +13,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin');
   if (auth instanceof NextResponse) return auth;
 
-  const { post_id, cross_post_group } = await request.json();
+  const { post_id, cross_post_group } = await readJson(request);
 
   if (cross_post_group) {
     const result = await query(
diff --git a/app/api/social/schedule/route.ts b/app/api/social/schedule/route.ts
index 6f5a882..5a6a72d 100644
--- a/app/api/social/schedule/route.ts
+++ b/app/api/social/schedule/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -30,7 +31,7 @@ export async function POST(request: NextRequest) {
   const auth = requireRole(request, 'admin', 'staff');
   if (auth instanceof NextResponse) return auth;
 
-  const { post_id, scheduled_at, target_platforms = [], recurrence_rule } = await request.json();
+  const { post_id, scheduled_at, target_platforms = [], recurrence_rule } = await readJson(request);
   if (!post_id || !scheduled_at) {
     return NextResponse.json({ error: 'post_id and scheduled_at required' }, { status: 400 });
   }
diff --git a/app/api/statements/[id]/route.ts b/app/api/statements/[id]/route.ts
index 5c51dea..0a28da0 100644
--- a/app/api/statements/[id]/route.ts
+++ b/app/api/statements/[id]/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -36,7 +37,7 @@ export async function PUT(request: NextRequest, ctx: RouteContext) {
   const { id } = await ctx.params;
 
   try {
-    const data = await request.json();
+    const data = await readJson(request);
 
     const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
     const orgFilter = orgId ? ' AND org_id = $2' : '';
diff --git a/app/api/statements/route.ts b/app/api/statements/route.ts
index e397855..fe08eda 100644
--- a/app/api/statements/route.ts
+++ b/app/api/statements/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 import { auditLog } from '@/lib/audit';
@@ -77,7 +78,7 @@ export async function POST(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const data = await request.json();
+    const data = await readJson(request);
 
     if (!data.title || !data.body_html || !data.event_title) {
       return NextResponse.json(
diff --git a/app/api/user-preferences/route.ts b/app/api/user-preferences/route.ts
index d04474c..9d6ca47 100644
--- a/app/api/user-preferences/route.ts
+++ b/app/api/user-preferences/route.ts
@@ -5,6 +5,7 @@
  */
 
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { requireRole } from '@/lib/require-role';
 import { query } from '@/lib/db';
 
@@ -42,7 +43,7 @@ async function writePreference(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { key, value } = body as { key?: string; value?: unknown };
     if (!key || typeof key !== 'string') {
       return NextResponse.json({ error: 'key required' }, { status: 400 });
diff --git a/app/api/webhooks/pulse-topic/route.ts b/app/api/webhooks/pulse-topic/route.ts
index 6977c28..ad47408 100644
--- a/app/api/webhooks/pulse-topic/route.ts
+++ b/app/api/webhooks/pulse-topic/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { auditLog } from '@/lib/audit';
 import { getBrand } from '@/lib/brand';
@@ -36,7 +37,7 @@ export async function POST(request: NextRequest) {
   }
 
   try {
-    const data = await request.json();
+    const data = await readJson(request);
     const topic = data.topic;
 
     if (!topic || !topic.title) {
diff --git a/app/api/xsessions/posts/route.ts b/app/api/xsessions/posts/route.ts
index a175ddb..d4cfa58 100644
--- a/app/api/xsessions/posts/route.ts
+++ b/app/api/xsessions/posts/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -12,7 +13,7 @@ export async function PATCH(request: NextRequest) {
   if (auth instanceof NextResponse) return auth;
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const { id, is_used } = body;
 
     if (!id) {
diff --git a/app/api/xsessions/route.ts b/app/api/xsessions/route.ts
index 4bcf472..3184123 100644
--- a/app/api/xsessions/route.ts
+++ b/app/api/xsessions/route.ts
@@ -1,4 +1,5 @@
 import { NextRequest, NextResponse } from 'next/server';
+import { readJson } from '@/lib/body';
 import { query, getClient } from '@/lib/db';
 import { requireRole } from '@/lib/require-role';
 
@@ -74,7 +75,7 @@ export async function POST(request: NextRequest) {
   const client = await getClient();
 
   try {
-    const body = await request.json();
+    const body = await readJson(request);
     const queryParams = body.query_params || { terms: 'advocacy' };
     const searchTerms = queryParams.terms || 'advocacy';
 
diff --git a/lib/body.ts b/lib/body.ts
index 18ab14d..d002869 100644
--- a/lib/body.ts
+++ b/lib/body.ts
@@ -14,7 +14,11 @@ export class BodyParseError extends Error {
   }
 }
 
-export async function readJson<T = Record<string, unknown>>(request: Request): Promise<T> {
+// Defaults to `any` to match the ergonomics of `request.json()` — this lets it be a
+// drop-in replacement across existing handlers without introducing `unknown`-member
+// type errors. Pass an explicit type param where you want stronger typing.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export async function readJson<T = any>(request: Request): Promise<T> {
   const raw = await request.text();
   if (!raw || raw.trim() === '') return {} as T;
   try {
diff --git a/tests/api-write-smoke.mjs b/tests/api-write-smoke.mjs
index e689d43..71b295e 100644
--- a/tests/api-write-smoke.mjs
+++ b/tests/api-write-smoke.mjs
@@ -87,7 +87,9 @@ async function call(path, verb, cookie, extra = {}) {
   const headers = { 'Content-Type': 'application/json', ...extra };
   if (cookie) headers.Cookie = `norma-auth=${cookie}`;
   // empty body for verbs that carry one — this is what keeps the probe side-effect-free
-  const body = verb === 'DELETE' ? undefined : '{}';
+  // Send NO body (not {}) — replicates a client omitting the body, which is what makes
+  // an unguarded request.json() throw 'Unexpected end of JSON input' -> 500 in prod.
+  const body = undefined;
   try {
     const res = await fetch(`${BASE}${path}`, { method: verb, headers, body, redirect: 'manual' });
     return res.status;

← 959da8b test+fix(cycle5): write-path smoke (216 mutations) + Playwri  ·  back to Norma  ·  test(cycle6): CRUD lifecycle suite (create->read->update->de e958e07 →