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
10 changes: 10 additions & 0 deletions src/app/api/search/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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,
);
}
21 changes: 20 additions & 1 deletion src/app/api/search/universal/route.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -40,6 +40,7 @@ const universalSearchQuerySchema = z.object({
domains: z
.string()
.trim()
.max(500)
.optional()
.transform((value) => {
if (!value) return undefined;
Expand DownExpand Up@@ -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,
);
}
12 changes: 10 additions & 2 deletions src/components/clinical-dashboard/guide-progress.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,8 +20,16 @@ export function parseGuideProgress(value: string | null): GuideProgress {
if (!value) return emptyGuideProgress;
try {
const parsed = JSON.parse(value) as Record<string, unknown>;
if (parsed.version !== 1 || !Array.isArray(parsed.completedStepIds)) return emptyGuideProgress;
const completedStepIdsRaw = parsed.completedStepIds;
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)
Expand Down
74 changes: 60 additions & 14 deletions src/lib/api-client-error.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11,6 +11,61 @@ export class ApiClientError extends Error {
}
}

type ApiErrorDetails = {
code?: string;
retryAfterSeconds?: number;
};

type ApiErrorPayload = {
message?: string;
error?: string;
code?: string;
details?: ApiErrorDetails;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

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();
if (!raw) return null;
Expand All@@ -24,30 +79,22 @@ function retryableStatus(status: number) {
return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
}

function sseErrorPayload(text: string) {
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<string, unknown>;
} catch {
return null;
}
return parseJsonPayload(data);
}
return null;
}

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<string, unknown> | null = null;
let payload: ApiErrorPayload | null = null;
if (contentType.includes("json")) {
try {
payload = JSON.parse(text) as Record<string, unknown>;
} catch {
payload = null;
}
payload = parseJsonPayload(text);
} else if (contentType.includes("text/event-stream")) {
payload = sseErrorPayload(text);
}
Expand All@@ -56,8 +103,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<string, unknown>) : null;
const details = payload?.details ?? null;
const code =
(typeof payload?.code === "string" && payload.code) ||
(typeof details?.code === "string" && details.code) ||
Expand Down
3 changes: 2 additions & 1 deletion src/lib/document-detail.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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[];
Expand All@@ -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;
Expand Down
35 changes: 26 additions & 9 deletions src/lib/private-search-scope.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,30 @@ 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 };

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<StoredPrivateSearchScope>;
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.isFinite(value.expiresAt)) return null;
return { version: 1, ownerId: value.ownerId, documentIds: value.documentIds, expiresAt: value.expiresAt };
}

export type PrivateSearchScopeRestore =
| { kind: "restored"; documentIds: string[] }
| { kind: "unavailable"; reason: "missing" | "invalid" | "expired" | "wrong_owner" };
Expand DownExpand Up@@ -48,15 +72,8 @@ export function restorePrivateSearchScope(
const raw = storage.getItem(key);
if (!raw) return { kind: "unavailable", reason: "missing" };
try {
const value = JSON.parse(raw) as Partial<StoredPrivateSearchScope>;
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 value = parseStoredPrivateSearchScope(raw);
if (!value) {
storage.removeItem(key);
return { kind: "unavailable", reason: "invalid" };
}
Expand Down
17 changes: 12 additions & 5 deletions src/lib/rag/rag-row-contracts.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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),
Expand All@@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion src/lib/service-catalog-mapper.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, unknown>,
catalogPayload: { tags: service.tags },
};
}

Expand Down
Loading
Loading