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
31 changes: 31 additions & 0 deletions __tests__/premium-profile-route-limits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest } from 'next/server';

jest.mock('@/lib/supabase', () => ({
getSupabase: jest.fn(() => null),
}));

import { POST as premiumPOST } from '@/app/api/agents/profile/premium/route';

describe('/api/agents/profile/premium request limits', () => {
test('rejects large bodies with 413', async () => {
const huge = 'x'.repeat(50_000);

const req = new NextRequest('http://localhost/api/agents/profile/premium', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-forwarded-for': '1.2.3.4',
},
body: JSON.stringify({
agentHandle: '@a',
config: { extendedBio: huge },
}),
});

const res = await premiumPOST(req);
expect(res.status).toBe(413);

const json = await res.json();
expect(json.error).toMatch(/too large/i);
});
});
5 changes: 0 additions & 5 deletions scripts/audit-write-endpoints.baseline.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,3 @@
# Known write endpoints that still need request limits.
# Keep this list shrinking over time.

src/app/api/agents/profile/premium/route.ts
src/app/api/collections/[id]/route.ts
src/app/api/ingest/route.ts
src/app/api/verify/check/route.ts
src/app/api/verify/start/route.ts
2 changes: 2 additions & 0 deletions src/app/agents/[handle]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Separator } from "@/components/ui/separator";
import { VerifiedBadge } from "@/components/PremiumBadge";
import { SaveToCollectionButton } from "@/components/collections/SaveToCollectionButton";
import Link from "next/link";
import { TrackRecentlyViewed } from "@/components/recently-viewed/TrackRecentlyViewed";

// Generate static paths for all agents
export function generateStaticParams() {
Expand Down Expand Up @@ -101,6 +102,7 @@ export default async function AgentProfilePage({

return (
<div className="min-h-screen">
<TrackRecentlyViewed item={{ type: "agent", key: agent.handle, title: agent.name, href: `/agents/${agent.handle}` }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
{/* Header */}
<header className="border-b border-white/5 backdrop-blur-sm sticky top-0 z-50 bg-background/80">
Expand Down
32 changes: 21 additions & 11 deletions src/app/api/agents/profile/premium/route.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSupabase } from '@/lib/supabase';
import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from '@/lib/requestLimits';

/**
* POST /api/agents/profile/premium
* Update premium profile configuration
*/
const MAX_JSON_BYTES = 12_000;

export async function POST(req: NextRequest) {
const ip = getClientIp(req);
const rl = checkRateLimit(`premium_profile:${ip}`, { windowMs: 60_000, max: 20 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);

try {
const body = await req.json();
const { agentHandle, config } = body;
const body = await readJsonWithLimit<Record<string, unknown>>(req, MAX_JSON_BYTES);
const agentHandle = body.agentHandle;
const config = body.config;

if (!agentHandle || !config) {
return NextResponse.json(
{ error: 'Agent handle and config required' },
{ status: 400 }
);
if (typeof agentHandle !== 'string' || !agentHandle.trim() || typeof config !== 'object' || !config) {
return NextResponse.json({ error: 'Agent handle and config required' }, { status: 400 });
}

const supabase = getSupabase();
Expand All @@ -41,10 +46,11 @@ export async function POST(req: NextRequest) {
}

// Validate config
const cfg = config as Record<string, unknown>;
const validatedConfig = {
accentColor: config.accentColor || '#06D6A0',
extendedBio: (config.extendedBio || '').substring(0, 500),
customLinks: (Array.isArray(config.customLinks) ? config.customLinks : [])
accentColor: typeof cfg.accentColor === 'string' ? cfg.accentColor : '#06D6A0',
extendedBio: (typeof cfg.extendedBio === 'string' ? cfg.extendedBio : '').substring(0, 500),
customLinks: (Array.isArray(cfg.customLinks) ? cfg.customLinks : [])
.slice(0, 5)
.map((link: unknown) => {
const obj = (link && typeof link === 'object') ? (link as Record<string, unknown>) : {};
Expand All @@ -58,7 +64,7 @@ export async function POST(req: NextRequest) {
icon: icon.substring(0, 2),
};
}),
pinnedSkills: (config.pinnedSkills || []).slice(0, 3),
pinnedSkills: (Array.isArray(cfg.pinnedSkills) ? cfg.pinnedSkills : []).slice(0, 3),
};

// Update premium config
Expand All @@ -74,6 +80,10 @@ export async function POST(req: NextRequest) {

return NextResponse.json({ success: true, config: validatedConfig });
} catch (err) {
if (typeof err === 'object' && err && 'status' in err && (err as { status?: unknown }).status === 413) {
return NextResponse.json({ error: 'payload too large' }, { status: 413 });
}

console.error('Premium config update error:', err);
return NextResponse.json({ error: 'Server error' }, { status: 500 });
}
Expand Down
54 changes: 41 additions & 13 deletions src/app/api/collections/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getSupabase } from "@/lib/supabase";
import { checkRateLimit, getClientIp, rateLimitResponse, readJsonWithLimit } from "@/lib/requestLimits";
import { ensureUniqueSlug, normalizeOwnerHandle } from "@/lib/collections";
import { getAgentByHandle, formatAgentHandle } from "@/lib/data";
import { getArtifactById } from "@/lib/artifacts";

const MAX_PATCH_BYTES = 8_000;
const MAX_BODY_BYTES = 0; // DELETE/PATCH should not accept large bodies

function ownerHandleFrom(req: NextRequest): string | null {
const header = req.headers.get("x-owner-handle") || req.headers.get("x-agent-handle");
const query = req.nextUrl.searchParams.get("ownerHandle");
Expand Down Expand Up @@ -113,6 +117,10 @@ export async function GET(req: NextRequest, context: { params: Promise<{ id: str
}

export async function PATCH(req: NextRequest, context: { params: Promise<{ id: string }> }) {
const ip = getClientIp(req);
const rl = checkRateLimit(`collections_patch:${ip}`, { windowMs: 60_000, max: 30 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);

const supabase = getSupabase();
if (!supabase) return NextResponse.json({ error: "Database not configured" }, { status: 500 });

Expand All @@ -123,25 +131,36 @@ export async function PATCH(req: NextRequest, context: { params: Promise<{ id: s
const owned = await assertOwner({ supabase, id, ownerHandle });
if ("error" in owned) return NextResponse.json({ error: owned.error }, { status: owned.status });

const body = (await req.json().catch(() => null)) as null | {
name?: string;
description?: string | null;
visibility?: "private" | "public";
slug?: string;
};
let body: null | {
name?: unknown;
description?: unknown;
visibility?: unknown;
slug?: unknown;
} = null;

try {
body = (await readJsonWithLimit<Record<string, unknown>>(req, MAX_PATCH_BYTES)) as unknown as typeof body;
} catch (err) {
if (typeof err === "object" && err && "status" in err && (err as { status?: unknown }).status === 413) {
return NextResponse.json({ error: "payload too large" }, { status: 413 });
}
body = null;
}

const patch: Record<string, unknown> = {};

if (typeof body?.name === "string") patch.name = body.name.trim().slice(0, 120);
if (typeof body?.description === "string") patch.description = body.description.trim().slice(0, 2000);
if (body?.description === null) patch.description = null;
if (body?.visibility === "private" || body?.visibility === "public") {
patch.visibility = body.visibility;
const b = body as unknown as Record<string, unknown> | null;

if (typeof b?.name === "string") patch.name = b.name.trim().slice(0, 120);
if (typeof b?.description === "string") patch.description = b.description.trim().slice(0, 2000);
if (b && b.description === null) patch.description = null;
if (b?.visibility === "private" || b?.visibility === "public") {
patch.visibility = b.visibility;
}

if (typeof body?.slug === "string" && body.slug.trim()) {
if (typeof b?.slug === "string" && b.slug.trim()) {
// Ensure uniqueness
const desired = body.slug.trim();
const desired = b.slug.trim();
const slug = await ensureUniqueSlug({
desired,
exists: async (s) => {
Expand Down Expand Up @@ -188,6 +207,15 @@ export async function PATCH(req: NextRequest, context: { params: Promise<{ id: s
}

export async function DELETE(req: NextRequest, context: { params: Promise<{ id: string }> }) {
const ip = getClientIp(req);
const rl = checkRateLimit(`collections_delete:${ip}`, { windowMs: 60_000, max: 20 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);

const contentLength = Number(req.headers.get("content-length") || 0);
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return NextResponse.json({ error: "payload too large" }, { status: 413 });
}

const supabase = getSupabase();
if (!supabase) return NextResponse.json({ error: "Database not configured" }, { status: 500 });

Expand Down
21 changes: 21 additions & 0 deletions src/app/api/ingest/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { runIngestion } from '@/lib/ingest-runtime';
import { checkRateLimit, getClientIp, rateLimitResponse } from '@/lib/requestLimits';
import { requireCronAuth } from '@/lib/server/cron-auth';
import { getSupabaseAdmin } from '@/lib/server/supabase-admin';

export const maxDuration = 60; // Allow up to 60 seconds for ingestion

const MAX_BODY_BYTES = 0; // cron endpoints should not accept request bodies

export async function POST(req: NextRequest) {
const ip = getClientIp(req);
const rl = checkRateLimit(`ingest_post:${ip}`, { windowMs: 60_000, max: 5 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);

const contentLength = Number(req.headers.get('content-length') || 0);
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'payload too large' }, { status: 413 });
}

const auth = requireCronAuth(req);
if (!auth.authorized) return auth.response;

Expand Down Expand Up @@ -60,13 +72,22 @@ export async function POST(req: NextRequest) {
export async function GET(req: NextRequest) {
const CRON_SECRET = process.env.CRON_SECRET || '';

const ip = getClientIp(req);
const rl = checkRateLimit(`ingest_get:${ip}`, { windowMs: 60_000, max: 10 });
if (!rl.ok) return rateLimitResponse(rl.retryAfterSec);

// Vercel Cron triggers GET requests. It includes a header that we can use to
// distinguish cron traffic from normal public traffic.
// Note: This header can be spoofed, so keep CRON_SECRET enabled for manual POSTs.
const isVercelCron = req.headers.get('x-vercel-cron') === '1';

// If this is a Vercel Cron invocation, run ingestion (still requires cron auth policy).
if (isVercelCron) {
const contentLength = Number(req.headers.get('content-length') || 0);
if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
return NextResponse.json({ error: 'payload too large' }, { status: 413 });
}

const auth = requireCronAuth(req);
if (!auth.authorized) return auth.response;

Expand Down
2 changes: 2 additions & 0 deletions src/app/artifacts/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ViralEventOnMount } from "@/components/metrics/ViralEventOnMount";
import { SaveToCollectionButton } from "@/components/collections/SaveToCollectionButton";
import { getArtifactById } from "@/lib/artifacts";
import { artifactUrl } from "@/lib/artifactsShared";
import { TrackRecentlyViewed } from "@/components/recently-viewed/TrackRecentlyViewed";

function toDescription(text: string): string {
const cleaned = text.replace(/\s+/g, " ").trim();
Expand Down Expand Up @@ -80,6 +81,7 @@ export default async function ArtifactPermalinkPage(props: {

return (
<div className="min-h-screen">
<TrackRecentlyViewed item={{ type: "artifact", key: artifact.id, title: artifact.title, href: `/artifacts/${artifact.id}` }} />
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<header className="border-b border-white/5 backdrop-blur-sm sticky top-0 z-50 bg-background/80 relative">
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center justify-between">
Expand Down
4 changes: 4 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NewsFeed } from "@/components/news-feed";
import { RecentSubmissions } from "@/components/recent-submissions";
import { AnnouncementBanner } from "@/components/announcement-banner";
import { Footer } from "@/components/footer";
import { ResumeSection } from "@/components/recently-viewed/ResumeSection";

export const revalidate = 300;

Expand Down Expand Up @@ -61,6 +62,9 @@ export default async function Home() {
{/* Announcement Banner */}
<AnnouncementBanner />

{/* Resume (recently viewed) */}
<ResumeSection />

{/* Hero */}
<section className="relative overflow-hidden min-h-[600px] flex items-center">
{/* Subtle aurora background */}
Expand Down
95 changes: 95 additions & 0 deletions src/components/recently-viewed/ResumeSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { clearRecentlyViewed, getRecentlyViewed, type RecentlyViewedItem } from "@/lib/recentlyViewed";

function labelForType(type: RecentlyViewedItem["type"]) {
if (type === "agent") return "Agent";
if (type === "artifact") return "Artifact";
return type;
}

export function ResumeSection() {
const [items, setItems] = useState<RecentlyViewedItem[]>(() => {
// Avoid effects that only exist to set state on mount.
// This runs only on the client; the component is `use client`.
try {
return getRecentlyViewed().slice(0, 8);
} catch {
return [];
}
});

useEffect(() => {
const onUpdate = () => setItems(getRecentlyViewed().slice(0, 8));

window.addEventListener("recentlyViewedUpdated", onUpdate);
window.addEventListener("storage", onUpdate);
return () => {
window.removeEventListener("recentlyViewedUpdated", onUpdate);
window.removeEventListener("storage", onUpdate);
};
}, []);

const hasItems = items.length > 0;

const rows = useMemo(
() =>
items.map((item) => ({
...item,
typeLabel: labelForType(item.type),
})),
[items],
);

if (!hasItems) return null;

return (
<section className="max-w-5xl mx-auto px-4 py-6">
<Card className="bg-card/50 border-white/5">
<div className="p-4 md:p-5">
<div className="flex items-start justify-between gap-3 mb-3">
<div>
<h2 className="text-lg font-semibold">⏯ Resume</h2>
<p className="text-xs text-muted-foreground mt-1">
Recently viewed (stored locally in your browser)
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
clearRecentlyViewed();
setItems([]);
}}
className="shrink-0"
>
Clear
</Button>
</div>

<div className="grid gap-2">
{rows.map((item) => (
<Link
key={`${item.type}:${item.key}`}
href={item.href}
className="flex items-center justify-between gap-3 rounded-md border border-white/5 px-3 py-2 hover:border-cyan/20 transition-colors"
>
<div className="min-w-0">
<div className="text-xs text-muted-foreground font-mono">
{item.typeLabel}
</div>
<div className="text-sm font-medium truncate">{item.title}</div>
</div>
<span className="text-xs text-cyan">Open →</span>
</Link>
))}
</div>
</div>
</Card>
</section>
);
}
Loading
Loading