diff --git a/web/src/app/api/activity/route.ts b/web/src/app/api/activity/route.ts index 8b78e3ac..53039534 100644 --- a/web/src/app/api/activity/route.ts +++ b/web/src/app/api/activity/route.ts @@ -5,6 +5,7 @@ import { InvalidActivityCursorError, } from "./query"; import { parseLimit } from "@/lib/parse-limit"; +import { internalError, badRequest } from "@/lib/api-response"; export const dynamic = "force-dynamic"; @@ -21,7 +22,7 @@ export async function GET(request: Request) { decodeActivityCursor(cursor); } catch (error) { if (error instanceof InvalidActivityCursorError) { - return Response.json({ error: "Invalid cursor" }, { status: 400 }); + return badRequest(request, "Invalid cursor"); } throw error; } @@ -40,9 +41,6 @@ export async function GET(request: Request) { return Response.json({ stats, transactions, nextCursor }); } catch { - return Response.json( - { error: "An unexpected error occurred" }, - { status: 500 }, - ); + return internalError(request); } } 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/leaderboard/route.ts b/web/src/app/api/leaderboard/route.ts index d208b1b3..cfb7a5fb 100644 --- a/web/src/app/api/leaderboard/route.ts +++ b/web/src/app/api/leaderboard/route.ts @@ -3,6 +3,7 @@ import { db } from "@/db"; import { tlsTalos, tlsPatrons, tlsActivities, tlsRevenues } from "@/db/schema"; import { and, desc, eq, sql } from "drizzle-orm"; import { parseLimit } from "@/lib/parse-limit"; +import { internalError, badRequest } from "@/lib/api-response"; // GET /api/leaderboard — Ranking data with cursor-based pagination export async function GET(request: NextRequest) { 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/services/route.ts b/web/src/app/api/services/route.ts index 8dd1201e..16151fab 100644 --- a/web/src/app/api/services/route.ts +++ b/web/src/app/api/services/route.ts @@ -5,6 +5,7 @@ import { and, desc, eq, ilike, lt, ne, or } from "drizzle-orm"; import { parseLimit } from "@/lib/parse-limit"; import { fetchReputations } from "@/lib/reputation-ledger"; import { withTraceContext } from "@/lib/tracing"; +import { internalError } from "@/lib/api-response"; // GET /api/services — Discover available services across all TALOS agents async function handleGet(request: NextRequest) { 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/app/api/talos/[id]/route.ts b/web/src/app/api/talos/[id]/route.ts index 8b74c384..ab7a9289 100644 --- a/web/src/app/api/talos/[id]/route.ts +++ b/web/src/app/api/talos/[id]/route.ts @@ -3,6 +3,7 @@ import { db } from "@/db"; import { tlsTalos } from "@/db/schema"; import { eq } from "drizzle-orm"; import { internalError, notFound } from "@/lib/api-response"; +import { withTraceContext } from "@/lib/tracing"; function maskApiKey(key: string | null): string | null { if (!key || key.length < 12) return null; @@ -10,7 +11,7 @@ function maskApiKey(key: string | null): string | null { } // GET /api/talos/:id — TALOS detail + configuration -export async function GET( +async function handleGet( request: Request, { params }: { params: Promise<{ id: string }> } ) { @@ -40,3 +41,4 @@ export async function GET( } export const GET = withTraceContext(handleGet); + diff --git a/web/src/app/api/talos/route.ts b/web/src/app/api/talos/route.ts index af1cc953..65adb9f5 100644 --- a/web/src/app/api/talos/route.ts +++ b/web/src/app/api/talos/route.ts @@ -9,6 +9,9 @@ import { createTalosSchema, parseBody } from "@/lib/schemas"; import { parseLimit } from "@/lib/parse-limit"; import { TimeoutError, withTimeout } from "@/lib/timeout"; import { fetchReputations } from "@/lib/reputation-ledger"; +import { withDriftDetection } from "@/lib/drift"; +import "@/lib/drift-schemas"; +import { internalError } from "@/lib/api-response"; // GET /api/talos — List TALOS entries with cursor-based pagination export async function GET(request: NextRequest) { diff --git a/web/src/db/relations.ts b/web/src/db/relations.ts index 16f8bde8..43ab9daa 100644 --- a/web/src/db/relations.ts +++ b/web/src/db/relations.ts @@ -14,6 +14,8 @@ import { tlsTokenPurchases, tlsBenchmarkRuns, tlsBenchmarkResults, + tlsWebhookSubscriptions, + tlsWebhookDeliveries, } from "./schema"; export const talosRelations = relations(tlsTalos, ({ many, one }) => ({ diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts index 3bf216bb..89df6213 100644 --- a/web/src/db/schema.ts +++ b/web/src/db/schema.ts @@ -712,3 +712,122 @@ export const tlsReputationInputs = pgTable( index("tls_reputation_inputs_talosId_requester_idx").on(t.talosId, t.requesterTalosId), ], ); + +// ─── Webhook Subscriptions ──────────────────────────────────────── +export const tlsWebhookSubscriptions = pgTable( + "tls_webhook_subscriptions", + { + id: text("id").primaryKey().$defaultFn(() => createId()), + talosId: text("talos_id").notNull().references(() => tlsTalos.id, { onDelete: "cascade" }), + url: text("url").notNull(), + secretCiphertext: text("secret_ciphertext").notNull(), + signatureVersion: integer("signature_version").notNull().default(1), + eventTypes: text("event_types").array().notNull().default([]), + description: text("description"), + active: boolean("active").notNull().default(true), + + createdAt: timestamp("created_at", { mode: "date", precision: 3 }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { mode: "date", precision: 3 }).notNull().$onUpdate(() => new Date()), + }, + (t) => [ + index("tls_webhook_subscriptions_talos_id_idx").on(t.talosId), + ], +); + +// ─── Webhook Deliveries ──────────────────────────────────────────── +export const tlsWebhookDeliveries = pgTable( + "tls_webhook_deliveries", + { + id: text("id").primaryKey().$defaultFn(() => createId()), + subscriptionId: text("subscription_id").notNull().references(() => tlsWebhookSubscriptions.id, { onDelete: "cascade" }), + eventType: text("event_type").notNull(), + payloadHash: text("payload_hash").notNull(), + status: text("status").notNull().default("pending"), + attempts: integer("attempts").notNull().default(0), + maxAttempts: integer("max_attempts").notNull().default(5), + lastStatusCode: integer("last_status_code"), + lastError: text("last_error"), + lastAttemptAt: timestamp("last_attempt_at", { mode: "date", precision: 3 }), + nextAttemptAt: timestamp("next_attempt_at", { mode: "date", precision: 3 }), + completedAt: timestamp("completed_at", { mode: "date", precision: 3 }), + responseBody: text("response_body"), + + leasedBy: text("leased_by"), + leasedAt: timestamp("leased_at", { mode: "date", precision: 3 }), + leaseExpiresAt: timestamp("lease_expires_at", { mode: "date", precision: 3 }), + fencingToken: integer("fencing_token").notNull().default(0), + + createdAt: timestamp("created_at", { mode: "date", precision: 3 }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex("tls_webhook_deliveries_sub_id_payload_hash_unique").on(t.subscriptionId, t.payloadHash), + index("tls_webhook_deliveries_pending_idx").on(t.nextAttemptAt, t.status) + .where(sql`${t.status} = ANY(ARRAY['pending', 'failed'])`), + ], +); + +// ─── Transactional Outbox for Domain Events ─────────────────────── +export const tlsOutboxEvents = pgTable( + "tls_outbox_events", + { + id: text("id").primaryKey().$defaultFn(() => createId()), + aggregateType: text("aggregateType").notNull(), + aggregateId: text("aggregateId").notNull(), + eventType: text("eventType").notNull(), + payload: jsonb("payload").default({}).notNull(), + status: text("status").default("pending").notNull(), + runAt: timestamp("runAt", { mode: "date", precision: 3 }).default(sql`now()`).notNull(), + leaseId: text("leaseId"), + leaseOwner: text("leaseOwner"), + leaseExpiresAt: timestamp("leaseExpiresAt", { mode: "date", precision: 3 }), + attempts: integer("attempts").default(0).notNull(), + maxAttempts: integer("maxAttempts").default(8).notNull(), + dedupeKey: text("dedupeKey"), + lastError: text("lastError"), + createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).default(sql`now()`).notNull(), + updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 }).notNull().$onUpdate(() => new Date()), + dispatchedAt: timestamp("dispatchedAt", { mode: "date", precision: 3 }), + }, + (table) => [ + index("tls_outbox_events_status_runAt_idx").on(table.status, table.runAt), + index("tls_outbox_events_eventType_status_idx").on(table.eventType, table.status), + index("tls_outbox_events_leaseExpiresAt_idx").on(table.leaseExpiresAt), + index("tls_outbox_events_dispatchedAt_idx").on(table.dispatchedAt), + uniqueIndex("tls_outbox_events_eventType_dedupeKey_unique").on(table.eventType, table.dedupeKey), + ], +); + +// ─── Background Jobs ────────────────────────────────────────────── +export const tlsJobs = pgTable( + "tls_jobs", + { + id: text("id").primaryKey().$defaultFn(() => createId()), + queue: text("queue").notNull(), + payload: jsonb("payload").default({}).notNull(), + status: text("status").default("pending").notNull(), + priority: integer("priority").default(0).notNull(), + runAt: timestamp("runAt", { mode: "date", precision: 3 }).default(sql`now()`).notNull(), + leaseId: text("leaseId"), + leaseOwner: text("leaseOwner"), + leaseExpiresAt: timestamp("leaseExpiresAt", { mode: "date", precision: 3 }), + heartbeatAt: timestamp("heartbeatAt", { mode: "date", precision: 3 }), + attempts: integer("attempts").default(0).notNull(), + maxAttempts: integer("maxAttempts").default(8).notNull(), + retryClass: text("retryClass").default("transient").notNull(), + cancelRequested: boolean("cancelRequested").default(false).notNull(), + idempotencyKey: text("idempotencyKey"), + lastError: text("lastError"), + result: jsonb("result"), + createdAt: timestamp("createdAt", { mode: "date", precision: 3 }).default(sql`now()`).notNull(), + updatedAt: timestamp("updatedAt", { mode: "date", precision: 3 }).notNull().$onUpdate(() => new Date()), + completedAt: timestamp("completedAt", { mode: "date", precision: 3 }), + }, + (table) => [ + index("tls_jobs_status_runAt_idx").on(table.status, table.runAt), + index("tls_jobs_queue_status_idx").on(table.queue, table.status), + index("tls_jobs_leaseExpiresAt_idx").on(table.leaseExpiresAt), + uniqueIndex("tls_jobs_queue_idempotencyKey_unique").on(table.queue, table.idempotencyKey), + ], +); + + diff --git a/web/src/lib/admin-auth.ts b/web/src/lib/admin-auth.ts index 6c26fe81..930a7448 100644 --- a/web/src/lib/admin-auth.ts +++ b/web/src/lib/admin-auth.ts @@ -67,3 +67,10 @@ export function verifyInternalSecret( return { ok: true }; } + +export function verifyInternalJobsSecret( + request: NextRequest, +): { ok: true } | { ok: false; response: Response } { + return verifyInternalSecret(request, "INTERNAL_JOBS_SECRET", "x-internal-jobs-secret"); +} + 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..1458af3c --- /dev/null +++ b/web/tests/analytics-route-limits.unit.test.ts @@ -0,0 +1,246 @@ +/** + * 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, +})); + +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"); + }); }); diff --git a/web/tests/reconciler-state-machine.unit.test.ts b/web/tests/reconciler-state-machine.unit.test.ts index ab07e863..4f8f8c5d 100644 --- a/web/tests/reconciler-state-machine.unit.test.ts +++ b/web/tests/reconciler-state-machine.unit.test.ts @@ -433,7 +433,7 @@ describe("State machine edge cases", () => { }); it("handles a record with null confirmedLedger in CONFIRMING (stays CONFIRMING)", () => { - const rec = makeRecord({ finalityStatus: "CONFIRMING", confirmedLedger: null }); + const rec = makeRecord({ finalityStatus: "CONFIRMING", confirmedLedger: null, ledgerSubmitted: null }); const update = transitionFromConfirming(rec, 9999, BASE_CONFIG); // confirmedLedger is null → can't compute depth → just update checkpoint expect(update?.finalityStatus).toBeUndefined();