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
49 changes: 38 additions & 11 deletions web/app/api/history/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { BodyTooLargeError, readJsonWithLimit } from "@/lib/body-limit";

// In-memory store for local development (will be replaced with Vercel KV in production)
const memoryHistory = new Map<string, HistoryEntry[]>();
Expand Down Expand Up @@ -89,22 +90,48 @@ function hasOverlap(a: string[] = [], b: string[] = []): boolean {
export async function POST(request: NextRequest) {
const sessionId = getSessionId(request);

let body: unknown;
try {
const body = await request.json();
body = await readJsonWithLimit(request);
} catch (error) {
if (error instanceof BodyTooLargeError) {
return NextResponse.json(
{ error: `Request body exceeds ${error.limit} bytes` },
{ status: 413 }
);
}
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

try {
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const bodyRecord = body as Record<string, unknown>;
const id = crypto.randomUUID();
const normalizedUrlHashes = normalizeHashes(body.normalizedUrlHashes);
const normalizedUrlHashes = normalizeHashes(bodyRecord.normalizedUrlHashes);
const flagsObj = (bodyRecord.flags && typeof bodyRecord.flags === "object")
? bodyRecord.flags as Record<string, unknown>
: null;
const flags: HistoryEntry["flags"] | undefined = flagsObj
? {
...(typeof flagsObj.skipCache === "boolean" ? { skipCache: flagsObj.skipCache } : {}),
...(typeof flagsObj.deepResearch === "boolean" ? { deepResearch: flagsObj.deepResearch } : {}),
}
: undefined;

const entry: HistoryEntry = {
id,
query: body.query || "",
url: body.url || null,
result: body.result || "",
provider: body.provider || "unknown",
query: typeof bodyRecord.query === "string" ? bodyRecord.query : "",
url: typeof bodyRecord.url === "string" ? bodyRecord.url : null,
result: typeof bodyRecord.result === "string" ? bodyRecord.result : "",
provider: typeof bodyRecord.provider === "string" ? bodyRecord.provider : "unknown",
timestamp: Date.now(),
charCount: body.charCount || 0,
resolveTime: body.resolveTime || 0,
profile: body.profile || undefined,
flags: body.flags || undefined,
providers: Array.isArray(body.providers) ? body.providers : undefined,
charCount: typeof bodyRecord.charCount === "number" ? bodyRecord.charCount : 0,
resolveTime: typeof bodyRecord.resolveTime === "number" ? bodyRecord.resolveTime : 0,
...(typeof bodyRecord.profile === "string" ? { profile: bodyRecord.profile } : {}),
...(flags ? { flags } : {}),
...(Array.isArray(bodyRecord.providers) ? { providers: bodyRecord.providers.filter((p): p is string => typeof p === "string") } : {}),
normalizedUrlHashes,
};

Expand Down
29 changes: 21 additions & 8 deletions web/app/api/resolve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Logger } from "@/lib/log";
import { searchViaExaMcpWithMistral } from "@/lib/resolvers/query";
import { isUrl, queryProviders, urlProviders, ProviderKeys } from "@/lib/resolvers/index";
import { validateResolveRequest, validateUrlForFetchAsync } from "@/lib/validation";
import { BodyTooLargeError, readJsonWithLimit } from "@/lib/body-limit";

// Allow up to 60 seconds for resolver operations
export const maxDuration = 60;
Expand Down Expand Up @@ -210,7 +211,19 @@ export async function POST(request: NextRequest) {
// counter twice, halving the effective limit to 15 req/min.
const log = new Logger();
try {
const body = await request.json();
let body: unknown;
try {
body = await readJsonWithLimit(request);
} catch (error) {
if (error instanceof BodyTooLargeError) {
return NextResponse.json(
{ error: `Request body exceeds ${error.limit} bytes` },
{ status: 413 }
);
}
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

const validation = validateResolveRequest(body);
if (!validation.success || !validation.data) {
return NextResponse.json({ error: validation.error || "Invalid request" }, { status: 400 });
Expand Down Expand Up @@ -250,13 +263,13 @@ export async function POST(request: NextRequest) {
const budget = new ResolutionBudget(profile);

// Extract optional user-provided API keys from request body
const userKeys: ProviderKeys = {
SERPER_API_KEY: body.serper_api_key,
TAVILY_API_KEY: body.tavily_api_key,
EXA_API_KEY: body.exa_api_key,
FIRECRAWL_API_KEY: body.firecrawl_api_key,
MISTRAL_API_KEY: body.mistral_api_key,
};
const bodyRecord = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
const userKeys: ProviderKeys = {};
if (typeof bodyRecord.serper_api_key === "string") userKeys.SERPER_API_KEY = bodyRecord.serper_api_key;
if (typeof bodyRecord.tavily_api_key === "string") userKeys.TAVILY_API_KEY = bodyRecord.tavily_api_key;
if (typeof bodyRecord.exa_api_key === "string") userKeys.EXA_API_KEY = bodyRecord.exa_api_key;
if (typeof bodyRecord.firecrawl_api_key === "string") userKeys.FIRECRAWL_API_KEY = bodyRecord.firecrawl_api_key;
if (typeof bodyRecord.mistral_api_key === "string") userKeys.MISTRAL_API_KEY = bodyRecord.mistral_api_key;

let markdown: string;
let provider: string;
Expand Down
16 changes: 14 additions & 2 deletions web/app/api/ui-state/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { BodyTooLargeError, readJsonWithLimit } from "@/lib/body-limit";

// In-memory store for UI state (sessionId -> UIState with TTL)
interface StoredState {
Expand Down Expand Up @@ -66,9 +67,20 @@ export async function GET(request: NextRequest) {

// POST endpoint: Save UI state
export async function POST(request: NextRequest) {
let body: unknown;
try {
body = await readJsonWithLimit(request);
} catch (error) {
if (error instanceof BodyTooLargeError) {
return NextResponse.json(
{ error: `Request body exceeds ${error.limit} bytes` },
{ status: 413 }
);
}
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}

try {
const body = await request.json();

if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid state payload" }, { status: 400 });
}
Expand Down
79 changes: 79 additions & 0 deletions web/tests/api/history-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";

import { DELETE, GET, POST } from "../../app/api/history/route";
import { MAX_BODY_BYTES } from "../../lib/body-limit";

describe("/api/history route", () => {
it("rejects an oversized body with 413 Payload Too Large", async () => {
const postReq = new NextRequest("http://localhost/api/history", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query: "test", padding: "x".repeat(MAX_BODY_BYTES + 64) }),
});

const postRes = await POST(postReq);
expect(postRes.status).toBe(413);
const data = await postRes.json();
expect(data.error).toContain("Request body exceeds");
});

it("saves and retrieves history entries", async () => {
const postReq = new NextRequest("http://localhost/api/history", {
method: "POST",
headers: {
"content-type": "application/json",
cookie: "ui-session=test-history-session",
},
body: JSON.stringify({
query: "rust async tutorial",
result: "# Rust Async",
provider: "jina",
}),
});

const postRes = await POST(postReq);
expect(postRes.status).toBe(200);
const postData = await postRes.json();
expect(postData.ok).toBe(true);
expect(postData.id).toBeDefined();

const getReq = new NextRequest("http://localhost/api/history", {
headers: { cookie: "ui-session=test-history-session" },
});

const getRes = await GET(getReq);
expect(getRes.status).toBe(200);
const getData = await getRes.json();
expect(getData.entries.length).toBeGreaterThanOrEqual(1);
expect(getData.entries[0].query).toBe("rust async tutorial");
});

it("deletes a history entry by id", async () => {
const postReq = new NextRequest("http://localhost/api/history", {
method: "POST",
headers: {
"content-type": "application/json",
cookie: "ui-session=test-delete-session",
},
body: JSON.stringify({ query: "to be deleted" }),
});

const postRes = await POST(postReq);
const { id } = await postRes.json();

const deleteReq = new NextRequest(`http://localhost/api/history?id=${id}`, {
method: "DELETE",
headers: { cookie: "ui-session=test-delete-session" },
});

const deleteRes = await DELETE(deleteReq);
expect(deleteRes.status).toBe(200);

const getReq = new NextRequest("http://localhost/api/history", {
headers: { cookie: "ui-session=test-delete-session" },
});
const getData = await (await GET(getReq)).json();
expect(getData.entries.find((e: { id: string }) => e.id === id)).toBeUndefined();
});
});
48 changes: 48 additions & 0 deletions web/tests/api/resolve-body-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";

vi.mock("../../lib/routing", () => ({
ResolutionBudget: vi.fn().mockImplementation(() => ({
canTry: vi.fn().mockReturnValue(true),
recordAttempt: vi.fn(),
getState: vi.fn().mockReturnValue({}),
})),
planProviderOrder: vi.fn().mockReturnValue(["jina"]),
detectJsHeavy: vi.fn().mockReturnValue(false),
isPaidProvider: vi.fn().mockReturnValue(false),
}));

vi.mock("../../lib/validation", () => ({
validateResolveRequest: vi.fn().mockImplementation((body) => ({
success: true,
data: body,
})),
validateUrlForFetchAsync: vi.fn().mockResolvedValue({ valid: true }),
}));

vi.mock("../../lib/cache", () => ({
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue(true),
}));

vi.mock("../../lib/records", () => ({
save: vi.fn(),
}));

import { POST } from "../../app/api/resolve/route";
import { MAX_BODY_BYTES } from "../../lib/body-limit";

describe("POST /api/resolve body limit", () => {
it("rejects an oversized body with 413 Payload Too Large", async () => {
const postReq = new NextRequest("http://localhost/api/resolve", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query: "test", padding: "x".repeat(MAX_BODY_BYTES + 64) }),
});

const postRes = await POST(postReq);
expect(postRes.status).toBe(413);
const data = await postRes.json();
expect(data.error).toContain("Request body exceeds");
});
});
14 changes: 14 additions & 0 deletions web/tests/api/ui-state-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,22 @@ import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";

import { GET, POST } from "../../app/api/ui-state/route";
import { MAX_BODY_BYTES } from "../../lib/body-limit";

describe("/api/ui-state route", () => {
it("rejects an oversized body with 413 Payload Too Large", async () => {
const postReq = new NextRequest("http://localhost/api/ui-state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ padding: "x".repeat(MAX_BODY_BYTES + 64) }),
});

const postRes = await POST(postReq);
expect(postRes.status).toBe(413);
const data = await postRes.json();
expect(data.error).toContain("Request body exceeds");
});

it("stores and restores custom profile provider selection", async () => {
const payload = {
activeProfile: "custom",
Expand Down
Loading