diff --git a/apps/logicsrc-web/contract/ontology-api.contract.test.ts b/apps/logicsrc-web/contract/ontology-api.contract.test.ts index b4cf725..53ffeac 100644 --- a/apps/logicsrc-web/contract/ontology-api.contract.test.ts +++ b/apps/logicsrc-web/contract/ontology-api.contract.test.ts @@ -102,32 +102,6 @@ describe("reads", () => { expect(payload.total).toBeGreaterThan(3); }); - it("falls back safely for negative and malformed pagination values", async () => { - const entityResponse = await listEntities( - request(`/api/ontologies/${ONTOLOGY}/entities?limit=-1&offset=invalid`), - params({ ontologyId: ONTOLOGY }) - ); - const entityPayload = await body<{ limit: number; offset: number }>(entityResponse); - expect(entityPayload.limit).toBe(50); - expect(entityPayload.offset).toBe(0); - - const claimResponse = await listClaims( - request(`/api/ontologies/${ONTOLOGY}/claims?limit=1.5`), - params({ ontologyId: ONTOLOGY }) - ); - expect((await body<{ limit: number }>(claimResponse)).limit).toBe(100); - - const defaultEvents = await events( - request(`/api/ontologies/${ONTOLOGY}/events`), - params({ ontologyId: ONTOLOGY }) - ); - const invalidEvents = await events( - request(`/api/ontologies/${ONTOLOGY}/events?limit=-1`), - params({ ontologyId: ONTOLOGY }) - ); - expect(await body(invalidEvents)).toEqual(await body(defaultEvents)); - }); - it("returns ranked matches with evidence when searching", async () => { const response = await listEntities( request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`), diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts index e98d479..f43ff23 100644 --- a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts @@ -1,5 +1,4 @@ import { handle, apiJson } from "@/lib/ontology-service"; -import { parseBoundedIntegerParam } from "@/lib/pagination"; export const dynamic = "force-dynamic"; @@ -7,7 +6,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto const { ontologyId } = await params; const url = new URL(request.url); const statusParam = url.searchParams.get("status") ?? "asserted"; - const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500); + const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500); return handle(request, ontologyId, (engine) => { const claims = engine.store.listClaims({ diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts index 8511661..6156a65 100644 --- a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts @@ -1,5 +1,4 @@ import { handle, apiJson } from "@/lib/ontology-service"; -import { parseBoundedIntegerParam } from "@/lib/pagination"; export const dynamic = "force-dynamic"; @@ -9,8 +8,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto const url = new URL(request.url); const type = url.searchParams.get("type") ?? undefined; const q = url.searchParams.get("q") ?? undefined; - const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 50, 1, 200); - const offset = parseBoundedIntegerParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER); + const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200); + const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0); return handle(request, ontologyId, (engine) => { if (q) { diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts index 3b7d45a..c56a5c9 100644 --- a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts @@ -1,5 +1,4 @@ import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service"; -import { parseBoundedIntegerParam } from "@/lib/pagination"; export const dynamic = "force-dynamic"; @@ -28,7 +27,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto const engine = bound.engine; if (!wantsStream) { - const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500); + const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500); return apiJson({ events: engine.listEvents({ limit }) }); } diff --git a/apps/logicsrc-web/src/lib/pagination.test.ts b/apps/logicsrc-web/src/lib/pagination.test.ts deleted file mode 100644 index 77ce56d..0000000 --- a/apps/logicsrc-web/src/lib/pagination.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, test } from "node:test"; -import assert from "node:assert/strict"; -import { parseBoundedIntegerParam } from "./pagination.ts"; - -describe("pagination parameters", () => { - test("uses the fallback for negative, fractional, and malformed values", () => { - for (const value of [null, "-1", "1.5", "invalid", "9007199254740992"]) { - assert.equal(parseBoundedIntegerParam(value, 50, 1, 200), 50); - } - }); - - test("accepts valid values and caps the upper bound", () => { - assert.equal(parseBoundedIntegerParam(" 1 ", 50, 1, 200), 1); - assert.equal(parseBoundedIntegerParam("500", 50, 1, 200), 200); - assert.equal(parseBoundedIntegerParam("0", 0, 0, Number.MAX_SAFE_INTEGER), 0); - }); -}); diff --git a/apps/logicsrc-web/src/lib/pagination.ts b/apps/logicsrc-web/src/lib/pagination.ts deleted file mode 100644 index 931cb86..0000000 --- a/apps/logicsrc-web/src/lib/pagination.ts +++ /dev/null @@ -1,13 +0,0 @@ -export function parseBoundedIntegerParam( - value: string | null, - fallback: number, - minimum: number, - maximum: number -): number { - const text = value?.trim() ?? ""; - if (!/^\d+$/.test(text)) return fallback; - - const parsed = Number(text); - if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback; - return Math.min(parsed, maximum); -} diff --git a/packages/opencontext/src/adapters/http.test.ts b/packages/opencontext/src/adapters/http.test.ts deleted file mode 100644 index 8995e75..0000000 --- a/packages/opencontext/src/adapters/http.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { httpAdapter } from "./http.js"; - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -describe("http adapter response limits", () => { - it("enforces the byte limit for multibyte responses without a content-length header", async () => { - const content = "é".repeat(3 * 1024 * 1024); - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(content))); - - await expect( - httpAdapter.load("https://example.com/context.md", { - dir: process.cwd(), - offline: false, - config: {} - }) - ).rejects.toThrow(/response exceeds the 5242880 byte limit/); - }); - - it("decodes responses within the byte limit", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("café"))); - - await expect( - httpAdapter.load("https://example.com/context.md", { - dir: process.cwd(), - offline: false, - config: {} - }) - ).resolves.toMatchObject({ content: "café", trust: "untrusted" }); - }); -}); diff --git a/packages/opencontext/src/adapters/http.ts b/packages/opencontext/src/adapters/http.ts index a8e7114..e900547 100644 --- a/packages/opencontext/src/adapters/http.ts +++ b/packages/opencontext/src/adapters/http.ts @@ -20,38 +20,6 @@ export class OfflineError extends Error { const DEFAULT_TIMEOUT_MS = 10_000; const MAX_BYTES = 5 * 1024 * 1024; -async function readBody(response: Response, uri: string): Promise { - if (!response.body) return ""; - - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let totalBytes = 0; - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - totalBytes += value.byteLength; - if (totalBytes > MAX_BYTES) { - await reader.cancel().catch(() => undefined); - throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`); - } - chunks.push(value); - } - } finally { - reader.releaseLock(); - } - - const bytes = new Uint8Array(totalBytes); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - return new TextDecoder().decode(bytes); -} - export const httpAdapter: Adapter = { name: "http", schemes: ["http", "https"], @@ -71,40 +39,41 @@ export const httpAdapter: Adapter = { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); + let response: Response; try { - let response: Response; - try { - response = await fetch(url, { - signal: controller.signal, - // Redirects can move a request to a host the author never named, so the - // final URL is reported back rather than followed silently. - redirect: "follow", - headers: { accept: "text/markdown, text/plain, application/json;q=0.9, */*;q=0.8" } - }); - } catch (error) { - throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`); - } - - if (!response.ok) { - throw new Error(`Failed to fetch ${uri}: HTTP ${response.status} ${response.statusText}`); - } - - const declaredLength = Number(response.headers.get("content-length") ?? "0"); - if (declaredLength > MAX_BYTES) { - throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`); - } - - const content = await readBody(response, uri); - - return { - content, - contentType: (response.headers.get("content-type") ?? "text/plain").split(";")[0]!.trim(), - digest: sha256Uri(content), - retrievedAt: new Date().toISOString(), - trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted" - }; + response = await fetch(url, { + signal: controller.signal, + // Redirects can move a request to a host the author never named, so the + // final URL is reported back rather than followed silently. + redirect: "follow", + headers: { accept: "text/markdown, text/plain, application/json;q=0.9, */*;q=0.8" } + }); + } catch (error) { + throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`); } finally { clearTimeout(timer); } + + if (!response.ok) { + throw new Error(`Failed to fetch ${uri}: HTTP ${response.status} ${response.statusText}`); + } + + const declaredLength = Number(response.headers.get("content-length") ?? "0"); + if (declaredLength > MAX_BYTES) { + throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`); + } + + const content = await response.text(); + if (content.length > MAX_BYTES) { + throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`); + } + + return { + content, + contentType: (response.headers.get("content-type") ?? "text/plain").split(";")[0]!.trim(), + digest: sha256Uri(content), + retrievedAt: new Date().toISOString(), + trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted" + }; } };