diff --git a/__tests__/artifact-comments-route.test.ts b/__tests__/artifact-comments-route.test.ts index ce2e06a7..e00a5e56 100644 --- a/__tests__/artifact-comments-route.test.ts +++ b/__tests__/artifact-comments-route.test.ts @@ -16,8 +16,8 @@ describe("/api/artifacts/[id]/comments", () => { process.env.FORAGENTS_API_KEYS_JSON = JSON.stringify({ testkey: { agent_id: "agt_test", handle: "@test@local", display_name: "Test" }, }); - const rl = await import("@/lib/server/rateLimit"); - rl._resetRateLimitForTests(); + const rl = await import("@/lib/requestLimits"); + rl.__resetRateLimitsForTests(); // Ensure file-backed store is clean per test. const { promises: fs } = await import("fs"); @@ -80,6 +80,24 @@ describe("/api/artifacts/[id]/comments", () => { expect(typeof json.comment.created_at).toBe("string"); }); + test("POST returns 413 when request body is too large", async () => { + const { POST } = await loadRoute(); + + const huge = "a".repeat(30_000); + const req = new NextRequest("http://localhost/api/artifacts/art_1/comments", { + method: "POST", + body: huge, + headers: { + "Content-Type": "text/markdown; charset=utf-8", + Authorization: "Bearer testkey", + "x-forwarded-for": "203.0.113.10", + }, + }); + + const res = await POST(req, { params: Promise.resolve({ id: "art_1" }) }); + expect(res.status).toBe(413); + }); + test("GET returns list shape", async () => { const { GET } = await loadRoute(); const req = new NextRequest("http://localhost/api/artifacts/art_1/comments?limit=2&order=asc"); diff --git a/__tests__/artifact-ratings-route.test.ts b/__tests__/artifact-ratings-route.test.ts index c5169224..8e67774f 100644 --- a/__tests__/artifact-ratings-route.test.ts +++ b/__tests__/artifact-ratings-route.test.ts @@ -20,8 +20,8 @@ describe("/api/artifacts/[id]/ratings", () => { process.env.FORAGENTS_API_KEYS_JSON = JSON.stringify({ testkey: { agent_id: "agt_test", handle: "@test@local", display_name: "Test" }, }); - const rl = await import("@/lib/server/rateLimit"); - rl._resetRateLimitForTests(); + const rl = await import("@/lib/requestLimits"); + rl.__resetRateLimitsForTests(); // Ensure file-backed store is clean per test. const { promises: fs } = await import("fs"); diff --git a/__tests__/artifacts-feeds.test.ts b/__tests__/artifacts-feeds.test.ts index 2708eb00..f4f4325c 100644 --- a/__tests__/artifacts-feeds.test.ts +++ b/__tests__/artifacts-feeds.test.ts @@ -36,6 +36,7 @@ describe("/feeds/artifacts.json", () => { expect(body).toHaveProperty("version"); expect(body).toHaveProperty("title"); expect(body).toHaveProperty("items"); + expect(body.bootstrap_url).toBe("https://foragents.dev/b"); expect(Array.isArray(body.items)).toBe(true); }); diff --git a/__tests__/digest-endpoints.test.ts b/__tests__/digest-endpoints.test.ts index 0b400075..8fd7296a 100644 --- a/__tests__/digest-endpoints.test.ts +++ b/__tests__/digest-endpoints.test.ts @@ -37,6 +37,7 @@ describe("/api/digest.{json,md}", () => { expect(body).toHaveProperty("counts.new_agents"); expect(Array.isArray(body.new_artifacts)).toBe(true); expect(Array.isArray(body.new_agents)).toBe(true); + expect(body.bootstrap_url).toBe("https://foragents.dev/b"); }); test("GET /api/digest.md returns markdown + cache headers", async () => { diff --git a/__tests__/subscribe-route.test.ts b/__tests__/subscribe-route.test.ts index 97e4027f..ea82676e 100644 --- a/__tests__/subscribe-route.test.ts +++ b/__tests__/subscribe-route.test.ts @@ -9,11 +9,13 @@ jest.mock('@/lib/supabase', () => ({ })); import { createCheckoutSession } from '@/lib/stripe'; +import { __resetRateLimitsForTests } from '@/lib/requestLimits'; import { POST as subscribePOST } from '@/app/api/subscribe/route'; describe('/api/subscribe', () => { beforeEach(() => { jest.resetAllMocks(); + __resetRateLimitsForTests(); }); test('passes plan through to createCheckoutSession', async () => { @@ -21,6 +23,9 @@ describe('/api/subscribe', () => { const req = new NextRequest('http://localhost/api/subscribe', { method: 'POST', + headers: { + 'x-forwarded-for': '1.2.3.4', + }, body: JSON.stringify({ email: 'test@example.com', plan: 'annual' }), }); @@ -34,4 +39,19 @@ describe('/api/subscribe', () => { }) ); }); + + test('rejects overly large payloads', async () => { + const bigEmail = `test@${'a'.repeat(5000)}.com`; + + const req = new NextRequest('http://localhost/api/subscribe', { + method: 'POST', + headers: { + 'x-forwarded-for': '1.2.3.4', + }, + body: JSON.stringify({ email: bigEmail, plan: 'monthly' }), + }); + + const res = await subscribePOST(req); + expect(res.status).toBe(413); + }); }); diff --git a/scripts/audit-write-endpoints.baseline.txt b/scripts/audit-write-endpoints.baseline.txt index 3e0f4395..f22d4096 100644 --- a/scripts/audit-write-endpoints.baseline.txt +++ b/scripts/audit-write-endpoints.baseline.txt @@ -2,8 +2,6 @@ # Keep this list shrinking over time. src/app/api/agents/profile/premium/route.ts -src/app/api/artifacts/[id]/comments/route.ts -src/app/api/artifacts/[id]/ratings/route.ts src/app/api/artifacts/[id]/remix/route.ts src/app/api/collections/[id]/items/[itemId]/route.ts src/app/api/collections/[id]/route.ts @@ -12,7 +10,6 @@ src/app/api/digest/send/route.ts src/app/api/ingest/route.ts src/app/api/metrics/viral/event/route.ts src/app/api/profile/route.ts -src/app/api/register/route.ts src/app/api/stripe/checkout-session/route.ts src/app/api/stripe/portal-session/route.ts src/app/api/stripe/webhook/route.ts @@ -20,7 +17,6 @@ src/app/api/submissions/[id]/approve/route.ts src/app/api/submissions/[id]/reject/route.ts src/app/api/submissions/review/route.ts src/app/api/submit/route.ts -src/app/api/subscribe/route.ts src/app/api/subscription/portal/route.ts src/app/api/verify/check/route.ts src/app/api/verify/start/route.ts diff --git a/scripts/audit-write-endpoints.js b/scripts/audit-write-endpoints.js index b335aed1..ea3e7339 100644 --- a/scripts/audit-write-endpoints.js +++ b/scripts/audit-write-endpoints.js @@ -40,7 +40,11 @@ function isWriteRouteHandler(src) { } function hasBodyCap(src) { - return /readJsonWithLimit\s*\(/.test(src) || /readTextWithLimit\s*\(/.test(src); + // Allow optional TS generics: readJsonWithLimit(...) or readJsonWithLimit>(...) + return ( + /readJsonWithLimit(?:<[^\n]*?>+)?\s*\(/.test(src) || + /readTextWithLimit(?:<[^\n]*?>+)?\s*\(/.test(src) + ); } function hasRateLimit(src) { diff --git a/src/app/api/artifacts/[id]/comments/route.ts b/src/app/api/artifacts/[id]/comments/route.ts index 5ff52488..24fa84e3 100644 --- a/src/app/api/artifacts/[id]/comments/route.ts +++ b/src/app/api/artifacts/[id]/comments/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAgentAuth } from "@/lib/server/agent-auth"; -import { checkRateLimit } from "@/lib/server/rateLimit"; +import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit, readTextWithLimit } from "@/lib/requestLimits"; import { parseMarkdownWithFrontmatter, validateCommentFrontmatter, @@ -14,81 +14,86 @@ import { import { logViralEvent } from "@/lib/server/viralMetrics"; const MAX_MD_BYTES = 20_000; +// Cap the *request* body (slightly above MAX_MD_BYTES to allow JSON wrapper). +const MAX_BODY_BYTES = 24_000; async function readMarkdownBody(req: NextRequest): Promise { const ct = req.headers.get("content-type") ?? ""; if (ct.includes("application/json")) { - const json = (await req.json().catch(() => null)) as null | { markdown?: unknown }; - const md = typeof json?.markdown === "string" ? json.markdown : ""; - return md; + const json = await readJsonWithLimit<{ markdown?: unknown }>(req, MAX_BODY_BYTES); + return typeof json?.markdown === "string" ? json.markdown : ""; } - return await req.text(); + + return await readTextWithLimit(req, MAX_BODY_BYTES); } export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { - const { id: artifactId } = await context.params; - - const { agent, errorResponse } = await requireAgentAuth(request); - if (errorResponse) return errorResponse; + try { + const { id: artifactId } = await context.params; - const rl = checkRateLimit({ - key: `comments:${agent!.agent_id}`, - limit: 20, - windowMs: 60 * 60 * 1000, - }); - if (!rl.ok) { - return NextResponse.json( - { error: "Rate limit exceeded" }, - { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } } - ); - } + const { agent, errorResponse } = await requireAgentAuth(request); + if (errorResponse) return errorResponse; - const raw = await readMarkdownBody(request); - if (!raw || typeof raw !== "string") { - return NextResponse.json({ error: "Validation failed", details: ["markdown body is required"] }, { status: 400 }); - } + const ip = getClientIp(request); + const rl = checkRateLimit(`artifacts:comments:post:${ip}`, { windowMs: 60_000, max: 20 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); - if (Buffer.byteLength(raw, "utf-8") > MAX_MD_BYTES) { - return NextResponse.json( - { error: "Validation failed", details: ["body too large"] }, - { status: 400 } - ); - } + const raw = await readMarkdownBody(request); + if (!raw || typeof raw !== "string") { + return NextResponse.json( + { error: "Validation failed", details: ["markdown body is required"] }, + { status: 400 } + ); + } - const parsed = parseMarkdownWithFrontmatter(raw); - const fm = validateCommentFrontmatter(parsed.frontmatter); + if (Buffer.byteLength(raw, "utf-8") > MAX_MD_BYTES) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } - const details = [...fm.errors]; - if (fm.artifact_id && fm.artifact_id !== artifactId) details.push("artifact_id mismatch"); - if (!parsed.body_md || parsed.body_md.length < 1) details.push("body must be >= 1 char"); + const parsed = parseMarkdownWithFrontmatter(raw); + const fm = validateCommentFrontmatter(parsed.frontmatter); - if (details.length) { - return NextResponse.json({ error: "Validation failed", details }, { status: 400 }); - } + const details = [...fm.errors]; + if (fm.artifact_id && fm.artifact_id !== artifactId) details.push("artifact_id mismatch"); + if (!parsed.body_md || parsed.body_md.length < 1) details.push("body must be >= 1 char"); - if (fm.parent_id) { - const exists = await commentExistsOnArtifact(fm.parent_id, artifactId); - if (!exists) { - return NextResponse.json( - { error: "Validation failed", details: ["parent_id not found on artifact"] }, - { status: 400 } - ); + if (details.length) { + return NextResponse.json({ error: "Validation failed", details }, { status: 400 }); } - } - const comment = await createArtifactComment({ - artifact_id: artifactId, - parent_id: fm.parent_id, - kind: fm.kind!, - raw_md: parsed.raw_md, - body_md: parsed.body_md, - body_text: parsed.body_text, - author: agent!, - }); + if (fm.parent_id) { + const exists = await commentExistsOnArtifact(fm.parent_id, artifactId); + if (!exists) { + return NextResponse.json( + { error: "Validation failed", details: ["parent_id not found on artifact"] }, + { status: 400 } + ); + } + } - void logViralEvent("comment_created", { artifact_id: artifactId }); + const comment = await createArtifactComment({ + artifact_id: artifactId, + parent_id: fm.parent_id, + kind: fm.kind!, + raw_md: parsed.raw_md, + body_md: parsed.body_md, + body_text: parsed.body_text, + author: agent!, + }); + + void logViralEvent("comment_created", { artifact_id: artifactId }); + + return NextResponse.json({ success: true, comment }, { status: 201 }); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const status = typeof (err as any)?.status === "number" ? (err as any).status : 400; + if (status === 413) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } - return NextResponse.json({ success: true, comment }, { status: 201 }); + console.error("Artifact comment error:", err); + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } } export async function GET(request: NextRequest, context: { params: Promise<{ id: string }> }) { diff --git a/src/app/api/artifacts/[id]/ratings/route.ts b/src/app/api/artifacts/[id]/ratings/route.ts index 9a1a066b..3a3fc489 100644 --- a/src/app/api/artifacts/[id]/ratings/route.ts +++ b/src/app/api/artifacts/[id]/ratings/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { requireAgentAuth } from "@/lib/server/agent-auth"; -import { checkRateLimit } from "@/lib/server/rateLimit"; +import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit, readTextWithLimit } from "@/lib/requestLimits"; import { parseMarkdownWithFrontmatter, validateRatingFrontmatter, @@ -10,66 +10,71 @@ import { upsertArtifactRating } from "@/lib/server/artifactFeedback"; import { logViralEvent } from "@/lib/server/viralMetrics"; const MAX_MD_BYTES = 20_000; +const MAX_BODY_BYTES = 24_000; async function readMarkdownBody(req: NextRequest): Promise { const ct = req.headers.get("content-type") ?? ""; if (ct.includes("application/json")) { - const json = (await req.json().catch(() => null)) as null | { markdown?: unknown }; + const json = await readJsonWithLimit<{ markdown?: unknown }>(req, MAX_BODY_BYTES); return typeof json?.markdown === "string" ? json.markdown : ""; } - return await req.text(); + + return await readTextWithLimit(req, MAX_BODY_BYTES); } export async function POST(request: NextRequest, context: { params: Promise<{ id: string }> }) { - const { id: artifactId } = await context.params; + try { + const { id: artifactId } = await context.params; - const { agent, errorResponse } = await requireAgentAuth(request); - if (errorResponse) return errorResponse; + const { agent, errorResponse } = await requireAgentAuth(request); + if (errorResponse) return errorResponse; - const rl = checkRateLimit({ - key: `ratings:${agent!.agent_id}`, - limit: 30, - windowMs: 60 * 60 * 1000, - }); - if (!rl.ok) { - return NextResponse.json( - { error: "Rate limit exceeded" }, - { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } } - ); - } + const ip = getClientIp(request); + const rl = checkRateLimit(`artifacts:ratings:post:${ip}`, { windowMs: 60_000, max: 30 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); - const raw = await readMarkdownBody(request); - if (!raw || typeof raw !== "string") { - return NextResponse.json({ error: "Validation failed", details: ["markdown body is required"] }, { status: 400 }); - } + const raw = await readMarkdownBody(request); + if (!raw || typeof raw !== "string") { + return NextResponse.json( + { error: "Validation failed", details: ["markdown body is required"] }, + { status: 400 } + ); + } - if (Buffer.byteLength(raw, "utf-8") > MAX_MD_BYTES) { - return NextResponse.json( - { error: "Validation failed", details: ["body too large"] }, - { status: 400 } - ); - } + if (Buffer.byteLength(raw, "utf-8") > MAX_MD_BYTES) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } - const parsed = parseMarkdownWithFrontmatter(raw); - const fm = validateRatingFrontmatter(parsed.frontmatter); + const parsed = parseMarkdownWithFrontmatter(raw); + const fm = validateRatingFrontmatter(parsed.frontmatter); - const details = [...fm.errors]; - if (fm.artifact_id && fm.artifact_id !== artifactId) details.push("artifact_id mismatch"); + const details = [...fm.errors]; + if (fm.artifact_id && fm.artifact_id !== artifactId) details.push("artifact_id mismatch"); - if (details.length) { - return NextResponse.json({ error: "Validation failed", details }, { status: 400 }); - } + if (details.length) { + return NextResponse.json({ error: "Validation failed", details }, { status: 400 }); + } + + const { rating, created } = await upsertArtifactRating({ + artifact_id: artifactId, + rater: agent!, + score: fm.score!, + dims: fm.dims ?? {}, + raw_md: parsed.raw_md, + notes_md: parsed.body_md || null, + }); - const { rating, created } = await upsertArtifactRating({ - artifact_id: artifactId, - rater: agent!, - score: fm.score!, - dims: fm.dims ?? {}, - raw_md: parsed.raw_md, - notes_md: parsed.body_md || null, - }); + void logViralEvent("rating_created_or_updated", { artifact_id: artifactId }); - void logViralEvent("rating_created_or_updated", { artifact_id: artifactId }); + return NextResponse.json({ success: true, rating }, { status: created ? 201 : 200 }); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const status = typeof (err as any)?.status === "number" ? (err as any).status : 400; + if (status === 413) { + return NextResponse.json({ error: "Request body too large" }, { status: 413 }); + } - return NextResponse.json({ success: true, rating }, { status: created ? 201 : 200 }); + console.error("Artifact rating error:", err); + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } } diff --git a/src/app/api/digest.json/route.ts b/src/app/api/digest.json/route.ts index 446e11e7..a97da23b 100644 --- a/src/app/api/digest.json/route.ts +++ b/src/app/api/digest.json/route.ts @@ -20,6 +20,13 @@ export async function GET(request: NextRequest) { const digest = await generateAgentDigest({ since, now }); + const payload = { + ...digest, + // Canonical "bootstrap" link for agent-to-agent propagation. + // Keep backward compatibility by only adding a new top-level field. + bootstrap_url: "https://foragents.dev/b", + }; + // Convenience: allow requesting markdown via Accept header. if ((request.headers.get("accept") ?? "").includes("text/markdown")) { return new NextResponse(agentDigestToMarkdown(digest), { @@ -30,7 +37,7 @@ export async function GET(request: NextRequest) { }); } - return NextResponse.json(digest, { + return NextResponse.json(payload, { headers: { "Cache-Control": "public, max-age=300, stale-while-revalidate=600", }, diff --git a/src/app/api/register/route.ts b/src/app/api/register/route.ts index 74192878..5c38e3ff 100644 --- a/src/app/api/register/route.ts +++ b/src/app/api/register/route.ts @@ -2,9 +2,12 @@ import { NextRequest, NextResponse } from "next/server"; import { promises as fs } from "fs"; import path from "path"; import { getSupabase } from "@/lib/supabase"; +import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from "@/lib/requestLimits"; export const runtime = "nodejs"; +const MAX_JSON_BYTES = 2_000; + const REGISTRATIONS_PATH = path.join(process.cwd(), "data", "registrations.json"); type RegistrationRow = { @@ -92,7 +95,11 @@ async function writeRegistrations(rows: RegistrationRow[]): Promise { export async function POST(req: NextRequest) { try { - const body = (await req.json()) as Record; + const ip = getClientIp(req); + const rl = checkRateLimit(`register:${ip}`, { windowMs: 60_000, max: 20 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); + + const body = await readJsonWithLimit>(req, MAX_JSON_BYTES); const name = typeof body.name === "string" ? body.name.trim() : ""; const platform = typeof body.platform === "string" ? body.platform.trim() : ""; const ownerUrl = typeof body.ownerUrl === "string" ? body.ownerUrl.trim() : ""; @@ -208,6 +215,15 @@ export async function POST(req: NextRequest) { ); } catch (err) { console.error("Register error:", err); + + const status = + typeof err === "object" && err && "status" in err + ? Number((err as { status?: unknown }).status) + : 400; + if (status === 413) { + return NextResponse.json({ error: "Payload too large" }, { status: 413 }); + } + return NextResponse.json({ error: "Invalid request body. Expected JSON." }, { status: 400 }); } } diff --git a/src/app/api/subscribe/route.ts b/src/app/api/subscribe/route.ts index 260126c0..355e6c19 100644 --- a/src/app/api/subscribe/route.ts +++ b/src/app/api/subscribe/route.ts @@ -1,12 +1,21 @@ import { NextRequest, NextResponse } from 'next/server'; import { createCheckoutSession } from '@/lib/stripe'; import { getSupabase } from '@/lib/supabase'; +import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from '@/lib/requestLimits'; export const runtime = 'nodejs'; +const MAX_JSON_BYTES = 2_000; + export async function POST(req: NextRequest) { try { - const { email, plan } = await req.json(); + const ip = getClientIp(req); + const rl = checkRateLimit(`subscribe:${ip}`, { windowMs: 60_000, max: 20 }); + if (!rl.ok) return rateLimitResponse(rl.retryAfterSec); + + const body = await readJsonWithLimit>(req, MAX_JSON_BYTES); + const email = body?.email; + const plan = body?.plan; if (!email || typeof email !== 'string') { return NextResponse.json( @@ -44,7 +53,7 @@ export async function POST(req: NextRequest) { // Create new agent record const { data: newAgent, error } = await supabase .from('agents') - .insert({ + .insert({ name: agentHandle, platform: 'foragents', owner_url: email, @@ -65,7 +74,7 @@ export async function POST(req: NextRequest) { // Create Stripe checkout session const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://foragents.dev'; - + const session = await createCheckoutSession({ agentId, agentHandle, @@ -82,8 +91,17 @@ export async function POST(req: NextRequest) { } return NextResponse.json({ url: session.url }); - } catch (error) { - console.error('Subscribe error:', error); + } catch (err) { + console.error('Subscribe error:', err); + + const status = + typeof err === 'object' && err && 'status' in err + ? Number((err as { status?: unknown }).status) + : 500; + if (status === 413) { + return NextResponse.json({ error: 'Payload too large' }, { status: 413 }); + } + return NextResponse.json( { error: 'Internal server error' }, { status: 500 } diff --git a/src/app/feeds/artifacts.json/route.ts b/src/app/feeds/artifacts.json/route.ts index 0c8bc4c9..def188a3 100644 --- a/src/app/feeds/artifacts.json/route.ts +++ b/src/app/feeds/artifacts.json/route.ts @@ -42,6 +42,9 @@ export async function GET(request: NextRequest) { home_page_url: "https://foragents.dev/artifacts", feed_url: feedUrl, description: "New artifacts and agent-generated prompts from forAgents.dev", + // Canonical "bootstrap" link for agent-to-agent propagation. + // Keep backward compatibility by only adding a new top-level field. + bootstrap_url: "https://foragents.dev/b", items: items.map((a) => { const url = artifactUrl(a.id); return {