mirror of
https://github.com/profullstack/logicsrc.git
synced 2026-08-13 14:37:26 +00:00
Compare commits
2 commits
opencontex
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1cfec322ac | ||
|
|
edf9a1b063 |
8 changed files with 159 additions and 36 deletions
|
|
@ -102,6 +102,32 @@ 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`),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { handle, apiJson } from "@/lib/ontology-service";
|
||||
import { parseBoundedIntegerParam } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -6,7 +7,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 = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
|
||||
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500);
|
||||
|
||||
return handle(request, ontologyId, (engine) => {
|
||||
const claims = engine.store.listClaims({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { handle, apiJson } from "@/lib/ontology-service";
|
||||
import { parseBoundedIntegerParam } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -8,8 +9,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 = Math.min(Number(url.searchParams.get("limit") ?? 50), 200);
|
||||
const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0);
|
||||
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 50, 1, 200);
|
||||
const offset = parseBoundedIntegerParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
return handle(request, ontologyId, (engine) => {
|
||||
if (q) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service";
|
||||
import { parseBoundedIntegerParam } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
|
@ -27,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto
|
|||
const engine = bound.engine;
|
||||
|
||||
if (!wantsStream) {
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500);
|
||||
const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500);
|
||||
return apiJson({ events: engine.listEvents({ limit }) });
|
||||
}
|
||||
|
||||
|
|
|
|||
17
apps/logicsrc-web/src/lib/pagination.test.ts
Normal file
17
apps/logicsrc-web/src/lib/pagination.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
13
apps/logicsrc-web/src/lib/pagination.ts
Normal file
13
apps/logicsrc-web/src/lib/pagination.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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);
|
||||
}
|
||||
33
packages/opencontext/src/adapters/http.test.ts
Normal file
33
packages/opencontext/src/adapters/http.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,38 @@ 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<string> {
|
||||
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"],
|
||||
|
|
@ -39,41 +71,40 @@ export const httpAdapter: Adapter = {
|
|||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
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}`);
|
||||
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"
|
||||
};
|
||||
} 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"
|
||||
};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue