Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions __tests__/artifact-comments-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
4 changes: 2 additions & 2 deletions __tests__/artifact-ratings-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions __tests__/artifacts-feeds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
1 change: 1 addition & 0 deletions __tests__/digest-endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 0 additions & 2 deletions scripts/audit-write-endpoints.baseline.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 62 additions & 57 deletions src/app/api/artifacts/[id]/comments/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string> {
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<CommentFrontmatter>(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<CommentFrontmatter>(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 }> }) {
Expand Down
93 changes: 49 additions & 44 deletions src/app/api/artifacts/[id]/ratings/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<string> {
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<RatingFrontmatter>(raw);
const fm = validateRatingFrontmatter(parsed.frontmatter);
const parsed = parseMarkdownWithFrontmatter<RatingFrontmatter>(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 });
}
}
9 changes: 8 additions & 1 deletion src/app/api/digest.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), {
Expand All @@ -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",
},
Expand Down
3 changes: 3 additions & 0 deletions src/app/feeds/artifacts.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading