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/scripts/audit-write-endpoints.baseline.txt b/scripts/audit-write-endpoints.baseline.txt index 3e0f4395..3fc3dd57 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 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/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 {