From 8198434c51b115ad9eda9e5c46713982d79cc879 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:45:44 +0800 Subject: [PATCH 1/3] fix(api): defensive boundary hardening and zod schema guards (#342, #343, #212) --- src/app/api/search/route.ts | 10 + src/app/api/search/universal/route.ts | 21 +- .../clinical-dashboard/guide-progress.ts | 26 +- src/lib/api-client-error.ts | 45 +- src/lib/document-detail.ts | 3 +- src/lib/private-search-scope.ts | 25 +- src/lib/rag/rag-row-contracts.ts | 17 +- src/lib/service-catalog-mapper.ts | 2 +- src/lib/universal-search-stream.ts | 75 ++- src/lib/validation/query.ts | 17 +- tests/api-search.test.ts | 454 ++++++++++++++++++ tests/rag-retrieval-row-contract.test.ts | 17 + 12 files changed, 654 insertions(+), 58 deletions(-) create mode 100644 tests/api-search.test.ts diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 6795da8bfa..92fb882c0b 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -1064,6 +1064,9 @@ export async function POST(request: Request) { if (error instanceof PublicApiError) { return jsonError(error, error.status); } + if (error instanceof SyntaxError || error instanceof URIError) { + return jsonError(new PublicApiError("Invalid search request.", 400, { code: "invalid_request" }), 400); + } if (error instanceof Error && error.message.trim()) { const code = classifySearchFailure(error); const fallbackBody = body; @@ -1111,3 +1114,10 @@ export async function POST(request: Request) { return jsonError(error, 500); } } + +export async function GET() { + return jsonError( + new PublicApiError("Method Not Allowed. Search requires a POST request.", 405, { code: "method_not_allowed" }), + 405, + ); +} diff --git a/src/app/api/search/universal/route.ts b/src/app/api/search/universal/route.ts index ed17b2a63d..3355ca27e4 100644 --- a/src/app/api/search/universal/route.ts +++ b/src/app/api/search/universal/route.ts @@ -7,7 +7,7 @@ import { rateLimitJsonResponse, } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; -import { jsonError } from "@/lib/http"; +import { jsonError, PublicApiError } from "@/lib/http"; import { publicAccessContext } from "@/lib/public-api-access"; import { buildServerTimingHeader, type ServerTimingEntry } from "@/lib/server-timing"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -40,6 +40,7 @@ const universalSearchQuerySchema = z.object({ domains: z .string() .trim() + .max(500) .optional() .transform((value) => { if (!value) return undefined; @@ -186,6 +187,24 @@ export async function GET(request: Request) { if (error instanceof AuthenticationError) { return unauthorizedResponse(); } + if (error instanceof z.ZodError) { + return jsonError(error, 400); + } + if (error instanceof PublicApiError) { + return jsonError(error, error.status); + } + if (error instanceof SyntaxError || error instanceof URIError || error instanceof TypeError) { + return jsonError(new PublicApiError("Invalid universal search query.", 400, { code: "invalid_query" }), 400); + } return jsonError(error); } } + +export async function POST() { + return jsonError( + new PublicApiError("Method Not Allowed. Universal search requires a GET request.", 405, { + code: "method_not_allowed", + }), + 405, + ); +} diff --git a/src/components/clinical-dashboard/guide-progress.ts b/src/components/clinical-dashboard/guide-progress.ts index 671fcabf33..04d519fb9c 100644 --- a/src/components/clinical-dashboard/guide-progress.ts +++ b/src/components/clinical-dashboard/guide-progress.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { guideTourStepIds, type GuideTourStepId } from "@/components/clinical-dashboard/guide-content"; export const guideProgressStorageKey = "clinical-kb-guide-progress:v1"; @@ -14,20 +15,25 @@ export const emptyGuideProgress: GuideProgress = { lastStepId: null, }; -const guideTourStepIdSet = new Set(guideTourStepIds); +const guideTourStepIdEnum = z.enum(guideTourStepIds); + +const guideProgressSchema = z.object({ + version: z.literal(1), + completedStepIds: z.array(z.string()).transform((ids) => guideTourStepIds.filter((id) => ids.includes(id))), + lastStepId: guideTourStepIdEnum.nullable().catch(null), +}); export function parseGuideProgress(value: string | null): GuideProgress { if (!value) return emptyGuideProgress; try { - const parsed = JSON.parse(value) as Record; - if (parsed.version !== 1 || !Array.isArray(parsed.completedStepIds)) return emptyGuideProgress; - const completedStepIdsRaw = parsed.completedStepIds; - const completedStepIds = guideTourStepIds.filter((id) => completedStepIdsRaw.includes(id)); - const lastStepId = - typeof parsed.lastStepId === "string" && guideTourStepIdSet.has(parsed.lastStepId) - ? (parsed.lastStepId as GuideTourStepId) - : null; - return { version: 1, completedStepIds, lastStepId }; + const raw = JSON.parse(value); + const parsed = guideProgressSchema.safeParse(raw); + if (!parsed.success) return emptyGuideProgress; + return { + version: 1, + completedStepIds: parsed.data.completedStepIds, + lastStepId: parsed.data.lastStepId, + }; } catch { return emptyGuideProgress; } diff --git a/src/lib/api-client-error.ts b/src/lib/api-client-error.ts index 4cf3a87417..a9a38e844d 100644 --- a/src/lib/api-client-error.ts +++ b/src/lib/api-client-error.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export class ApiClientError extends Error { constructor( message: string, @@ -11,6 +13,20 @@ export class ApiClientError extends Error { } } +const apiErrorDetailsSchema = z.looseObject({ + code: z.string().optional(), + retryAfterSeconds: z.number().optional(), +}); + +const apiErrorPayloadSchema = z.looseObject({ + message: z.string().optional(), + error: z.string().optional(), + code: z.string().optional(), + details: apiErrorDetailsSchema.optional(), +}); + +type ApiErrorPayload = z.infer; + function retryAfterMs(response: Response, now: number) { const raw = response.headers.get("retry-after")?.trim(); if (!raw) return null; @@ -24,16 +40,22 @@ function retryableStatus(status: number) { return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504; } -function sseErrorPayload(text: string) { +function parseJsonPayload(raw: string): ApiErrorPayload | null { + try { + const parsed = JSON.parse(raw); + const result = apiErrorPayloadSchema.safeParse(parsed); + return result.success ? result.data : null; + } catch { + return null; + } +} + +function sseErrorPayload(text: string): ApiErrorPayload | null { for (const block of text.split(/\r?\n\r?\n/)) { if (!/^event:\s*error\s*$/m.test(block)) continue; const data = block.match(/^data:\s*(.+)$/m)?.[1]; if (!data) continue; - try { - return JSON.parse(data) as Record; - } catch { - return null; - } + return parseJsonPayload(data); } return null; } @@ -41,13 +63,9 @@ function sseErrorPayload(text: string) { export async function parseApiErrorResponse(response: Response, now = Date.now()) { const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; const text = await response.text().catch(() => ""); - let payload: Record | null = null; + let payload: ApiErrorPayload | null = null; if (contentType.includes("json")) { - try { - payload = JSON.parse(text) as Record; - } catch { - payload = null; - } + payload = parseJsonPayload(text); } else if (contentType.includes("text/event-stream")) { payload = sseErrorPayload(text); } @@ -56,8 +74,7 @@ export async function parseApiErrorResponse(response: Response, now = Date.now() (typeof payload?.error === "string" && payload.error) || (text && !contentType.includes("text/event-stream") ? text.slice(0, 300) : "") || `Request failed (${response.status})`; - const details = - payload?.details && typeof payload.details === "object" ? (payload.details as Record) : null; + const details = payload?.details ?? null; const code = (typeof payload?.code === "string" && payload.code) || (typeof details?.code === "string" && details.code) || diff --git a/src/lib/document-detail.ts b/src/lib/document-detail.ts index 0b8e4f88a8..ea00e5004c 100644 --- a/src/lib/document-detail.ts +++ b/src/lib/document-detail.ts @@ -287,7 +287,7 @@ function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): Docu const rawPayload = getDemoDocumentPayload(rawId); if (!rawPayload) throw new PublicApiError("Demo document not found.", 404); - const payload = rawPayload as unknown as { + const payload = rawPayload as { document: ClinicalDocument; pages: DocumentDetailPage[]; images: DocumentDetailImage[]; @@ -296,6 +296,7 @@ function loadDemoDocumentDetail(rawId: string, query: DocumentDetailQuery): Docu indexHealth?: DocumentDetailPayload["indexHealth"]; }; const allChunks = payload.chunks ?? []; + const selectedChunk = query.chunk ? (allChunks.find((chunk) => chunk.id === query.chunk) ?? null) : null; const requestedPage = Math.min(query.page, Math.max(1, payload.document.page_count ?? 1)); const effectivePage = selectedChunk?.page_number ?? requestedPage; diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index ad49650e0c..4599ec2bc8 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -1,9 +1,19 @@ +import { z } from "zod"; + const storagePrefix = "clinical.private-search-scope."; export const privateSearchScopeTtlMs = 30 * 60 * 1000; const maxDocumentIds = 25; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -type StoredPrivateSearchScope = { version: 1; ownerId: string; documentIds: string[]; expiresAt: number }; +const storedPrivateSearchScopeSchema = z.object({ + version: z.literal(1), + ownerId: z.string().min(1), + documentIds: z.array(z.string().regex(uuidPattern)).min(1).max(maxDocumentIds), + expiresAt: z.number(), +}); + +type StoredPrivateSearchScope = z.infer; + export type PrivateSearchScopeRestore = | { kind: "restored"; documentIds: string[] } | { kind: "unavailable"; reason: "missing" | "invalid" | "expired" | "wrong_owner" }; @@ -48,18 +58,13 @@ export function restorePrivateSearchScope( const raw = storage.getItem(key); if (!raw) return { kind: "unavailable", reason: "missing" }; try { - const value = JSON.parse(raw) as Partial; - if ( - value.version !== 1 || - !Array.isArray(value.documentIds) || - value.documentIds.length === 0 || - value.documentIds.length > maxDocumentIds || - value.documentIds.some((id) => typeof id !== "string" || !uuidPattern.test(id)) || - typeof value.expiresAt !== "number" - ) { + const rawJson = JSON.parse(raw); + const parsed = storedPrivateSearchScopeSchema.safeParse(rawJson); + if (!parsed.success) { storage.removeItem(key); return { kind: "unavailable", reason: "invalid" }; } + const value = parsed.data; if (value.expiresAt <= now) { storage.removeItem(key); return { kind: "unavailable", reason: "expired" }; diff --git a/src/lib/rag/rag-row-contracts.ts b/src/lib/rag/rag-row-contracts.ts index 8b140732cf..6229914d4f 100644 --- a/src/lib/rag/rag-row-contracts.ts +++ b/src/lib/rag/rag-row-contracts.ts @@ -19,10 +19,11 @@ import type { SearchResult } from "@/lib/types"; * * - **Strict on the ranking, citation, and evidence fields.** Every required field except * `source_metadata` is `not null` in `supabase/schema.sql`, so requiring it cannot reject a - * row that works today. `source_metadata` is the exception: `documents.metadata` is bare - * `jsonb`, which permits arrays and scalars, so pinning it to an object is guaranteed by the - * data rather than by a constraint. Measured 2026-08-15, all 2851 live documents are - * objects; a `check (jsonb_typeof(metadata) = 'object')` would make that structural. + * row that works today. `source_metadata` is the exception: `documents.metadata` is `not null` + * `jsonb default '{}'::jsonb` without a `check (jsonb_typeof(metadata) = 'object')` constraint, + * so bare `jsonb` in postgres permits arrays and scalars. Pinning it to an object via this + * contract guarantees structural object validation at runtime rather than relying solely on + * database column constraints. Measured 2026-08-15, all 2851 live documents are objects. * The four score fields are `.nullish()` — absent or * null already flows through the downstream `?? 0` handling unchanged — but a *string where * a number belongs* is rejected, which is precisely the silent-misranking case this exists @@ -40,6 +41,12 @@ const retrievalImageSchema = z.looseObject({ caption: z.string(), }); +const sourceMetadataSchema = z + .record(z.string(), z.unknown(), { + message: "source_metadata must be a JSON object", + }) + .nullable(); + const retrievalRowSchema = z.looseObject({ id: z.string().min(1), document_id: z.string().min(1), @@ -50,7 +57,7 @@ const retrievalRowSchema = z.looseObject({ section_heading: z.string().nullable(), content: z.string(), image_ids: z.array(z.string()), - source_metadata: z.record(z.string(), z.unknown()).nullable(), + source_metadata: sourceMetadataSchema, images: z.array(retrievalImageSchema), similarity: z.number().nullish(), text_rank: z.number().nullish(), diff --git a/src/lib/service-catalog-mapper.ts b/src/lib/service-catalog-mapper.ts index 46c5dede85..49a7647df6 100644 --- a/src/lib/service-catalog-mapper.ts +++ b/src/lib/service-catalog-mapper.ts @@ -354,7 +354,7 @@ export function catalogToServiceRecord(service: CatalogService): ServiceRecord { // Facet matching needs the typed tag dimensions only. Keeping the full // catalogue record here would inflate every registry response with unused // source text and metadata. - catalogPayload: { tags: service.tags } as unknown as Record, + catalogPayload: { tags: service.tags }, }; } diff --git a/src/lib/universal-search-stream.ts b/src/lib/universal-search-stream.ts index 33af25025f..e041069035 100644 --- a/src/lib/universal-search-stream.ts +++ b/src/lib/universal-search-stream.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search"; export type UniversalSearchStreamResponse = UniversalSearchResponse & { @@ -10,6 +11,65 @@ export type UniversalSearchStreamEvent = | { type: "complete"; response: UniversalSearchStreamResponse } | { type: "error"; code: "universal_search_failed" }; +const universalSearchItemSchema = z.looseObject({ + id: z.string(), + kind: z.string(), + title: z.string(), + subtitle: z.string().optional(), + href: z.string(), + score: z.number().optional(), + badge: z.string().optional(), + meta: z.string().optional(), + confident: z.boolean().optional(), +}); + +const universalSearchGroupSchema = z.looseObject({ + kind: z.string(), + total: z.number(), + items: z.array(universalSearchItemSchema), + latencyMs: z.number(), + error: z.boolean().optional(), +}); + +const universalSearchStreamResponseSchema = z.looseObject({ + query: z.string(), + groups: z.array(universalSearchGroupSchema), + tookMs: z.number(), + domainOrder: z.array(z.string()), + demoMode: z.boolean().optional(), + publicAccess: z.boolean().optional(), +}); + +export const universalSearchStreamEventSchema = z.discriminatedUnion("type", [ + z.looseObject({ + type: z.literal("group"), + query: z.string(), + group: universalSearchGroupSchema, + }), + z.looseObject({ + type: z.literal("complete"), + response: universalSearchStreamResponseSchema, + }), + z.looseObject({ + type: z.literal("error"), + code: z.literal("universal_search_failed"), + }), +]); + +function parseEvent(line: string): UniversalSearchStreamEvent { + let rawJson: unknown; + try { + rawJson = JSON.parse(line); + } catch { + throw new Error("Invalid universal-search NDJSON event."); + } + const parsed = universalSearchStreamEventSchema.safeParse(rawJson); + if (!parsed.success) { + throw new Error("Invalid universal-search NDJSON event."); + } + return parsed.data as UniversalSearchStreamEvent; +} + function abortReason(signal: AbortSignal): Error { return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted.", "AbortError"); } @@ -18,21 +78,8 @@ function throwIfAborted(signal?: AbortSignal) { if (signal?.aborted) throw abortReason(signal); } -function parseEvent(line: string): UniversalSearchStreamEvent { - const parsed = JSON.parse(line) as Partial; - if (parsed.type === "group" && typeof parsed.query === "string" && parsed.group) { - return parsed as Extract; - } - if (parsed.type === "complete" && parsed.response) { - return parsed as Extract; - } - if (parsed.type === "error" && parsed.code === "universal_search_failed") { - return parsed as Extract; - } - throw new Error("Invalid universal-search NDJSON event."); -} - /** Consume split NDJSON chunks, surfacing groups immediately and returning final JSON parity. */ + export async function consumeUniversalSearchNdjson( response: Response, options: { diff --git a/src/lib/validation/query.ts b/src/lib/validation/query.ts index 24c132673f..cb0c49e166 100644 --- a/src/lib/validation/query.ts +++ b/src/lib/validation/query.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { parseSchema } from "@/lib/validation/http"; +import { parseSchema, validationError } from "@/lib/validation/http"; type QueryIntegerOptions = { fallback: number; @@ -51,6 +51,19 @@ export function parseRequestQuery( schema: TSchema, message = "Invalid query parameters.", ): z.infer { - const params = Object.fromEntries(new URL(request.url).searchParams.entries()); + let url: URL; + try { + url = new URL(request.url); + } catch { + throw validationError(message, "invalid_query"); + } + const params: Record = {}; + try { + for (const [key, value] of url.searchParams.entries()) { + params[key] = value; + } + } catch { + throw validationError(message, "invalid_query"); + } return parseSchema(schema, params, message, "invalid_query"); } diff --git a/tests/api-search.test.ts b/tests/api-search.test.ts new file mode 100644 index 0000000000..1527dfdc52 --- /dev/null +++ b/tests/api-search.test.ts @@ -0,0 +1,454 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const ownerId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +const documentId = "11111111-1111-4111-8111-111111111111"; +const chunkId = "22222222-2222-4222-8222-222222222222"; + +function createSupabaseMock() { + const inserts: Array<{ table: string; payload: unknown }> = []; + const from = vi.fn((table: string) => { + const filters: Array<{ column: string; value: unknown }> = []; + const inFilters: Array<{ column: string; values: unknown[] }> = []; + const builder = { + select: vi.fn(() => builder), + eq: vi.fn((column: string, value: unknown) => { + filters.push({ column, value }); + return builder; + }), + is: vi.fn((column: string, value: unknown) => { + filters.push({ column, value }); + return builder; + }), + in: vi.fn((column: string, values: unknown[]) => { + inFilters.push({ column, values }); + return builder; + }), + order: vi.fn(() => builder), + range: vi.fn(() => builder), + limit: vi.fn(() => builder), + abortSignal: vi.fn(() => builder), + insert: vi.fn(async (payload: unknown) => { + inserts.push({ table, payload }); + return { data: null, error: null }; + }), + then: (onfulfilled?: (value: { data: unknown; error: null }) => unknown) => { + const explicitIds = inFilters.find((filter) => filter.column === "id")?.values as string[] | undefined; + const ownerFilter = filters.find((filter) => filter.column === "owner_id"); + const data = + table === "documents" && ownerFilter + ? (explicitIds?.length ? explicitIds : [documentId]) + .filter((id) => ownerFilter.value !== null || id === documentId) + .map((id) => ({ + id, + metadata: {}, + import_batch_id: null, + })) + : []; + return Promise.resolve({ data, error: null }).then(onfulfilled); + }, + }; + return builder; + }); + + return { from, inserts }; +} + +function sampleSearchResult() { + return { + id: chunkId, + document_id: documentId, + title: "Clozapine monitoring guideline", + file_name: "clozapine.pdf", + page_number: 1, + chunk_index: 0, + section_heading: "Monitoring", + section_path: ["Monitoring"], + heading_level: 1, + parent_heading: null, + anchor_id: null, + content: "Clozapine monitoring source text.", + image_ids: [], + similarity: 0.92, + text_rank: 0.8, + hybrid_score: 0.93, + rrf_score: 0.03, + source_strength: "strong", + source_metadata: { + document_status: "current", + clinical_validation_status: "unverified", + extraction_quality: "good", + }, + document_labels: [], + images: [], + }; +} + +function mockRuntime(options: { demoMode?: boolean } = {}) { + vi.resetModules(); + + class MockAuthenticationError extends Error { + constructor() { + super("Authentication required."); + this.name = "AuthenticationError"; + } + } + + const supabase = createSupabaseMock(); + const createAdminClient = vi.fn(() => supabase); + const requireAuthenticatedUser = vi.fn(async (request: Request) => { + const id = request.headers.get("x-test-user"); + if (!id) throw new MockAuthenticationError(); + return { id }; + }); + const getOptionalAuthenticatedUser = vi.fn(async (request: Request) => { + const id = request.headers.get("x-test-user"); + return id ? { id } : null; + }); + const unauthorizedResponse = vi.fn(() => Response.json({ error: "Authentication required." }, { status: 401 })); + const searchChunksWithTelemetry = vi.fn(async () => ({ + results: [sampleSearchResult()], + telemetry: { + query_class: "document_lookup", + retrieval_strategy: "hybrid", + retrieval_plan: "document_lookup:title_label_section_then_chunks", + retrieval_query_variant_count: 2, + search_cache_hit: false, + embedding_skipped: false, + embedding_skip_reason: null, + embedding_cache_hit: false, + embedding_prefetched: true, + text_fast_path_latency_ms: 0, + text_candidate_budget: 24, + text_candidate_count: 3, + text_fast_path_reason: null, + embedding_latency_ms: 0, + vector_candidate_count: 5, + embedding_field_count: 1, + supabase_rpc_latency_ms: 0, + rerank_latency_ms: 0, + retrieval_provenance_counts: { chunk: 1, title: 1 }, + second_stage_rerank_used: true, + second_stage_rerank_latency_ms: 2, + visual_direct_image_count: 0, + memory_card_count: 0, + memory_top_score: 0, + weighted_top_score: 0.93, + rrf_top_score: 0.03, + }, + })); + const fetchRelatedDocuments = vi.fn(async () => []); + + vi.doMock("@/lib/env", () => ({ + env: { + NEXT_PUBLIC_SUPABASE_URL: "https://mock.supabase.co", + NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: "mock-key", + MAX_UPLOAD_MB: 150, + RAG_SEARCH_CACHE_TTL_MS: 0, + RAG_SEARCH_CACHE_SIZE: 0, + RAG_ANSWER_CACHE_TTL_MS: 0, + RAG_ANSWER_CACHE_SIZE: 0, + RAG_AWAIT_QUERY_LOGS: false, + }, + isDemoMode: () => Boolean(options.demoMode), + isLocalNoAuthMode: () => false, + requireOpenAIEnv: () => undefined, + requireServerEnv: () => undefined, + })); + vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient })); + vi.doMock("@/lib/supabase/auth", () => ({ + AuthenticationError: MockAuthenticationError, + requireAuthenticatedUser, + getOptionalAuthenticatedUser, + unauthorizedResponse, + })); + vi.doMock("@/lib/api-rate-limit", () => ({ + consumeSubjectApiRateLimit: vi.fn(async () => ({ + limited: false, + limit: 100, + remaining: 99, + retryAfterSeconds: 0, + resetAt: new Date(Date.now() + 60_000).toISOString(), + })), + allowRateLimitInMemoryFallbackOnUnavailable: () => true, + rateLimitJsonResponse: vi.fn(), + })); + vi.doMock("@/lib/demo-data", () => ({ + demoSearch: vi.fn(() => [sampleSearchResult()]), + demoAnswer: vi.fn(), + })); + vi.doMock("@/lib/rag/rag", () => ({ + searchChunksWithTelemetry, + })); + + vi.doMock("@/lib/document-enrichment", () => ({ + fetchRelatedDocuments, + toDocumentMatch: vi.fn((document: unknown) => document), + })); + vi.doMock("@/lib/evidence", () => ({ + buildSmartPanel: vi.fn(() => ({})), + buildVisualEvidence: vi.fn(() => []), + diversifySearchResults: vi.fn((results: unknown[]) => results), + })); + vi.doMock("@/lib/evidence-relevance", async (importOriginal) => ({ + ...(await importOriginal()), + annotateDocumentMatches: vi.fn((_query: string, documents: unknown[]) => documents), + annotateSearchResults: vi.fn((_query: string, results: unknown[]) => results), + })); + vi.doMock("@/lib/universal-search", () => ({ + universalSearchDomains: ["documents", "medications", "forms", "services", "differentials", "calculators"], + runUniversalSearch: vi.fn(async (args: { query: string; demo?: boolean }) => ({ + query: args.query, + groups: [ + { + kind: "medications", + title: "Medications", + items: [{ id: "lithium", title: "Lithium", href: "/medications/lithium" }], + total: 1, + latencyMs: 5, + }, + ], + tookMs: 6, + domainOrder: ["medications"], + })), + })); + + return { + createAdminClient, + fetchRelatedDocuments, + getOptionalAuthenticatedUser, + requireAuthenticatedUser, + searchChunksWithTelemetry, + supabase, + }; +} + +function jsonRequest(path: string, body: Record, authenticated = false) { + const headers = new Headers({ "Content-Type": "application/json" }); + if (authenticated) headers.set("x-test-user", ownerId); + + return new Request(`http://localhost${path}`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); +} + +function request(path: string, init?: RequestInit) { + return new Request(`http://localhost${path}`, { + ...init, + headers: { + ...init?.headers, + }, + }); +} + +async function payload(response: Response) { + return (await response.json()) as Record; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); +}); + +describe("/api/search route defensive hardening (Task #342)", () => { + it("accepts a valid search request and returns 200 in demo mode", async () => { + mockRuntime({ demoMode: true }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "lithium" })); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.demoMode).toBe(true); + expect(Array.isArray(body.results)).toBe(true); + }); + + it("accepts a valid search request and returns 200 in live mode", async () => { + mockRuntime({ demoMode: false }); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "lithium dosage", topK: 5 }, true)); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(Array.isArray(body.results)).toBe(true); + expect(body.results).toHaveLength(1); + }); + + it("rejects malformed JSON bodies with structured 400 JSON", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{ malformed json", + }), + ); + const body = await payload(response); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ + error: "Invalid search request.", + code: "invalid_body", + }); + }); + + it("rejects empty or missing query parameter with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const responseEmpty = await POST(jsonRequest("/api/search", { query: " " })); + const bodyEmpty = await payload(responseEmpty); + + expect(responseEmpty.status).toBe(400); + expect(bodyEmpty).toMatchObject({ + error: "Invalid search request.", + code: "invalid_body", + }); + + const responseMissing = await POST(jsonRequest("/api/search", {})); + const bodyMissing = await payload(responseMissing); + + expect(responseMissing.status).toBe(400); + expect(bodyMissing).toMatchObject({ + error: "Invalid search request.", + code: "invalid_body", + }); + }); + + it("rejects queries exceeding the 2000 character length limit with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "a".repeat(2001) })); + const body = await payload(response); + + expect(response.status).toBe(400); + expect(body).toMatchObject({ error: "Invalid search request.", code: "invalid_body" }); + }); + + it("rejects invalid topK or documentLimit with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const responseTopK = await POST(jsonRequest("/api/search", { query: "lithium", topK: 50 })); + expect(responseTopK.status).toBe(400); + + const responseDocLimit = await POST(jsonRequest("/api/search", { query: "lithium", documentLimit: 100 })); + expect(responseDocLimit.status).toBe(400); + }); + + it("rejects non-UUID documentId with 400", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST(jsonRequest("/api/search", { query: "lithium", documentId: "not-a-uuid" })); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ error: "Invalid search request.", code: "invalid_body" }); + }); + + it("rejects payloads exceeding the byte limit with 413 payload_too_large", async () => { + mockRuntime(); + const { POST } = await import("../src/app/api/search/route"); + + const response = await POST( + request("/api/search", { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": "300000", + }, + body: JSON.stringify({ query: "lithium" }), + }), + ); + expect(response.status).toBe(413); + expect(await payload(response)).toMatchObject({ code: "payload_too_large" }); + }); + + it("rejects GET requests with structured 405 Method Not Allowed", async () => { + const { GET } = await import("../src/app/api/search/route"); + + const response = await GET(); + const body = await payload(response); + + expect(response.status).toBe(405); + expect(body).toMatchObject({ + code: "method_not_allowed", + }); + }); +}); + +describe("/api/search/universal route defensive hardening (Task #342)", () => { + it("accepts a valid universal search query and returns 200", async () => { + mockRuntime({ demoMode: true }); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(request("/api/search/universal?q=lithium")); + const body = await payload(response); + + expect(response.status).toBe(200); + expect(body.query).toBe("lithium"); + expect(Array.isArray(body.groups)).toBe(true); + }); + + it("rejects missing or too short q parameter with structured 400 JSON", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const responseMissing = await GET(request("/api/search/universal")); + expect(responseMissing.status).toBe(400); + expect(await payload(responseMissing)).toMatchObject({ code: "invalid_query" }); + + const responseShort = await GET(request("/api/search/universal?q=a")); + expect(responseShort.status).toBe(400); + expect(await payload(responseShort)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects queries exceeding 200 chars with structured 400 JSON", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(request(`/api/search/universal?q=${"a".repeat(201)}`)); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects oversized domains parameter with structured 400 JSON", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const response = await GET(request(`/api/search/universal?q=lithium&domains=${"a".repeat(501)}`)); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects malformed URLs gracefully with structured 400 instead of unhandled 500", async () => { + mockRuntime(); + const { GET } = await import("../src/app/api/search/universal/route"); + + const badRequest = { + url: "://malformed-url", + headers: new Headers(), + signal: new AbortController().signal, + } as unknown as Request; + + const response = await GET(badRequest); + expect(response.status).toBe(400); + expect(await payload(response)).toMatchObject({ code: "invalid_query" }); + }); + + it("rejects POST requests on universal search with structured 405 Method Not Allowed", async () => { + const { POST } = await import("../src/app/api/search/universal/route"); + + const response = await POST(); + const body = await payload(response); + + expect(response.status).toBe(405); + expect(body).toMatchObject({ + code: "method_not_allowed", + }); + }); +}); diff --git a/tests/rag-retrieval-row-contract.test.ts b/tests/rag-retrieval-row-contract.test.ts index ac1b7a0467..ee953259a5 100644 --- a/tests/rag-retrieval-row-contract.test.ts +++ b/tests/rag-retrieval-row-contract.test.ts @@ -108,9 +108,26 @@ describe("retrieval row shape contract", () => { ); }); + it.each([ + ["array", [1, 2, 3]], + ["string", "invalid-string-metadata"], + ["number", 12345], + ["boolean", true], + ])("rejects non-object JSON structure for source_metadata (%s)", (_type, invalidMetadata) => { + let thrown: RetrievalRowShapeError | null = null; + try { + assertRetrievalRows([hybridRow({ source_metadata: invalidMetadata })], "match_document_chunks_hybrid"); + } catch (error) { + thrown = error as RetrievalRowShapeError; + } + expect(thrown).toBeInstanceOf(RetrievalRowShapeError); + expect(thrown?.message).toContain("source_metadata"); + }); + it("accepts absent or null scores, which downstream already coalesces to 0", () => { expect(() => assertRetrievalRows([withoutColumn("text_rank")], "match_document_chunks")).not.toThrow(); expect(() => assertRetrievalRows([hybridRow({ rrf_score: null })], "match_document_chunks")).not.toThrow(); + expect(() => assertRetrievalRows([hybridRow({ source_metadata: null })], "match_document_chunks")).not.toThrow(); expect(() => assertRetrievalRows([], "match_document_chunks")).not.toThrow(); }); From 6699d6794442c7dcfc296709a634d7942395d459 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:41:38 +0800 Subject: [PATCH 2/3] fix(api): repair CI blockers on boundary hardening (PR #2064) --- .../clinical-dashboard/guide-progress.ts | 34 ++--- src/lib/api-client-error.ts | 75 ++++++---- src/lib/private-search-scope.ts | 38 ++++-- src/lib/universal-search-stream.ts | 129 +++++++++++------- 4 files changed, 175 insertions(+), 101 deletions(-) diff --git a/src/components/clinical-dashboard/guide-progress.ts b/src/components/clinical-dashboard/guide-progress.ts index 04d519fb9c..efadcbb8bf 100644 --- a/src/components/clinical-dashboard/guide-progress.ts +++ b/src/components/clinical-dashboard/guide-progress.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import { guideTourStepIds, type GuideTourStepId } from "@/components/clinical-dashboard/guide-content"; export const guideProgressStorageKey = "clinical-kb-guide-progress:v1"; @@ -15,25 +14,28 @@ export const emptyGuideProgress: GuideProgress = { lastStepId: null, }; -const guideTourStepIdEnum = z.enum(guideTourStepIds); - -const guideProgressSchema = z.object({ - version: z.literal(1), - completedStepIds: z.array(z.string()).transform((ids) => guideTourStepIds.filter((id) => ids.includes(id))), - lastStepId: guideTourStepIdEnum.nullable().catch(null), -}); +const guideTourStepIdSet = new Set(guideTourStepIds); export function parseGuideProgress(value: string | null): GuideProgress { if (!value) return emptyGuideProgress; try { - const raw = JSON.parse(value); - const parsed = guideProgressSchema.safeParse(raw); - if (!parsed.success) return emptyGuideProgress; - return { - version: 1, - completedStepIds: parsed.data.completedStepIds, - lastStepId: parsed.data.lastStepId, - }; + const parsed = JSON.parse(value) as Record; + if ( + typeof parsed !== "object" || + parsed === null || + parsed.version !== 1 || + !Array.isArray(parsed.completedStepIds) || + parsed.completedStepIds.some((id) => typeof id !== "string") + ) { + return emptyGuideProgress; + } + const completedStepIdsRaw = parsed.completedStepIds as unknown[]; + const completedStepIds = guideTourStepIds.filter((id) => completedStepIdsRaw.includes(id)); + const lastStepId = + typeof parsed.lastStepId === "string" && guideTourStepIdSet.has(parsed.lastStepId) + ? (parsed.lastStepId as GuideTourStepId) + : null; + return { version: 1, completedStepIds, lastStepId }; } catch { return emptyGuideProgress; } diff --git a/src/lib/api-client-error.ts b/src/lib/api-client-error.ts index a9a38e844d..d6846cd602 100644 --- a/src/lib/api-client-error.ts +++ b/src/lib/api-client-error.ts @@ -1,5 +1,3 @@ -import { z } from "zod"; - export class ApiClientError extends Error { constructor( message: string, @@ -13,19 +11,60 @@ export class ApiClientError extends Error { } } -const apiErrorDetailsSchema = z.looseObject({ - code: z.string().optional(), - retryAfterSeconds: z.number().optional(), -}); +type ApiErrorDetails = { + code?: string; + retryAfterSeconds?: number; +}; + +type ApiErrorPayload = { + message?: string; + error?: string; + code?: string; + details?: ApiErrorDetails; +}; -const apiErrorPayloadSchema = z.looseObject({ - message: z.string().optional(), - error: z.string().optional(), - code: z.string().optional(), - details: apiErrorDetailsSchema.optional(), -}); +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} -type ApiErrorPayload = z.infer; +function parseJsonPayload(raw: string): ApiErrorPayload | null { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + if (!isRecord(json)) return null; + const payload: ApiErrorPayload = {}; + if (json.message !== undefined) { + if (typeof json.message !== "string") return null; + payload.message = json.message; + } + if (json.error !== undefined) { + if (typeof json.error !== "string") return null; + payload.error = json.error; + } + if (json.code !== undefined) { + if (typeof json.code !== "string") return null; + payload.code = json.code; + } + if (json.details !== undefined) { + if (!isRecord(json.details)) return null; + const details: ApiErrorDetails = {}; + if (json.details.code !== undefined) { + if (typeof json.details.code !== "string") return null; + details.code = json.details.code; + } + if (json.details.retryAfterSeconds !== undefined) { + if (typeof json.details.retryAfterSeconds !== "number" || Number.isNaN(json.details.retryAfterSeconds)) { + return null; + } + details.retryAfterSeconds = json.details.retryAfterSeconds; + } + payload.details = details; + } + return payload; +} function retryAfterMs(response: Response, now: number) { const raw = response.headers.get("retry-after")?.trim(); @@ -40,16 +79,6 @@ function retryableStatus(status: number) { return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504; } -function parseJsonPayload(raw: string): ApiErrorPayload | null { - try { - const parsed = JSON.parse(raw); - const result = apiErrorPayloadSchema.safeParse(parsed); - return result.success ? result.data : null; - } catch { - return null; - } -} - function sseErrorPayload(text: string): ApiErrorPayload | null { for (const block of text.split(/\r?\n\r?\n/)) { if (!/^event:\s*error\s*$/m.test(block)) continue; diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index 4599ec2bc8..45a7c809ba 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -1,18 +1,32 @@ -import { z } from "zod"; - const storagePrefix = "clinical.private-search-scope."; export const privateSearchScopeTtlMs = 30 * 60 * 1000; const maxDocumentIds = 25; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const storedPrivateSearchScopeSchema = z.object({ - version: z.literal(1), - ownerId: z.string().min(1), - documentIds: z.array(z.string().regex(uuidPattern)).min(1).max(maxDocumentIds), - expiresAt: z.number(), -}); +type StoredPrivateSearchScope = { version: 1; ownerId: string; documentIds: string[]; expiresAt: number }; -type StoredPrivateSearchScope = z.infer; +function parseStoredPrivateSearchScope(raw: string): StoredPrivateSearchScope | null { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + if (typeof json !== "object" || json === null) return null; + const value = json as Partial; + if (value.version !== 1) return null; + if (typeof value.ownerId !== "string" || value.ownerId.length === 0) return null; + if ( + !Array.isArray(value.documentIds) || + value.documentIds.length === 0 || + value.documentIds.length > maxDocumentIds || + value.documentIds.some((id) => typeof id !== "string" || !uuidPattern.test(id)) + ) { + return null; + } + if (typeof value.expiresAt !== "number" || Number.isNaN(value.expiresAt)) return null; + return { version: 1, ownerId: value.ownerId, documentIds: value.documentIds, expiresAt: value.expiresAt }; +} export type PrivateSearchScopeRestore = | { kind: "restored"; documentIds: string[] } @@ -58,13 +72,11 @@ export function restorePrivateSearchScope( const raw = storage.getItem(key); if (!raw) return { kind: "unavailable", reason: "missing" }; try { - const rawJson = JSON.parse(raw); - const parsed = storedPrivateSearchScopeSchema.safeParse(rawJson); - if (!parsed.success) { + const value = parseStoredPrivateSearchScope(raw); + if (!value) { storage.removeItem(key); return { kind: "unavailable", reason: "invalid" }; } - const value = parsed.data; if (value.expiresAt <= now) { storage.removeItem(key); return { kind: "unavailable", reason: "expired" }; diff --git a/src/lib/universal-search-stream.ts b/src/lib/universal-search-stream.ts index e041069035..30ee07a8fb 100644 --- a/src/lib/universal-search-stream.ts +++ b/src/lib/universal-search-stream.ts @@ -1,4 +1,3 @@ -import { z } from "zod"; import type { UniversalSearchGroup, UniversalSearchResponse } from "@/lib/universal-search"; export type UniversalSearchStreamResponse = UniversalSearchResponse & { @@ -11,50 +10,71 @@ export type UniversalSearchStreamEvent = | { type: "complete"; response: UniversalSearchStreamResponse } | { type: "error"; code: "universal_search_failed" }; -const universalSearchItemSchema = z.looseObject({ - id: z.string(), - kind: z.string(), - title: z.string(), - subtitle: z.string().optional(), - href: z.string(), - score: z.number().optional(), - badge: z.string().optional(), - meta: z.string().optional(), - confident: z.boolean().optional(), -}); - -const universalSearchGroupSchema = z.looseObject({ - kind: z.string(), - total: z.number(), - items: z.array(universalSearchItemSchema), - latencyMs: z.number(), - error: z.boolean().optional(), -}); - -const universalSearchStreamResponseSchema = z.looseObject({ - query: z.string(), - groups: z.array(universalSearchGroupSchema), - tookMs: z.number(), - domainOrder: z.array(z.string()), - demoMode: z.boolean().optional(), - publicAccess: z.boolean().optional(), -}); - -export const universalSearchStreamEventSchema = z.discriminatedUnion("type", [ - z.looseObject({ - type: z.literal("group"), - query: z.string(), - group: universalSearchGroupSchema, - }), - z.looseObject({ - type: z.literal("complete"), - response: universalSearchStreamResponseSchema, - }), - z.looseObject({ - type: z.literal("error"), - code: z.literal("universal_search_failed"), - }), -]); +type UniversalSearchItem = UniversalSearchGroup["items"][number]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && !Number.isNaN(value); +} + +function optionalString(value: unknown) { + return value === undefined || typeof value === "string"; +} + +function optionalNumber(value: unknown) { + return value === undefined || isFiniteNumber(value); +} + +function optionalBoolean(value: unknown) { + return value === undefined || typeof value === "boolean"; +} + +function parseItem(value: unknown): UniversalSearchItem | null { + if (!isRecord(value)) return null; + if (typeof value.id !== "string") return null; + if (typeof value.kind !== "string") return null; + if (typeof value.title !== "string") return null; + if (!optionalString(value.subtitle)) return null; + if (typeof value.href !== "string") return null; + if (!optionalNumber(value.score)) return null; + if (!optionalString(value.badge)) return null; + if (!optionalString(value.meta)) return null; + if (!optionalBoolean(value.confident)) return null; + return value as unknown as UniversalSearchItem; +} + +function parseGroup(value: unknown): UniversalSearchGroup | null { + if (!isRecord(value)) return null; + if (typeof value.kind !== "string") return null; + if (!isFiniteNumber(value.total)) return null; + if (!Array.isArray(value.items)) return null; + const items = value.items.map(parseItem); + if (items.some((item) => item === null)) return null; + if (!isFiniteNumber(value.latencyMs)) return null; + if (!optionalBoolean(value.error)) return null; + return { ...value, items } as unknown as UniversalSearchGroup; +} + +function parseResponse(value: unknown): UniversalSearchStreamResponse | null { + if (!isRecord(value)) return null; + if (typeof value.query !== "string") return null; + if (!Array.isArray(value.groups)) return null; + const groups = value.groups.map(parseGroup); + if (groups.some((group) => group === null)) return null; + if (!isFiniteNumber(value.tookMs)) return null; + if ( + value.domainOrder !== undefined && + (!Array.isArray(value.domainOrder) || value.domainOrder.some((domain) => typeof domain !== "string")) + ) { + return null; + } + if (!optionalBoolean(value.demoMode)) return null; + if (!optionalBoolean(value.publicAccess)) return null; + return { ...value, groups } as unknown as UniversalSearchStreamResponse; +} function parseEvent(line: string): UniversalSearchStreamEvent { let rawJson: unknown; @@ -63,11 +83,22 @@ function parseEvent(line: string): UniversalSearchStreamEvent { } catch { throw new Error("Invalid universal-search NDJSON event."); } - const parsed = universalSearchStreamEventSchema.safeParse(rawJson); - if (!parsed.success) { - throw new Error("Invalid universal-search NDJSON event."); + if (!isRecord(rawJson)) throw new Error("Invalid universal-search NDJSON event."); + if (rawJson.type === "group") { + if (typeof rawJson.query !== "string") throw new Error("Invalid universal-search NDJSON event."); + const group = parseGroup(rawJson.group); + if (!group) throw new Error("Invalid universal-search NDJSON event."); + return { type: "group", query: rawJson.query, group }; + } + if (rawJson.type === "complete") { + const response = parseResponse(rawJson.response); + if (!response) throw new Error("Invalid universal-search NDJSON event."); + return { type: "complete", response }; + } + if (rawJson.type === "error" && rawJson.code === "universal_search_failed") { + return { type: "error", code: "universal_search_failed" }; } - return parsed.data as UniversalSearchStreamEvent; + throw new Error("Invalid universal-search NDJSON event."); } function abortReason(signal: AbortSignal): Error { From 2949b372c7a1f87b586180aa27ca961a75d0b4c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 18 Aug 2026 01:55:34 +0000 Subject: [PATCH 3/3] fix(validation): reject Infinity in stream and scope guards Co-authored-by: BigSimmo <87357024+BigSimmo@users.noreply.github.com> --- src/lib/private-search-scope.ts | 2 +- src/lib/universal-search-stream.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/private-search-scope.ts b/src/lib/private-search-scope.ts index 45a7c809ba..126b151195 100644 --- a/src/lib/private-search-scope.ts +++ b/src/lib/private-search-scope.ts @@ -24,7 +24,7 @@ function parseStoredPrivateSearchScope(raw: string): StoredPrivateSearchScope | ) { return null; } - if (typeof value.expiresAt !== "number" || Number.isNaN(value.expiresAt)) return null; + if (typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt)) return null; return { version: 1, ownerId: value.ownerId, documentIds: value.documentIds, expiresAt: value.expiresAt }; } diff --git a/src/lib/universal-search-stream.ts b/src/lib/universal-search-stream.ts index 30ee07a8fb..8c4f7155b0 100644 --- a/src/lib/universal-search-stream.ts +++ b/src/lib/universal-search-stream.ts @@ -17,7 +17,7 @@ function isRecord(value: unknown): value is Record { } function isFiniteNumber(value: unknown): value is number { - return typeof value === "number" && !Number.isNaN(value); + return typeof value === "number" && Number.isFinite(value); } function optionalString(value: unknown) {