diff --git a/web/src/app/api/dashboard/route.ts b/web/src/app/api/dashboard/route.ts index 7514ec45..dc1a7271 100644 --- a/web/src/app/api/dashboard/route.ts +++ b/web/src/app/api/dashboard/route.ts @@ -17,14 +17,14 @@ export async function GET(req: NextRequest) { let patronTalosIds = []; try { patronTalosIds = await withTimeout( - db.select({ talosId: tlsPatrons.talosId }).from(tlsPatrons).where(eq(tlsPatrons.stellarPublicKey, addr)), + db.select({ talosId: tlsPatrons.talosId }).from(tlsPatrons).where(eq(tlsPatrons.stellarPublicKey, addr)).limit(100), 25000, "Dashboard patron query timeout", ); } catch (error) { if (error instanceof TimeoutError) { return NextResponse.json( - { error: "Query timeout. Please try again with a simpler query.", details: error.message }, + { error: "Query timeout. Please try again with a simpler query." }, { status: 408 }, ); } @@ -51,11 +51,12 @@ export async function GET(req: NextRequest) { talosRows = await withTimeout( db.query.tlsTalos.findMany({ where: whereCondition, + limit: 100, with: { - approvals: { orderBy: (a, { desc: d }) => [d(a.createdAt)] }, + approvals: { orderBy: (a, { desc: d }) => [d(a.createdAt)], limit: 50 }, activities: { orderBy: (a, { desc: d }) => [d(a.createdAt)], limit: 10 }, - revenues: { orderBy: (r, { desc: d }) => [d(r.createdAt)] }, - patrons: true, + revenues: { orderBy: (r, { desc: d }) => [d(r.createdAt)], limit: 50 }, + patrons: { limit: 50 }, }, }), 30000, @@ -64,7 +65,7 @@ export async function GET(req: NextRequest) { } catch (error) { if (error instanceof TimeoutError) { return NextResponse.json( - { error: "Query timeout. Please try again with a simpler query.", details: error.message }, + { error: "Query timeout. Please try again with a simpler query." }, { status: 408 }, ); } diff --git a/web/src/app/api/ecosystem-intelligence/route.ts b/web/src/app/api/ecosystem-intelligence/route.ts index 2c8b22df..1c2b5065 100644 --- a/web/src/app/api/ecosystem-intelligence/route.ts +++ b/web/src/app/api/ecosystem-intelligence/route.ts @@ -18,6 +18,7 @@ import { suppressSparseRecord, suppressSparseRows, } from "@/lib/analytics-privacy"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; export const dynamic = 'force-dynamic'; @@ -109,6 +110,13 @@ export async function GET(req: Request) { // if (!wallet) { // return NextResponse.json({ error: "wallet parameter required" }, { status: 400 }); // } + + const url = new URL(req.url); + const parsedLimit = parseAnalyticsLimit(url.searchParams.get("limit"), 10, 50); + if (!parsedLimit.ok) { + return parsedLimit.response; + } + const detailedLimit = parsedLimit.limit; try { const now = new Date(); @@ -309,7 +317,7 @@ export async function GET(req: Request) { ); const byAgent = visibleByAgent.rows .sort((a, b) => b.completionRate - a.completionRate) - .slice(0, 10); + .slice(0, detailedLimit); // Opportunity metrics // Calculate demand vs supply by category @@ -351,7 +359,7 @@ export async function GET(req: Request) { row => categoryCohortSizes[row.category] ?? 0, ); const underservedCategories = visibleUnderservedCategories.rows - .slice(0, 5); + .slice(0, Math.min(detailedLimit, 5)); // Trending agents (by revenue growth) const agentRevenue7d = new Map(); @@ -383,7 +391,7 @@ export async function GET(req: Request) { row => row.cohortSize, ); const trendingAgents = visibleTrendingAgents.rows - .slice(0, 5) + .slice(0, Math.min(detailedLimit, 5)) .map(agent => ({ agentId: agent.agentId, agentName: agent.agentName, diff --git a/web/src/app/api/proposals/route.ts b/web/src/app/api/proposals/route.ts index 8719413b..442ffd96 100644 --- a/web/src/app/api/proposals/route.ts +++ b/web/src/app/api/proposals/route.ts @@ -3,11 +3,16 @@ import { db } from "@/db"; import { tlsApprovals, tlsTalos } from "@/db/schema"; import { eq, desc } from "drizzle-orm"; import { internalError } from "@/lib/api-response"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; // GET /api/proposals — All proposals across all Talos, newest first -// Optional ?status=pending|approved|rejected filter +// Optional ?status=pending|approved|rejected filter, optional ?limit=1..100 (default 50) export async function GET(request: NextRequest) { - const status = new URL(request.url).searchParams.get("status"); + const searchParams = new URL(request.url).searchParams; + const status = searchParams.get("status"); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 50, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; try { const rows = await db @@ -28,7 +33,8 @@ export async function GET(request: NextRequest) { .from(tlsApprovals) .innerJoin(tlsTalos, eq(tlsApprovals.talosId, tlsTalos.id)) .where(status ? eq(tlsApprovals.status, status) : undefined) - .orderBy(desc(tlsApprovals.createdAt)); + .orderBy(desc(tlsApprovals.createdAt)) + .limit(limit); return Response.json(rows); } catch { diff --git a/web/src/app/api/talos/[id]/dividends/route.ts b/web/src/app/api/talos/[id]/dividends/route.ts index 255afa05..bce94137 100644 --- a/web/src/app/api/talos/[id]/dividends/route.ts +++ b/web/src/app/api/talos/[id]/dividends/route.ts @@ -5,6 +5,7 @@ import { desc, eq } from "drizzle-orm"; import { verifyAgentApiKey } from "@/lib/auth"; import { recordDividendSchema, parseBody } from "@/lib/schemas"; import { emitWebhookEvent } from "@/lib/webhooks/delivery"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; /** * GET /api/talos/:id/dividends @@ -13,15 +14,20 @@ import { emitWebhookEvent } from "@/lib/webhooks/delivery"; * revenue that has been shared out to Mitos/Pulse token holders over time. * * Public read (consistent with revenue history + RLS anon_read policy). - * Returns the most recent 50 distributions, newest first. + * Returns distributions with bounded limit (default 50, max 100), newest first. */ export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; try { + const { searchParams } = new URL(request.url); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 50, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; + const talos = await db .select({ id: tlsTalos.id }) .from(tlsTalos) @@ -38,7 +44,7 @@ export async function GET( .from(tlsDividends) .where(eq(tlsDividends.talosId, id)) .orderBy(desc(tlsDividends.createdAt)) - .limit(50); + .limit(limit); return Response.json(dividends); } catch { diff --git a/web/src/app/api/talos/[id]/exposure/__tests__/route.test.ts b/web/src/app/api/talos/[id]/exposure/__tests__/route.test.ts index eb5899b3..74311d52 100644 --- a/web/src/app/api/talos/[id]/exposure/__tests__/route.test.ts +++ b/web/src/app/api/talos/[id]/exposure/__tests__/route.test.ts @@ -79,4 +79,43 @@ describe("GET /api/talos/:id/exposure", () => { expect(body.exposures[0].settledAmount).toBe(100); expect(body.windowDays).toBe(7); }); + + it("returns 400 when limit exceeds maximum allowed limit", async () => { + mocks.verifyAgentApiKey.mockResolvedValue({ ok: true, talos: { id: "agent-1", apiKey: "valid" } }); + + const response = await GET(new NextRequest("http://localhost/api/talos/agent-1/exposure?limit=150"), { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-1", "abc", "1.5", ""])("returns 400 for malformed limit=%s", async (val) => { + mocks.verifyAgentApiKey.mockResolvedValue({ ok: true, talos: { id: "agent-1", apiKey: "valid" } }); + + const response = await GET(new NextRequest(`http://localhost/api/talos/agent-1/exposure?limit=${val}`), { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); + + it("accepts maximum limit of 100", async () => { + mocks.verifyAgentApiKey.mockResolvedValue({ ok: true, talos: { id: "agent-1", apiKey: "valid" } }); + mocks.select + .mockReturnValueOnce(chain([{ id: "agent-1" }])) + .mockReturnValueOnce(chain([])); + + const response = await GET(new NextRequest("http://localhost/api/talos/agent-1/exposure?limit=100"), { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.pagination.limit).toBe(100); + }); }); diff --git a/web/src/app/api/talos/[id]/exposure/alerts/__tests__/route.test.ts b/web/src/app/api/talos/[id]/exposure/alerts/__tests__/route.test.ts index 46880281..05a97b32 100644 --- a/web/src/app/api/talos/[id]/exposure/alerts/__tests__/route.test.ts +++ b/web/src/app/api/talos/[id]/exposure/alerts/__tests__/route.test.ts @@ -76,4 +76,60 @@ describe("GET /api/talos/:id/exposure/alerts", () => { expect(body.alerts.some((alert: { type: string }) => alert.type === "repeated-denial")).toBe(true); expect(body.alerts.some((alert: { type: string }) => alert.type === "reconciliation-drift")).toBe(true); }); + + it("returns 400 when limit exceeds maximum allowed limit of 100", async () => { + mocks.verifyAgentApiKey.mockResolvedValue({ ok: true, talos: { id: "agent-1", apiKey: "valid" } }); + + const response = await GET(new NextRequest("http://localhost/api/talos/agent-1/exposure/alerts?limit=150"), { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-1", "abc", "1.5", ""])("returns 400 for malformed limit=%s", async (val) => { + mocks.verifyAgentApiKey.mockResolvedValue({ ok: true, talos: { id: "agent-1", apiKey: "valid" } }); + + const response = await GET(new NextRequest(`http://localhost/api/talos/agent-1/exposure/alerts?limit=${val}`), { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); + + it("bounds returned alerts array to requested limit", async () => { + mocks.verifyAgentApiKey.mockResolvedValue({ ok: true, talos: { id: "agent-1", apiKey: "valid" } }); + mocks.select + .mockReturnValueOnce(chain([{ id: "agent-1" }])) + .mockReturnValueOnce(chain([ + { + counterpartyId: "counterparty-1", + asset: "USDC", + reservedAmount: "1200", + settledAmount: "100", + deniedCount: 3, + lastObservedAt: new Date("2026-07-25T00:00:00.000Z"), + }, + { + counterpartyId: "counterparty-2", + asset: "USDC", + reservedAmount: "2000", + settledAmount: "100", + deniedCount: 5, + lastObservedAt: new Date("2026-07-25T00:00:00.000Z"), + }, + ])); + + const response = await GET(new NextRequest("http://localhost/api/talos/agent-1/exposure/alerts?limit=2"), { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.alerts.length).toBeLessThanOrEqual(2); + }); }); diff --git a/web/src/app/api/talos/[id]/exposure/alerts/route.ts b/web/src/app/api/talos/[id]/exposure/alerts/route.ts index 6633f3fa..00d05738 100644 --- a/web/src/app/api/talos/[id]/exposure/alerts/route.ts +++ b/web/src/app/api/talos/[id]/exposure/alerts/route.ts @@ -3,6 +3,7 @@ import { and, desc, eq, gte, sql } from "drizzle-orm"; import { db } from "@/db"; import { tlsCommerceJobs, tlsCommerceServices, tlsTalos } from "@/db/schema"; import { verifyAgentApiKey } from "@/lib/auth"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; function toNumber(value: unknown): number { const parsed = typeof value === "string" ? Number(value) : Number(value ?? 0); @@ -25,6 +26,11 @@ export async function GET( const auth = await verifyAgentApiKey(request, id, ["revenue:read"]); if (!auth.ok) return auth.response; + const { searchParams } = new URL(request.url); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 25, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; + const talos = await db .select({ id: tlsTalos.id }) .from(tlsTalos) @@ -36,7 +42,6 @@ export async function GET( return Response.json({ error: "TALOS not found" }, { status: 404 }); } - const { searchParams } = new URL(request.url); const windowDays = Math.max(1, Number(searchParams.get("window") ?? 30)); const since = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000); @@ -59,7 +64,8 @@ export async function GET( ) .where(and(eq(tlsCommerceJobs.talosId, id), gte(tlsCommerceJobs.createdAt, since))) .groupBy(tlsCommerceJobs.requesterTalosId, tlsCommerceServices.currency) - .orderBy(desc(sql`max(${tlsCommerceJobs.createdAt})`)); + .orderBy(desc(sql`max(${tlsCommerceJobs.createdAt})`)) + .limit(1000); const alerts = [] as Array<{ type: string; @@ -118,7 +124,7 @@ export async function GET( return Response.json({ agentId: id, windowDays, - alerts, + alerts: alerts.slice(0, limit), }); } catch { return Response.json({ error: "Internal server error" }, { status: 500 }); diff --git a/web/src/app/api/talos/[id]/exposure/route.ts b/web/src/app/api/talos/[id]/exposure/route.ts index 34aec70b..97780ae9 100644 --- a/web/src/app/api/talos/[id]/exposure/route.ts +++ b/web/src/app/api/talos/[id]/exposure/route.ts @@ -3,6 +3,7 @@ import { and, desc, eq, gte, sql } from "drizzle-orm"; import { db } from "@/db"; import { tlsCommerceJobs, tlsCommerceServices, tlsTalos } from "@/db/schema"; import { verifyAgentApiKey } from "@/lib/auth"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; function parseWindow(windowParam: string | null): { windowMs: number; windowDays: number } { const raw = windowParam ?? "30d"; @@ -13,13 +14,6 @@ function parseWindow(windowParam: string | null): { windowMs: number; windowDays return { windowMs: windowDays * 24 * 60 * 60 * 1000, windowDays }; } -function parsePagination(request: Request): { limit: number; cursor: string | null } { - const { searchParams } = new URL(request.url); - const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "25", 10) || 25, 1), 100); - const cursor = searchParams.get("cursor"); - return { limit, cursor }; -} - function toNumber(value: unknown): number { const parsed = typeof value === "string" ? Number(value) : Number(value ?? 0); return Number.isFinite(parsed) ? parsed : 0; @@ -41,6 +35,12 @@ export async function GET( const auth = await verifyAgentApiKey(request, id, ["revenue:read"]); if (!auth.ok) return auth.response; + const { searchParams } = new URL(request.url); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 25, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; + const cursor = searchParams.get("cursor"); + const talos = await db .select({ id: tlsTalos.id }) .from(tlsTalos) @@ -52,9 +52,7 @@ export async function GET( return Response.json({ error: "TALOS not found" }, { status: 404 }); } - const { searchParams } = new URL(request.url); const { windowMs, windowDays } = parseWindow(searchParams.get("window")); - const { limit, cursor } = parsePagination(request); const since = new Date(Date.now() - windowMs); const rows = await db @@ -78,7 +76,8 @@ export async function GET( ) .where(and(eq(tlsCommerceJobs.talosId, id), gte(tlsCommerceJobs.createdAt, since))) .groupBy(tlsCommerceJobs.requesterTalosId, tlsCommerceJobs.serviceName, tlsCommerceServices.currency) - .orderBy(desc(sql`max(${tlsCommerceJobs.createdAt})`)); + .orderBy(desc(sql`max(${tlsCommerceJobs.createdAt})`)) + .limit(1000); const filtered = rows .map((row) => ({ diff --git a/web/src/app/api/talos/[id]/financial-projection/route.ts b/web/src/app/api/talos/[id]/financial-projection/route.ts index 7556b29e..2d1b0d82 100644 --- a/web/src/app/api/talos/[id]/financial-projection/route.ts +++ b/web/src/app/api/talos/[id]/financial-projection/route.ts @@ -65,7 +65,8 @@ export async function GET( const patrons = await db .select() .from(tlsPatrons) - .where(eq(tlsPatrons.talosId, id)); + .where(eq(tlsPatrons.talosId, id)) + .limit(100); // Calculate revenue summary const totalRevenue = revenues.reduce( @@ -232,7 +233,7 @@ Generate projections for the next 12 months.`; } catch (error) { console.error("Financial projection error:", error); return Response.json( - { error: "Internal server error", details: error instanceof Error ? error.message : "Unknown error" }, + { error: "Internal server error" }, { status: 500 } ); } diff --git a/web/src/app/api/talos/[id]/financial-summary/__tests__/route.test.ts b/web/src/app/api/talos/[id]/financial-summary/__tests__/route.test.ts index 721b83dd..eb14fef1 100644 --- a/web/src/app/api/talos/[id]/financial-summary/__tests__/route.test.ts +++ b/web/src/app/api/talos/[id]/financial-summary/__tests__/route.test.ts @@ -536,6 +536,44 @@ describe("GET /api/talos/:id/financial-summary", () => { const parsed = JSON.parse(jsonString); expect(parsed.cashFlow.totalRevenue).toBe(1000000000000000.123456); }); + + it("returns 400 when limit exceeds maximum allowed limit of 100", async () => { + mocks.mockVerifyAgentApiKey.mockResolvedValue({ + ok: true, + talos: { id: "agent-1", apiKey: "valid-key" }, + }); + + const request = new NextRequest( + "http://localhost/api/talos/agent-1/financial-summary?limit=150", + { headers: { Authorization: "Bearer valid-key" } }, + ); + const response = await GET(request, { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-1", "abc", "1.5", ""])("returns 400 for malformed limit=%s", async (val) => { + mocks.mockVerifyAgentApiKey.mockResolvedValue({ + ok: true, + talos: { id: "agent-1", apiKey: "valid-key" }, + }); + + const request = new NextRequest( + `http://localhost/api/talos/agent-1/financial-summary?limit=${val}`, + { headers: { Authorization: "Bearer valid-key" } }, + ); + const response = await GET(request, { + params: Promise.resolve({ id: "agent-1" }), + }); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); }); describe("toMonetaryValue helper unit tests", () => { diff --git a/web/src/app/api/talos/[id]/financial-summary/route.ts b/web/src/app/api/talos/[id]/financial-summary/route.ts index 12e0f9f3..dbf4383f 100644 --- a/web/src/app/api/talos/[id]/financial-summary/route.ts +++ b/web/src/app/api/talos/[id]/financial-summary/route.ts @@ -9,6 +9,7 @@ import { } from "@/db/schema"; import { and, eq, gte, sql, desc } from "drizzle-orm"; import { verifyAgentApiKey } from "@/lib/auth"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; /** * Monetary Value Representation Standard: @@ -57,6 +58,11 @@ export async function GET( const auth = await verifyAgentApiKey(request, id, ["revenue:read"]); if (!auth.ok) return auth.response; + const { searchParams } = new URL(request.url); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 20, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; + // ── Verify the TALOS agent exists ───────────────────────────── const talos = await db .select({ @@ -213,7 +219,7 @@ export async function GET( ), ) .orderBy(desc(tlsApprovals.createdAt)) - .limit(20); + .limit(limit); // ── Playbook sales metrics ─────────────────────────────────── const playbookRows = await db @@ -240,7 +246,8 @@ export async function GET( tlsPlaybooks.currency, tlsPlaybooks.category, tlsPlaybooks.status, - ); + ) + .limit(limit); // ── Compute derived monetary analytics ─────────────────────── const totalRevenueNum = toMonetaryValue(revenueAllTime?.totalRevenue); diff --git a/web/src/app/api/talos/[id]/patrons/route.ts b/web/src/app/api/talos/[id]/patrons/route.ts index d6b0ef87..d59776e7 100644 --- a/web/src/app/api/talos/[id]/patrons/route.ts +++ b/web/src/app/api/talos/[id]/patrons/route.ts @@ -4,14 +4,19 @@ import { tlsTalos, tlsPatrons } from "@/db/schema"; import { and, desc, eq } from "drizzle-orm"; import { getAccountInfo, verifyStellarSignature } from "@/lib/stellar"; import { becomePatronSchema, revokePatronSchema, parseBody } from "@/lib/schemas"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; // GET /api/talos/:id/patrons — List patrons for a TALOS export async function GET( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { try { const { id } = await params; + const { searchParams } = new URL(request.url); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 50, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; const talos = await db .select({ id: tlsTalos.id }) @@ -28,7 +33,8 @@ export async function GET( .select() .from(tlsPatrons) .where(and(eq(tlsPatrons.talosId, id), eq(tlsPatrons.status, "active"))) - .orderBy(desc(tlsPatrons.createdAt)); + .orderBy(desc(tlsPatrons.createdAt)) + .limit(limit); return Response.json(patrons); } catch { diff --git a/web/src/app/api/talos/[id]/revenue/route.ts b/web/src/app/api/talos/[id]/revenue/route.ts index 557a78b3..f62a53da 100644 --- a/web/src/app/api/talos/[id]/revenue/route.ts +++ b/web/src/app/api/talos/[id]/revenue/route.ts @@ -4,6 +4,8 @@ import { tlsTalos, tlsRevenues } from "@/db/schema"; import { and, desc, eq, sql } from "drizzle-orm"; import { verifyAgentApiKey } from "@/lib/auth"; import { emitWebhookEvent } from "@/lib/webhooks/delivery"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; +import { withTraceContext } from "@/lib/tracing"; // GET /api/talos/:id/revenue — Get revenue history export async function GET( @@ -13,7 +15,9 @@ export async function GET( const { id } = await params; const { searchParams } = new URL(request.url); const cursor = searchParams.get("cursor"); - const limit = Math.min(Math.max(parseInt(searchParams.get("limit") ?? "50", 10) || 50, 1), 200); + const parsedLimit = parseAnalyticsLimit(searchParams.get("limit"), 50, 100); + if (!parsedLimit.ok) return parsedLimit.response; + const limit = parsedLimit.limit; try { const auth = await verifyAgentApiKey(request, id, ["revenue:read"]); diff --git a/web/src/lib/analytics-limits.ts b/web/src/lib/analytics-limits.ts new file mode 100644 index 00000000..66116ef2 --- /dev/null +++ b/web/src/lib/analytics-limits.ts @@ -0,0 +1,68 @@ +/** + * Shared validation and parsing helper for public analytics endpoint response limits. + * + * Rules: + * - Absent param (null | undefined) → returns defaultLimit + * - Non-empty string with non-digits (e.g. "abc", "1.5", "-1", "0", "") → 400 Bad Request + * - Values exceeding maxLimit → 400 Bad Request (explicit error response) + * - Valid positive integer within [1, maxLimit] → returns parsed limit + */ + +export interface AnalyticsLimitResult { + ok: true; + limit: number; +} + +export interface AnalyticsLimitError { + ok: false; + response: Response; +} + +export function parseAnalyticsLimit( + raw: string | null | undefined, + defaultLimit: number, + maxLimit: number, + paramName = "limit", +): AnalyticsLimitResult | AnalyticsLimitError { + // Absent param → use default + if (raw === null || raw === undefined) { + return { ok: true, limit: defaultLimit }; + } + + // Must be a non-empty string of digits only (no signs, decimal points, spaces) + if (!/^\d+$/.test(raw)) { + return { + ok: false, + response: Response.json( + { error: `${paramName} must be a positive integer` }, + { status: 400 }, + ), + }; + } + + const n = parseInt(raw, 10); + + // 0 is not a valid limit + if (n === 0) { + return { + ok: false, + response: Response.json( + { error: `${paramName} must be a positive integer` }, + { status: 400 }, + ), + }; + } + + // Exceeds route-configured max limit → return 400 + if (n > maxLimit) { + return { + ok: false, + response: Response.json( + { error: `${paramName} exceeds maximum allowed limit of ${maxLimit}` }, + { status: 400 }, + ), + }; + } + + return { ok: true, limit: n }; +} diff --git a/web/tests/analytics-limits.unit.test.ts b/web/tests/analytics-limits.unit.test.ts new file mode 100644 index 00000000..6ebf63f1 --- /dev/null +++ b/web/tests/analytics-limits.unit.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { parseAnalyticsLimit } from "@/lib/analytics-limits"; + +describe("parseAnalyticsLimit", () => { + describe("absent param → defaultLimit", () => { + it("returns defaultLimit when raw is null", () => { + const result = parseAnalyticsLimit(null, 25, 100); + expect(result).toEqual({ ok: true, limit: 25 }); + }); + + it("returns defaultLimit when raw is undefined", () => { + const result = parseAnalyticsLimit(undefined, 10, 50); + expect(result).toEqual({ ok: true, limit: 10 }); + }); + }); + + describe("valid positive integers up to maxLimit", () => { + const validCases: Array<[string, number, number, number]> = [ + ["1", 25, 100, 1], + ["25", 25, 100, 25], + ["100", 25, 100, 100], + ["50", 10, 50, 50], + ["10", 10, 50, 10], + ["5000", 5000, 10000, 5000], + ["10000", 5000, 10000, 10000], + ]; + + it.each(validCases)( + 'parseAnalyticsLimit("%s", %i, %i) → %i', + (raw, def, max, expected) => { + const result = parseAnalyticsLimit(raw, def, max); + expect(result).toEqual({ ok: true, limit: expected }); + }, + ); + }); + + describe("exceeds maxLimit → returns 400 validation error", () => { + const overLimitCases: Array<[string, number, number]> = [ + ["101", 25, 100], + ["500", 25, 100], + ["9999", 50, 100], + ["51", 10, 50], + ["10001", 5000, 10000], + ]; + + it.each(overLimitCases)( + 'parseAnalyticsLimit("%s", %i, %i) → 400 over limit', + async (raw, def, max) => { + const result = parseAnalyticsLimit(raw, def, max); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(400); + const body = await result.response.json(); + expect(body.error).toContain(`exceeds maximum allowed limit of ${max}`); + } + }, + ); + }); + + describe("zero, negative, and malformed inputs → returns 400", () => { + const invalidCases = [ + "0", + "-1", + "-50", + "abc", + "1.5", + "1.0", + "", + " ", + "1e2", + "NaN", + "Infinity", + "0x10", + " 10", + "10 ", + ]; + + it.each(invalidCases)('rejects "%s" with 400', async (raw) => { + const result = parseAnalyticsLimit(raw, 25, 100); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.response.status).toBe(400); + const body = await result.response.json(); + expect(body).toHaveProperty("error"); + expect(body.error).toBe("limit must be a positive integer"); + } + }); + }); + + describe("custom parameter names in error response", () => { + it("uses custom paramName in error messages", async () => { + const malformed = parseAnalyticsLimit("abc", 25, 100, "jobLimit"); + expect(malformed.ok).toBe(false); + if (!malformed.ok) { + expect(malformed.response.status).toBe(400); + const body = await malformed.response.json(); + expect(body.error).toBe("jobLimit must be a positive integer"); + } + + const overLimit = parseAnalyticsLimit("200", 25, 100, "jobLimit"); + expect(overLimit.ok).toBe(false); + if (!overLimit.ok) { + expect(overLimit.response.status).toBe(400); + const body = await overLimit.response.json(); + expect(body.error).toBe("jobLimit exceeds maximum allowed limit of 100"); + } + }); + }); +}); diff --git a/web/tests/analytics-route-limits.unit.test.ts b/web/tests/analytics-route-limits.unit.test.ts new file mode 100644 index 00000000..dc1ae2af --- /dev/null +++ b/web/tests/analytics-route-limits.unit.test.ts @@ -0,0 +1,251 @@ +/** + * Unit tests verifying response-size limits on public analytics and collection endpoints. + * + * Checks: + * 1. Default limit when limit query parameter is absent. + * 2. Custom valid limit accepted within supported maximum. + * 3. HTTP 400 validation error when requested limit exceeds maximum allowed limit. + * 4. HTTP 400 validation error on malformed, zero, or negative limit parameters. + * 5. Error responses do not expose internal database details. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +// ── Shared DB mock ───────────────────────────────────────────────────────────── +function buildSelectChain(rows: unknown[] = []) { + const obj: Record = {}; + const passthrough = ["from", "where", "orderBy", "leftJoin", "innerJoin", "groupBy", "as"]; + for (const m of passthrough) { + obj[m] = vi.fn(() => obj); + } + obj.limit = vi.fn(() => obj); + obj.then = vi.fn((onFulfilled: (v: unknown) => unknown) => + Promise.resolve(onFulfilled(rows)), + ); + return obj; +} + +const mocks = vi.hoisted(() => ({ + mockDb: { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + transaction: vi.fn(), + query: { + tlsTalos: { findFirst: vi.fn(), findMany: vi.fn() }, + tlsPatrons: { findMany: vi.fn() }, + tlsRevenues: { findMany: vi.fn() }, + tlsCommerceServices: { findMany: vi.fn() }, + tlsCommerceJobs: { findMany: vi.fn() }, + tlsPlaybooks: { findMany: vi.fn() }, + tlsPlaybookPurchases: { findMany: vi.fn() }, + tlsActivities: { findMany: vi.fn() }, + }, + }, + verifyAgentApiKey: vi.fn().mockResolvedValue({ ok: true, talos: { id: "test-agent", apiKey: "valid-key" } }), +})); + +vi.mock("@/db", () => ({ db: mocks.mockDb })); +vi.mock("@/lib/auth", () => ({ + verifyAgentApiKey: mocks.verifyAgentApiKey, +})); +// The analytics routes also import the webhook delivery engine (unrelated to +// response-size limits). Mock it so these tests stay isolated from that module. +vi.mock("@/lib/webhooks/delivery", () => ({ + emitWebhookEvent: vi.fn().mockResolvedValue(undefined), +})); + +function req(url: string, params: Record = {}): NextRequest { + const u = new URL(`http://localhost${url}`); + for (const [k, v] of Object.entries(params)) { + u.searchParams.set(k, v); + } + return new NextRequest(u.toString(), { + headers: { Authorization: "Bearer valid-key" }, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. GET /api/talos/:id/patrons +// ───────────────────────────────────────────────────────────────────────────── +import { GET as patronsGET } from "@/app/api/talos/[id]/patrons/route"; + +describe("GET /api/talos/:id/patrons — response-size limits", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockDb.select.mockReturnValue(buildSelectChain([{ id: "test-agent" }])); + }); + + it("returns 200 and uses defaultLimit=50 when limit is absent", async () => { + const res = await patronsGET(req("/api/talos/test-agent/patrons"), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(200); + }); + + it("returns 200 with valid custom limit=25", async () => { + const res = await patronsGET(req("/api/talos/test-agent/patrons", { limit: "25" }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(200); + }); + + it("returns 400 when limit exceeds maxLimit=100", async () => { + const res = await patronsGET(req("/api/talos/test-agent/patrons", { limit: "101" }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-5", "abc", "1.5", ""])("returns 400 for malformed limit=%s", async (val) => { + const res = await patronsGET(req("/api/talos/test-agent/patrons", { limit: val }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. GET /api/talos/:id/dividends +// ───────────────────────────────────────────────────────────────────────────── +import { GET as dividendsGET } from "@/app/api/talos/[id]/dividends/route"; + +describe("GET /api/talos/:id/dividends — response-size limits", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockDb.select.mockReturnValue(buildSelectChain([{ id: "test-agent" }])); + }); + + it("returns 200 with default limit=50", async () => { + const res = await dividendsGET(req("/api/talos/test-agent/dividends"), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(200); + }); + + it("returns 200 with valid limit=100 (maximum)", async () => { + const res = await dividendsGET(req("/api/talos/test-agent/dividends", { limit: "100" }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(200); + }); + + it("returns 400 when limit exceeds maxLimit=100", async () => { + const res = await dividendsGET(req("/api/talos/test-agent/dividends", { limit: "150" }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-1", "xyz", "2.2", ""])("returns 400 for malformed limit=%s", async (val) => { + const res = await dividendsGET(req("/api/talos/test-agent/dividends", { limit: val }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. GET /api/talos/:id/revenue +// ───────────────────────────────────────────────────────────────────────────── +import { GET as revenueGET } from "@/app/api/talos/[id]/revenue/route"; + +describe("GET /api/talos/:id/revenue — response-size limits", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockDb.select.mockReturnValue(buildSelectChain([{ id: "test-agent" }])); + }); + + it("returns 200 with default limit=50", async () => { + const res = await revenueGET(req("/api/talos/test-agent/revenue"), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(200); + }); + + it("returns 200 with valid limit=75", async () => { + const res = await revenueGET(req("/api/talos/test-agent/revenue", { limit: "75" }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(200); + }); + + it("returns 400 when limit exceeds maxLimit=100", async () => { + const res = await revenueGET(req("/api/talos/test-agent/revenue", { limit: "101" }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-10", "bad", "3.14", ""])("returns 400 for malformed limit=%s", async (val) => { + const res = await revenueGET(req("/api/talos/test-agent/revenue", { limit: val }), { + params: Promise.resolve({ id: "test-agent" }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. GET /api/proposals +// ───────────────────────────────────────────────────────────────────────────── +import { GET as proposalsGET } from "@/app/api/proposals/route"; + +describe("GET /api/proposals — response-size limits", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.mockDb.select.mockReturnValue(buildSelectChain([])); + }); + + it("returns 200 with default limit=50", async () => { + const res = await proposalsGET(req("/api/proposals")); + expect(res.status).toBe(200); + }); + + it("returns 200 with valid limit=10", async () => { + const res = await proposalsGET(req("/api/proposals", { limit: "10" })); + expect(res.status).toBe(200); + }); + + it("returns 400 when limit exceeds maxLimit=100", async () => { + const res = await proposalsGET(req("/api/proposals", { limit: "120" })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 100"); + }); + + it.each(["0", "-2", "invalid", "1.9", ""])("returns 400 for malformed limit=%s", async (val) => { + const res = await proposalsGET(req("/api/proposals", { limit: val })); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Error responses do not expose internal DB details +// ───────────────────────────────────────────────────────────────────────────── +describe("Error response detail hiding", () => { + it("does not leak sql or postgres errors in GET /api/proposals on DB failure", async () => { + mocks.mockDb.select.mockImplementation(() => { + throw new Error("FATAL: connection to postgres failed at pg_hba.conf"); + }); + + const res = await proposalsGET(req("/api/proposals")); + expect(res.status).toBe(500); + const body = await res.json(); + expect(JSON.stringify(body)).not.toMatch(/postgres|pg_hba|sql|FATAL/i); + }); +}); diff --git a/web/tests/ecosystem-intelligence.test.ts b/web/tests/ecosystem-intelligence.test.ts index 706197f2..07783a6d 100644 --- a/web/tests/ecosystem-intelligence.test.ts +++ b/web/tests/ecosystem-intelligence.test.ts @@ -758,4 +758,18 @@ describe("GET /api/ecosystem-intelligence", () => { expect(body.demand.byCategory).toEqual({ marketing: 1 }); expect(body.metadata.privacy.deduplicatedRows).toBe(49); }); + + it("returns 400 when limit exceeds maximum allowed limit of 50", async () => { + const res = await GET(new Request("http://localhost/api/ecosystem-intelligence?limit=100")); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toContain("exceeds maximum allowed limit of 50"); + }); + + it.each(["0", "-1", "abc", "1.5", ""])("returns 400 for malformed limit=%s", async (val) => { + const res = await GET(new Request(`http://localhost/api/ecosystem-intelligence?limit=${val}`)); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("limit must be a positive integer"); + }); });