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
20 changes: 20 additions & 0 deletions __tests__/subscribe-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,23 @@ 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 () => {
(createCheckoutSession as unknown as jest.Mock).mockResolvedValue({ url: 'https://stripe.test/checkout' });

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' }),
});

Expand All @@ -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);
});
});
4 changes: 0 additions & 4 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 All @@ -12,15 +10,13 @@ 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
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
Expand Down
6 changes: 5 additions & 1 deletion scripts/audit-write-endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ function isWriteRouteHandler(src) {
}

function hasBodyCap(src) {
return /readJsonWithLimit\s*\(/.test(src) || /readTextWithLimit\s*\(/.test(src);
// Allow optional TS generics: readJsonWithLimit<T>(...) or readJsonWithLimit<Record<string,unknown>>(...)
return (
/readJsonWithLimit(?:<[^\n]*?>+)?\s*\(/.test(src) ||
/readTextWithLimit(?:<[^\n]*?>+)?\s*\(/.test(src)
);
}

function hasRateLimit(src) {
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
Loading
Loading