diff --git a/scripts/eval-quality.ts b/scripts/eval-quality.ts
index 0bde64faf..0c1749ceb 100644
--- a/scripts/eval-quality.ts
+++ b/scripts/eval-quality.ts
@@ -45,6 +45,7 @@ type EvalQualityArgs = {
retrievalOnly: boolean;
ragOnly: boolean;
skipPreflight: boolean;
+ forceEmbedding: boolean;
};
export type RagQualityResult = {
@@ -150,6 +151,7 @@ function parseArgs(argv: string[]): EvalQualityArgs {
retrievalOnly: false,
ragOnly: false,
skipPreflight: false,
+ forceEmbedding: false,
};
for (let index = 0; index < argv.length; index += 1) {
@@ -176,6 +178,10 @@ function parseArgs(argv: string[]): EvalQualityArgs {
args.skipPreflight = true;
continue;
}
+ if (token === "--force-embedding") {
+ args.forceEmbedding = true;
+ continue;
+ }
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${token}`);
@@ -476,6 +482,11 @@ export function buildEvalQualityReport(args: {
`retrieval content_recall_at_5 ${retrievalSummary.content_recall_at_5} below ${qualityThresholds.retrievalContentRecallAt5}`,
);
}
+ if (retrievalSummary.force_embedding_failure_count > 0) {
+ thresholdFailures.push(
+ `retrieval force_embedding_failure_count ${retrievalSummary.force_embedding_failure_count} above 0`,
+ );
+ }
if (governance.stale_rate > qualityThresholds.staleTopResultRate) {
thresholdFailures.push(
`top-result stale_rate ${governance.stale_rate} above ${qualityThresholds.staleTopResultRate}`,
@@ -664,6 +675,8 @@ ${markdownTable([
## Retrieval Decision Metrics
${markdownTable([
+ ["Force-embedding cases", retrieval.force_embedding_case_count],
+ ["Force-embedding failures", retrieval.force_embedding_failure_count],
["Embedding skipped rate", retrieval.embedding_skipped_rate],
["Median text candidate budget", retrieval.median_text_candidate_budget],
["Second-stage rerank rate", retrieval.second_stage_rerank_rate],
@@ -728,6 +741,7 @@ async function runRetrievalQualityCases(args: {
ownerId?: string;
limit?: number;
query?: string;
+ forceEmbedding?: boolean;
supabase: Awaited>;
}) {
const [{ searchChunksWithTelemetry }, capturedCases] = await Promise.all([
@@ -756,7 +770,7 @@ async function runRetrievalQualityCases(args: {
topK: retrievalLimitForGoldenCase(testCase),
minSimilarity: 0.12,
skipCache: true,
- forceEmbedding: testCase.forceEmbedding,
+ forceEmbedding: testCase.forceEmbedding || args.forceEmbedding,
}),
);
const latencyMs =
diff --git a/scripts/eval-retrieval.ts b/scripts/eval-retrieval.ts
index edeb89564..2a46d96f2 100644
--- a/scripts/eval-retrieval.ts
+++ b/scripts/eval-retrieval.ts
@@ -47,6 +47,7 @@ type EvalArgs = {
export type GoldenRetrievalResult = {
id: string;
query: string;
+ forceEmbedding: boolean;
expectedQueryClass: string;
actualQueryClass: string | null;
expectedDocumentSubstrings: string[];
@@ -516,6 +517,11 @@ export function evaluateGoldenRetrievalCase(args: {
const tableEvidenceFoundAtK = hasTableEvidence(args.results, topK);
const actualQueryClass = args.telemetry.query_class ?? null;
const failures: string[] = [];
+ const vectorLayerCount = Object.entries(args.telemetry.retrieval_layer_counts ?? {}).reduce(
+ (sum, [layer, count]) =>
+ ["embedding_fields", "index_units", "hybrid_vector", "vector_fallback"].includes(layer) ? sum + count : sum,
+ 0,
+ );
const hitAtK =
documentHitsAtK.missing.length === 0 &&
contentHitsAtK.missing.length === 0 &&
@@ -533,10 +539,23 @@ export function evaluateGoldenRetrievalCase(args: {
if (args.testCase.expectTableEvidence && !tableEvidenceFound) {
failures.push("expected table evidence in top 5");
}
+ if (args.testCase.forceEmbedding) {
+ if (args.telemetry.embedding_skipped) failures.push("forceEmbedding expected embedding to run");
+ if (args.telemetry.retrieval_strategy === "search_cache") failures.push("forceEmbedding served search cache");
+ if (
+ args.telemetry.retrieval_strategy === "text_fast_path" ||
+ args.telemetry.retrieval_strategy === "document_lookup_fast_path"
+ ) {
+ failures.push(`forceEmbedding returned lexical strategy ${args.telemetry.retrieval_strategy}`);
+ }
+ if (args.telemetry.coverage_gate_decision === "accepted") failures.push("forceEmbedding returned coverage gate");
+ if (vectorLayerCount <= 0) failures.push("forceEmbedding found no vector-layer candidates");
+ }
return {
id: args.testCase.id,
query: args.testCase.query,
+ forceEmbedding: args.testCase.forceEmbedding ?? false,
expectedQueryClass: args.testCase.expectedQueryClass,
actualQueryClass,
expectedDocumentSubstrings: args.testCase.expectedDocumentSubstrings,
@@ -612,6 +631,7 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[]
}
return counts;
}, {});
+ const forceEmbeddingResults = results.filter((result) => result.forceEmbedding);
return {
case_count: results.length,
document_recall_at_5: Number(
@@ -647,6 +667,8 @@ export function summarizeGoldenRetrievalResults(results: GoldenRetrievalResult[]
embedding_skip_reason_counts: embeddingSkipReasonCounts,
text_fast_path_reason_counts: textFastPathReasonCounts,
retrieval_layer_counts: layerCounts,
+ force_embedding_case_count: forceEmbeddingResults.length,
+ force_embedding_failure_count: forceEmbeddingResults.filter((result) => result.failures.length > 0).length,
median_text_candidate_budget: percentile(textCandidateBudgets, 50),
second_stage_rerank_rate: Number(
(results.filter((result) => result.secondStageRerankUsed).length / Math.max(results.length, 1)).toFixed(4),
@@ -723,6 +745,8 @@ function printHumanSummary(summary: ReturnType {
throw new PublicApiError("Invalid upload form data.", 400, {
code: "invalid_form_data",
@@ -107,7 +112,7 @@ export async function POST(request: Request) {
const documentId = randomUUID();
const safeName = file.name.replace(/[^\w.\-() ]+/g, "_");
- const storagePath = `${user.id}/documents/${documentId}/${safeName}`;
+ const storagePath = `${uploadOwnerId}/documents/${documentId}/${safeName}`;
const buffer = Buffer.from(await file.arrayBuffer());
// The declared MIME type is client-supplied; verify the real byte signature
// before persisting a clinical document.
@@ -117,7 +122,7 @@ export async function POST(request: Request) {
const { data: duplicate, error: duplicateError } = await adminSupabase
.from("documents")
.select("id,title,file_name,status,page_count,chunk_count,image_count,created_at")
- .eq("owner_id", user.id)
+ .eq("owner_id", uploadOwnerId)
.eq("content_hash", contentHash)
.maybeSingle();
@@ -147,7 +152,7 @@ export async function POST(request: Request) {
};
const namePlan = await planDocumentName({
supabase: namingSupabase,
- ownerId: user.id,
+ ownerId: uploadOwnerId,
fileName: file.name,
requestedTitle: uploadMetadata.title,
contentHash,
@@ -160,7 +165,7 @@ export async function POST(request: Request) {
.from("documents")
.insert({
id: documentId,
- owner_id: user.id,
+ owner_id: uploadOwnerId,
title,
description,
file_name: file.name,
@@ -178,7 +183,7 @@ export async function POST(request: Request) {
review_date: null,
uploaded_at: uploadedAt,
indexed_at: null,
- uploaded_by: user.id,
+ uploaded_by: uploadOwnerId,
original_file_name: namePlan.originalFileName,
original_title: namePlan.originalTitle,
smart_title_base: namePlan.baseTitle,
@@ -202,7 +207,7 @@ export async function POST(request: Request) {
insertedDocumentOwnerId = null;
return duplicateUploadResponse({
supabase,
- ownerId: user.id,
+ ownerId: uploadOwnerId,
contentHash,
storagePath: uploadedPath,
});
@@ -210,7 +215,7 @@ export async function POST(request: Request) {
throw new Error(documentError.message);
}
insertedDocumentId = documentId;
- insertedDocumentOwnerId = user.id;
+ insertedDocumentOwnerId = uploadOwnerId;
const { data: job, error: jobError } = await supabase
.from("ingestion_jobs")
@@ -230,7 +235,7 @@ export async function POST(request: Request) {
.from("documents")
.delete()
.eq("id", documentId)
- .eq("owner_id", user.id);
+ .eq("owner_id", uploadOwnerId);
if (rollbackDocumentError) {
throw new Error(
`Failed to enqueue ingestion job: ${jobError.message}; rollback failed: ${rollbackDocumentError.message}`,
@@ -242,7 +247,7 @@ export async function POST(request: Request) {
}
await writeAuditLog(supabase, {
- ownerId: user.id,
+ ownerId: uploadOwnerId,
action: "document_upload",
resourceType: "document",
resourceId: documentId,
diff --git a/src/components/ClinicalDashboard.tsx b/src/components/ClinicalDashboard.tsx
index 647d6e1a8..aa6c1cb2d 100644
--- a/src/components/ClinicalDashboard.tsx
+++ b/src/components/ClinicalDashboard.tsx
@@ -53,7 +53,8 @@ import { type DocumentDeleteResult } from "@/components/DocumentManagementAction
import { useDismissableLayer } from "@/components/use-dismissable-layer";
import { extractSafetyFindings } from "@/lib/clinical-safety";
import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity";
-import { isLocalNoAuthMode } from "@/lib/env";
+import { isDeployedClinicalKb } from "@/lib/deployed-app";
+import { isLocalNoAuthMode, publicUploadsEnabled } from "@/lib/env";
import {
appBackdrop,
answerSurface,
@@ -1751,7 +1752,11 @@ export function ClinicalDashboard({
process.env.NODE_ENV !== "production" && localProjectReady && hasReadyRequiredPublicSearchConfig(setupChecks);
const canUsePrivateApis =
localProjectReady && (localNoAuthMode || localDevCanAttemptPrivateApis || authStatus === "authenticated");
- const canRunSearch = explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis;
+ const canUploadDocuments =
+ canUsePrivateApis || (publicUploadsEnabled() && canUsePublicSearchApis);
+ const canAttemptDeployedPublicSearch = isDeployedClinicalKb() && localProjectReady;
+ const canRunSearch =
+ explicitDemoMode || canUsePublicSearchApis || canUseDegradedLocalSearchApis || canAttemptDeployedPublicSearch;
const closeDashboardTransientSurfaces = useCallback(
(except?: "guide" | "settings" | "accountSetup" | "mobileSidebar" | "documents" | "upload") => {
if (except !== "guide") setGuideOpen(false);
@@ -1932,20 +1937,25 @@ export function ClinicalDashboard({
const setupResponse = await fetch("/api/setup-status", { cache: "no-store" }).catch(() => null);
if (!setupResponse) {
- setApiUnavailable(true);
- setSetupWarning("The local API is unavailable.");
- return;
- }
-
- if (setupResponse.ok) {
+ if (isDeployedClinicalKb()) {
+ setSetupWarning("Setup status could not be loaded. You can still try search.");
+ } else {
+ setApiUnavailable(true);
+ setSetupWarning("The local API is unavailable.");
+ return;
+ }
+ } else if (setupResponse.ok) {
const payload = (await setupResponse.json()) as SetupStatusPayload;
setSetupChecks(payload.checks ?? fallbackSetupChecks);
nextDemoMode = Boolean(payload.demoMode);
routeIndexingActive = Boolean(payload.indexingActive);
routePollDelayMs = shorterPollDelay(routePollDelayMs, payload.pollAfterMs);
if (nextDemoMode) setDemoMode(true);
+ } else if (isDeployedClinicalKb()) {
+ setSetupWarning("Setup status could not be loaded. You can still try search.");
} else {
setApiUnavailable(true);
+ return;
}
}
@@ -2499,11 +2509,13 @@ export function ClinicalDashboard({
function searchNetworkFailure(label: string) {
const offline = typeof navigator !== "undefined" && !navigator.onLine;
- const localOrigin = typeof window !== "undefined" ? window.location.origin : "the local Clinical KB server";
+ const origin = typeof window !== "undefined" ? window.location.origin : "Clinical KB";
return makeSearchError(
offline
? `${label} could not run because the browser is offline.`
- : `${label} could not reach Clinical KB at ${localOrigin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`,
+ : isDeployedClinicalKb()
+ ? `${label} could not reach Clinical KB at ${origin}. Check your connection and try again shortly.`
+ : `${label} could not reach Clinical KB at ${origin}. The local server may still be starting or restarting; retry shortly or run npm run ensure.`,
undefined,
true,
);
@@ -3582,21 +3594,21 @@ export function ClinicalDashboard({
);
- const showAuthPanel = !clientDemoMode && !canUsePrivateApis;
- const showDegradedNotice = !isOnline || apiUnavailable;
+ const showAuthPanel = false;
+ const showDegradedNotice = !isOnline || (apiUnavailable && !canRunSearch);
const hasMobileBottomSearch = searchMode !== "answer";
const showDesktopHomeComposer =
- !loading &&
!error &&
- ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
- (searchMode === "documents" &&
- activeModeResultKind === "documents" &&
- documentMatches.length === 0 &&
- !modeSearchSubmitted) ||
- (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) ||
- (activeModeResultKind === "differentials" && !modeSearchSubmitted) ||
+ (activeModeResultKind === "tools" ||
activeModeResultKind === "favourites" ||
- activeModeResultKind === "tools");
+ (!loading &&
+ ((activeModeResultKind === "answer" && !answer && !modeSearchSubmitted) ||
+ (searchMode === "documents" &&
+ activeModeResultKind === "documents" &&
+ documentMatches.length === 0 &&
+ !modeSearchSubmitted) ||
+ (searchMode === "prescribing" && activeModeResultKind === "documents" && !modeSearchSubmitted) ||
+ (activeModeResultKind === "differentials" && !modeSearchSubmitted))));
const desktopHomeComposerSlotId = showDesktopHomeComposer ? modeHomeDesktopComposerSlotId : undefined;
// Favourites and Tools are content-rich hubs: they share the centred hero but
// stay top-aligned so their lists start in a stable position.
@@ -3621,14 +3633,18 @@ export function ClinicalDashboard({
summary={
!isOnline
? "Your browser is offline. Existing content may remain visible, but private search and uploads need network access."
- : "The local API did not respond. Check the app server and setup status before retrying."
+ : isDeployedClinicalKb()
+ ? "The app could not reach its API. Try again in a moment."
+ : "The local API did not respond. Check the app server and setup status before retrying."
}
mobileSummary={!isOnline ? "Offline" : "API unavailable"}
>
{!isOnline
? "Reconnect before uploading documents, refreshing source URLs, or generating answers."
- : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
+ : isDeployedClinicalKb()
+ ? "The app will preserve the current view. If this keeps happening, check your connection and try again shortly."
+ : "The app will preserve the current view. Retry after confirming the local server, Supabase, OpenAI, and worker setup."}
);
@@ -3656,7 +3672,7 @@ export function ClinicalDashboard({
{
id: "upload",
label: "Upload",
- summary: uploadReadOnlyMode || !canUsePrivateApis ? "Locked" : "Ready",
+ summary: uploadReadOnlyMode || !canUploadDocuments ? "Locked" : "Ready",
panelId: "dashboard-upload-section",
icon: UploadCloud,
},
@@ -4236,7 +4252,7 @@ export function ClinicalDashboard({
diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx
index 8d875334f..10533849e 100644
--- a/src/components/DocumentViewer.tsx
+++ b/src/components/DocumentViewer.tsx
@@ -1923,12 +1923,22 @@ export function DocumentViewer({
const [viewerModeInitialized] = useState(true);
const generatedSummaryRef = useRef(null);
const { status: authStatus, isConfigured, authorizationHeader, markSessionExpired } = useAuthSession();
+ const [authLoadingTimedOut, setAuthLoadingTimedOut] = useState(false);
const [serverDemoMode, setServerDemoMode] = useState(process.env.NEXT_PUBLIC_DEMO_MODE === "true");
const localNoAuthMode = isLocalNoAuthMode();
const clientDemoMode = localNoAuthMode || serverDemoMode;
const canViewSourceDocuments = localProjectReady;
const canUsePrivateApis = localProjectReady && (clientDemoMode || authStatus === "authenticated");
+ useEffect(() => {
+ if (authStatus !== "loading") {
+ const resetId = window.setTimeout(() => setAuthLoadingTimedOut(false), 0);
+ return () => window.clearTimeout(resetId);
+ }
+ const timeoutId = window.setTimeout(() => setAuthLoadingTimedOut(true), 4_000);
+ return () => window.clearTimeout(timeoutId);
+ }, [authStatus]);
+
useEffect(() => {
if (typeof window === "undefined" || !viewerModeInitialized || hasExplicitPdfViewerMode) return;
@@ -2090,9 +2100,17 @@ export function DocumentViewer({
setTableFacts([]);
setChunks([]);
setIndexHealth(null);
- setViewerError(
- detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.",
- );
+ const message =
+ detailResult.reason instanceof Error ? detailResult.reason.message : "Document could not be loaded.";
+ if (!canUsePrivateApis && !clientDemoMode && message === "Document not found.") {
+ setViewerError(
+ isConfigured
+ ? "Sign in to open private source documents."
+ : "Supabase browser authentication is not configured for private source documents.",
+ );
+ } else {
+ setViewerError(message);
+ }
}
if (signedUrlResult.status === "fulfilled") {
@@ -2153,7 +2171,7 @@ export function DocumentViewer({
useEffect(() => {
const query = sourceSearch.trim();
- if (!canUsePrivateApis || query.length < 2) {
+ if (!canViewSourceDocuments || query.length < 2) {
const reset = window.setTimeout(() => {
setDocumentSearchResults([]);
setSearchingDocument(false);
@@ -2195,7 +2213,7 @@ export function DocumentViewer({
window.clearTimeout(timeout);
controller.abort();
};
- }, [authorizationHeader, canUsePrivateApis, clientDemoMode, documentId, markSessionExpired, sourceSearch]);
+ }, [authorizationHeader, canViewSourceDocuments, clientDemoMode, documentId, markSessionExpired, sourceSearch]);
useEffect(() => {
const updateOnline = () => setIsOnline(navigator.onLine);
@@ -2238,8 +2256,20 @@ export function DocumentViewer({
}
}
- const authViewerError = null;
- const effectiveLoadingDocument = loadingDocument;
+ const authViewerError =
+ !canUsePrivateApis &&
+ !clientDemoMode &&
+ !loadingDocument &&
+ !document &&
+ (authStatus !== "loading" || authLoadingTimedOut) &&
+ (viewerError === "Sign in to open private source documents." ||
+ viewerError === "Supabase browser authentication is not configured for private source documents.")
+ ? viewerError
+ : null;
+ const effectiveLoadingDocument =
+ !canUsePrivateApis && authStatus === "loading" && !authLoadingTimedOut && loadingDocument
+ ? true
+ : loadingDocument;
const effectiveViewerError = authViewerError ?? viewerError;
const viewerState = effectiveLoadingDocument
? "loading"
diff --git a/src/components/applications-launcher-page.tsx b/src/components/applications-launcher-page.tsx
index b47b1267a..a362bc811 100644
--- a/src/components/applications-launcher-page.tsx
+++ b/src/components/applications-launcher-page.tsx
@@ -906,12 +906,17 @@ export function ApplicationsLauncherWorkspace({
}: ApplicationsLauncherWorkspaceProps) {
const [uncontrolledQuery, setUncontrolledQuery] = useState("");
const [activeFilter, setActiveFilter] = useState("all");
- const [selectedId, setSelectedId] = useState(() => initialToolId(controlledQuery));
const isDashboardTools = variant === "dashboard-tools";
const [detailOpen, setDetailOpen] = useState(!isDashboardTools && showDetailPanel === true);
const copy = isDashboardTools ? dashboardToolsLauncherCopy : standaloneLauncherCopy;
const query = controlledQuery ?? uncontrolledQuery;
const normalizedQuery = query.trim().toLowerCase();
+ const queryDerivedId = useMemo(() => initialToolId(query), [query]);
+ const [selection, setSelection] = useState(() => ({
+ queryKey: (controlledQuery ?? "").trim().toLowerCase(),
+ id: initialToolId(controlledQuery),
+ }));
+ const selectedId = selection.queryKey === normalizedQuery ? selection.id : queryDerivedId;
const filteredApps = useMemo(() => {
return launcherApps.filter((app) => {
@@ -941,7 +946,7 @@ export function ApplicationsLauncherWorkspace({
}
function openTool(id: string) {
- setSelectedId(id);
+ setSelection({ queryKey: normalizedQuery, id });
setDetailOpen(true);
}
diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx
index b25fae026..35062b110 100644
--- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx
+++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx
@@ -188,7 +188,11 @@ export function UploadPanel({
return;
}
if (!canUpload) {
- changeStatus("Sign in before uploading private guideline files.");
+ changeStatus(
+ demoMode
+ ? demoUploadReadOnlyMessage
+ : "Uploads are unavailable until this public workspace is configured.",
+ );
return;
}
@@ -202,7 +206,7 @@ export function UploadPanel({
setUploading(true);
changeStatus(
files.length === 1
- ? "Uploading private document to Supabase Storage..."
+ ? "Uploading document to Supabase Storage..."
: `Uploading 1 of ${files.length}: ${files[0].name}`,
);
@@ -226,8 +230,8 @@ export function UploadPanel({
if (failures.length === 0) {
changeStatus(
files.length === 1
- ? "Successfully uploaded private document to storage queue."
- : `Successfully uploaded ${files.length} private documents.`,
+ ? "Successfully uploaded document to storage queue."
+ : `Successfully uploaded ${files.length} documents.`,
);
if (input) input.value = "";
onUploaded();
diff --git a/src/components/clinical-dashboard/document-search-results.tsx b/src/components/clinical-dashboard/document-search-results.tsx
index 5efffa0ad..d14f25ff1 100644
--- a/src/components/clinical-dashboard/document-search-results.tsx
+++ b/src/components/clinical-dashboard/document-search-results.tsx
@@ -24,6 +24,7 @@ import {
import { DocumentTagCloud } from "@/components/DocumentTagCloud";
import { documentDisplayTitle } from "@/components/DocumentOrganizationBadges";
+import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { ModeHomeTemplate } from "@/components/mode-home-template";
import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-results-header-band";
import { SafeBoldText } from "@/components/SafeBoldText";
@@ -726,7 +727,7 @@ function RecordRegistryNotice({ status, mode }: { status: RegistryRequestStatus;
status === "loading"
? { Icon: Loader2, spin: true, tone: "info" as const, text: `Loading your ${noun} registry...` }
: status === "unauthorized"
- ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Sign in to search your ${noun} registry.` }
+ ? { Icon: Shield, spin: false, tone: "warning" as const, text: `Your session expired. Sign in again to search your private ${noun} registry.` }
: {
Icon: ShieldAlert,
spin: false,
@@ -834,9 +835,11 @@ export function DocumentSearchResultsPanel({
}
const unavailableMessage = apiUnavailable
- ? "The local API is unavailable. Check the app server before searching documents."
+ ? isDeployedClinicalKb()
+ ? "Clinical KB could not be reached. Check your connection and try again shortly."
+ : "The local API is unavailable. Check the app server before searching documents."
: authUnavailable
- ? "Sign in or enable local no-auth mode before listing private indexed documents."
+ ? "Your session expired. Sign in again to view private indexed documents."
: !realDataReady
? setupWarning || "Complete the search setup before using Documents mode."
: null;
diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
index 05596db09..597d4d459 100644
--- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
+++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx
@@ -31,6 +31,7 @@ import { SearchResultsHeaderBand } from "@/components/clinical-dashboard/search-
import { useSearchCommand } from "@/components/clinical-dashboard/search-command-context";
import { useMedicationCatalog } from "@/components/clinical-dashboard/use-medication-catalog";
import { medicationMatchesCommandScopes } from "@/lib/search-command-surface";
+import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { cn, toneDanger, toneInfo, toneNeutral, toneSuccess, toneWarning } from "@/components/ui-primitives";
type MedicationPrescribingWorkspaceProps = {
@@ -414,9 +415,13 @@ function StatusNotice({
}: Pick) {
if (realDataReady && !authUnavailable && !apiUnavailable && !setupWarning) return null;
const message = authUnavailable
- ? "Private medication search is waiting for sign-in."
+ ? isDeployedClinicalKb()
+ ? "Sign in to search your private medication library."
+ : "Private medication search is waiting for sign-in."
: apiUnavailable
- ? "Medication search is using the local mockup while the API is unavailable."
+ ? isDeployedClinicalKb()
+ ? "Medication search is temporarily unavailable. Try again shortly."
+ : "Medication search is using the local mockup while the API is unavailable."
: setupWarning || "Medication search setup is still warming up.";
return (
diff --git a/src/components/forms/forms-home-page.tsx b/src/components/forms/forms-home-page.tsx
index eb3e040de..03a6495c1 100644
--- a/src/components/forms/forms-home-page.tsx
+++ b/src/components/forms/forms-home-page.tsx
@@ -90,10 +90,10 @@ export function FormsHomePage() {
) : registry.status === "unauthorized" ? (
) : registry.status === "error" ? (
}
- title="Sign in required"
- body={`Sign in to view this ${copy.noun}. Registry records are private to your workspace.`}
- action={{ href: "/", label: "Go to sign in" }}
+ title="Session expired"
+ body={`Your session expired. Sign in again to view your private ${copy.noun}. Public ${copy.noun} records remain available from search.`}
+ action={{ href: "/", label: "Open account setup" }}
/>
);
}
diff --git a/src/components/services/services-home-page.tsx b/src/components/services/services-home-page.tsx
index 132432f57..6624fc2e6 100644
--- a/src/components/services/services-home-page.tsx
+++ b/src/components/services/services-home-page.tsx
@@ -92,10 +92,10 @@ export function ServicesHomePage() {
) : registry.status === "unauthorized" ? (
) : registry.status === "error" ? (
> = {
answer: { limit: 6, windowSeconds: 60 },
search: { limit: 60, windowSeconds: 60 },
+ document_read: { limit: 45, windowSeconds: 60 },
};
type SupabaseAdmin = ReturnType;
diff --git a/src/lib/deployed-app.ts b/src/lib/deployed-app.ts
new file mode 100644
index 000000000..82bb8f3a7
--- /dev/null
+++ b/src/lib/deployed-app.ts
@@ -0,0 +1,3 @@
+export function isDeployedClinicalKb() {
+ return process.env.NODE_ENV === "production";
+}
diff --git a/src/lib/env.ts b/src/lib/env.ts
index ac5d8dbf5..f6acba54f 100644
--- a/src/lib/env.ts
+++ b/src/lib/env.ts
@@ -11,6 +11,8 @@ const envSchema = z.object({
LOCAL_NO_AUTH: z.enum(["true", "false"]).optional().default("false"),
LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(),
LOCAL_NO_AUTH_OWNER_ID: z.string().optional(),
+ PUBLIC_WORKSPACE_OWNER_ID: z.string().uuid().optional(),
+ NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: z.enum(["true", "false"]).optional(),
OPENAI_API_KEY: z.string().optional(),
OPENAI_EMBEDDING_MODEL: z.string().default("text-embedding-3-small"),
// Must match the vector(N) dimension in supabase/schema.sql. Changing the embedding
@@ -192,3 +194,11 @@ export function isLocalNoAuthMode() {
return process.env.NODE_ENV !== "production" && (publicNoAuth || serverNoAuth);
}
+
+export function publicWorkspaceOwnerId() {
+ return env.PUBLIC_WORKSPACE_OWNER_ID?.trim() || null;
+}
+
+export function publicUploadsEnabled() {
+ return env.NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED === "true";
+}
diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts
index 8ae3473e1..04a9da0ba 100644
--- a/src/lib/public-api-access.ts
+++ b/src/lib/public-api-access.ts
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import type { createAdminClient } from "@/lib/supabase/admin";
+import { consumeSubjectApiRateLimit, allowRateLimitInMemoryFallbackOnUnavailable, type ApiRateLimitResult } from "@/lib/api-rate-limit";
import { getOptionalAuthenticatedUser } from "@/lib/supabase/auth";
type AdminClient = ReturnType;
@@ -25,22 +26,35 @@ export function anonymousApiSubjectKey(request: Request) {
return `anon:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`;
}
-export function hasPublicApiAuthSignal(request: Request) {
- const authorization = request.headers.get("authorization") ?? "";
- if (/^Bearer\s+\S+/i.test(authorization)) return true;
-
+export function hasSessionCookieSignal(request: Request) {
const cookieHeader = request.headers.get("cookie") ?? "";
return cookieHeader.includes("sb-");
}
+export function hasBearerAuthAttempt(request: Request) {
+ const authorization = request.headers.get("authorization") ?? "";
+ return /^Bearer\s+\S+/i.test(authorization);
+}
+
+/** True when the request may carry a durable Supabase session (cookie), not a bare bearer attempt. */
+export function hasPublicApiAuthSignal(request: Request) {
+ return hasSessionCookieSignal(request);
+}
+
+/** Anonymous callers with no cookie or bearer skip auth resolution and rate limits on curated public catalogs. */
+export function shouldResolvePublicCatalogAccess(request: Request) {
+ return hasSessionCookieSignal(request) || hasBearerAuthAttempt(request);
+}
+
type OwnerScopedQuery = {
eq(column: string, value: unknown): T;
is(column: string, value: null): T;
+ or(filters: string): T;
};
-/** Scope document reads to the authenticated owner or public (owner_id IS NULL) rows. */
+/** Scope reads to public rows (owner_id IS NULL) and, when signed in, the caller's owned rows. */
export function withOwnerReadScope>(query: T, ownerId: string | undefined): T {
- if (ownerId) return query.eq("owner_id", ownerId);
+ if (ownerId) return query.or(`owner_id.eq.${ownerId},owner_id.is.null`);
return query.is("owner_id", null);
}
@@ -60,3 +74,17 @@ export async function publicAccessContext(request: Request, supabase: AdminClien
rateLimitSubject: { kind: "anonymous", subjectKey: anonymousApiSubjectKey(request) } satisfies RateLimitSubject,
};
}
+
+export async function enforceDocumentReadRateLimit(
+ request: Request,
+ supabase: AdminClient,
+): Promise<{ access: Awaited>; rateLimit: ApiRateLimitResult }> {
+ const access = await publicAccessContext(request, supabase);
+ const rateLimit = await consumeSubjectApiRateLimit({
+ supabase,
+ subject: access.rateLimitSubject,
+ bucket: "document_read",
+ allowInMemoryFallbackOnUnavailable: allowRateLimitInMemoryFallbackOnUnavailable(),
+ });
+ return { access, rateLimit };
+}
diff --git a/src/lib/rag.ts b/src/lib/rag.ts
index 6adea390f..7192677a3 100644
--- a/src/lib/rag.ts
+++ b/src/lib/rag.ts
@@ -1441,7 +1441,14 @@ function stableHash(value: string) {
export function retrievalPlanCacheQuery(
args: Pick<
SearchChunksArgs,
- "query" | "documentId" | "documentIds" | "ownerId" | "queryMode" | "topK" | "minSimilarity" | "forceEmbedding"
+ | "query"
+ | "documentId"
+ | "documentIds"
+ | "ownerId"
+ | "queryMode"
+ | "topK"
+ | "minSimilarity"
+ | "forceEmbedding"
>,
queryClass?: RagQueryClass,
queryVariants: string[] = [],
@@ -1456,6 +1463,7 @@ export function retrievalPlanCacheQuery(
`mode:${modeKey(args)}`,
`topK:${args.topK ?? 8}`,
`min:${args.minSimilarity ?? 0.15}`,
+ `forceEmbedding:${args.forceEmbedding ? "1" : "0"}`,
`rag:${ragDeepMemoryVersion}`,
`force:${args.forceEmbedding ? 1 : 0}`,
].join("|");
@@ -1554,7 +1562,7 @@ type SharedCacheMissReason =
function sharedCacheSelector(
supabase: ReturnType,
kind: SharedCacheKind,
- args: Pick,
+ args: Pick,
indexingVersion: string,
normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)),
) {
@@ -1719,7 +1727,10 @@ async function getSharedCachedSearch(
}
async function getSharedCachedAnswer(
- args: Pick,
+ args: Pick<
+ SearchChunksArgs,
+ "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding"
+ >,
startedAt: number,
) {
if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return null;
@@ -1752,7 +1763,7 @@ async function getSharedCachedAnswer(
async function replaceSharedCacheRow(
kind: SharedCacheKind,
- args: Pick,
+ args: Pick,
payload: unknown,
ttlMs: number,
normalizedQuery: string = queryCacheKeyForStorage(normalizedCacheQuery(`${modeKey(args)} ${args.query}`)),
@@ -1804,7 +1815,10 @@ function setSharedCachedSearch(
}
function setSharedCachedAnswer(
- args: Pick,
+ args: Pick<
+ SearchChunksArgs,
+ "query" | "documentId" | "documentIds" | "ownerId" | "skipCache" | "queryMode" | "forceEmbedding"
+ >,
answer: RagAnswer,
) {
if (args.skipCache || env.RAG_ANSWER_CACHE_TTL_MS <= 0) return;
@@ -5386,6 +5400,9 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
// When the provider is source-only (offline mode, or auto mode without a usable key) we must
// never call OpenAI for embeddings; retrieval falls back to the lexical text-fast-path only.
const sourceOnlyRetrieval = isSourceOnlyMode();
+ if (args.forceEmbedding && sourceOnlyRetrieval) {
+ throw new Error("forceEmbedding requires embedding-capable retrieval; source-only mode cannot exercise vectors.");
+ }
// A3: shared across every withMemoryBoostedCandidates call in this request so the same
// owner/query memory cards are fetched at most once per (query, embedding-present, count).
const memoryCardCache: MemoryCardCache = new Map();
@@ -5463,7 +5480,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
telemetry.shared_cache_miss_reason = sharedCached.reason;
}
- if (shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) {
+ if (!args.forceEmbedding && shouldApplyUnsupportedSearchShortCircuit(retrievalQuery, queryAnalysis, ragAliasExpansions)) {
// Item 10 follow-up (RC6): a typo can make an on-topic query ("schizophrenai management") look
// unsupported and short-circuit before any layer runs. Before giving up, trigram-correct the
// query against the known clinical-term vocabulary; if it changes, re-run the whole retrieval
@@ -5722,7 +5739,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
telemetry,
});
const coverageGate = evaluateEvidenceCoverageGate(args.query, coverageGateResults, queryClassification.queryClass);
- applyCoverageGateTelemetry(telemetry, coverageGate, coverageGate.accepted);
+ applyCoverageGateTelemetry(telemetry, coverageGate, !args.forceEmbedding && coverageGate.accepted);
if (!args.forceEmbedding && coverageGate.accepted) {
telemetry.retrieval_strategy = coverageGate.strategy;
recordSearchScoreTelemetry(telemetry, coverageGateResults);
@@ -5752,7 +5769,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
} catch (error) {
// In auto mode a failed embedding call (e.g. quota exhausted) degrades to the lexical
// results already gathered rather than failing the whole search. "openai" mode rethrows.
- if (!allowsAutoDegrade()) throw error;
+ if (args.forceEmbedding || !allowsAutoDegrade()) throw error;
telemetry.embedding_skipped = true;
telemetry.embedding_skip_reason = sourceOnlyReason(error);
telemetry.vector_skipped_reason = classifyProviderFailure(error);
@@ -5854,10 +5871,14 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
latencyMs: hybridResult.latencyMs,
topScore: layerTopScore((hybridData ?? []) as SearchResult[]),
});
+ const vectorCandidates = mergeSearchResults(
+ mergeSearchResults((hybridData ?? []) as SearchResult[], embeddingFieldCandidates),
+ indexUnitCandidates,
+ );
if (!hybridError) {
const rerankStartedAt = Date.now();
- const merged = mergeSearchResults((hybridData ?? []) as SearchResult[], textFastResults);
+ const merged = args.forceEmbedding ? vectorCandidates : mergeSearchResults(vectorCandidates, textFastResults);
const mergedWithMetadata = await attachDocumentRankingMetadata(supabase, merged, args.ownerId);
const memoryBoost = await withMemoryBoostedCandidates({
supabase,
@@ -5918,7 +5939,7 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
return (data ?? []) as SearchResult[];
}),
).catch((error) => {
- if (textFastResults.length > 0) return [] as SearchResult[][];
+ if (!args.forceEmbedding && textFastResults.length > 0) return [] as SearchResult[][];
throw error;
});
const fallbackLatencyMs = Date.now() - fallbackRpcStartedAt;
@@ -5930,9 +5951,13 @@ export async function searchChunksWithTelemetry(args: SearchChunksArgs) {
});
const rerankStartedAt = Date.now();
+ const fallbackVectorCandidates = mergeSearchResults(
+ mergeSearchResults(resultSets.flat(), embeddingFieldCandidates),
+ indexUnitCandidates,
+ );
const mergedWithMetadata = await attachDocumentRankingMetadata(
supabase,
- mergeSearchResults(resultSets.flat(), textFastResults),
+ args.forceEmbedding ? fallbackVectorCandidates : mergeSearchResults(fallbackVectorCandidates, textFastResults),
args.ownerId,
);
const memoryBoost = await withMemoryBoostedCandidates({
diff --git a/src/lib/supabase/auth.ts b/src/lib/supabase/auth.ts
index 044384b07..3ecf6122c 100644
--- a/src/lib/supabase/auth.ts
+++ b/src/lib/supabase/auth.ts
@@ -31,12 +31,14 @@ function readCookies(cookieHeader: string | null): Map {
return cookies;
}
-function extractSessionAccessToken(request: Request): string | null {
+function extractBearerAccessToken(request: Request): string | null {
const authorization = request.headers.get("authorization") ?? "";
const match = authorization.match(/^Bearer\s+(.+)$/i);
const headerToken = match?.[1]?.trim();
- if (headerToken) return headerToken;
+ return headerToken || null;
+}
+function extractCookieSessionAccessToken(request: Request): string | null {
const cookies = readCookies(request.headers.get("cookie"));
const legacyAccessToken = cookies.get("sb-access-token")?.trim();
if (legacyAccessToken) return legacyAccessToken;
@@ -57,6 +59,19 @@ function extractSessionAccessToken(request: Request): string | null {
return null;
}
+function extractSessionAccessToken(request: Request): string | null {
+ return extractBearerAccessToken(request) ?? extractCookieSessionAccessToken(request);
+}
+
+async function getUserFromAccessToken(
+ supabase: AdminClient,
+ token: string,
+): Promise {
+ const { data, error } = await supabase.auth.getUser(token);
+ if (error || !data.user?.id) return null;
+ return { id: data.user.id };
+}
+
export class AuthenticationError extends Error {
constructor(message = "Authentication required.") {
super(message);
@@ -72,7 +87,7 @@ export function unauthorizedResponse(error?: AuthenticationError) {
/**
* Resolve the user from the `@supabase/ssr` cookie session. The
* `sb-[-auth-token` cookie it writes is base64-encoded (and chunked when
- * large), which `extractSessionAccessToken`'s plain-JSON parser cannot read, so
+ * large), which `extractCookieSessionAccessToken`'s plain-JSON parser cannot read, so
* this uses the ssr server client to decode + validate it. Returns null when
* the public env is absent or no `sb-` cookie is present.
*/
@@ -102,22 +117,28 @@ async function getUserFromRequestCookies(request: Request): Promise {
- // 1. Bearer token / legacy cookie (programmatic callers + current clients).
- const token = extractSessionAccessToken(request);
- if (token) {
- const { data, error } = await supabase.auth.getUser(token);
- if (!error && data.user?.id) {
- return { id: data.user.id };
- }
+async function resolveOptionalAuthenticatedUser(
+ request: Request,
+ supabase: AdminClient,
+): Promise {
+ const bearerToken = extractBearerAccessToken(request);
+ if (bearerToken) {
+ const bearerUser = await getUserFromAccessToken(supabase, bearerToken);
+ if (bearerUser) return bearerUser;
}
- // 2. @supabase/ssr cookie session (persistent cookie logins).
- const cookieUser = await getUserFromRequestCookies(request);
- if (cookieUser) {
- return cookieUser;
+ const cookieToken = extractCookieSessionAccessToken(request);
+ if (cookieToken && cookieToken !== bearerToken) {
+ const cookieTokenUser = await getUserFromAccessToken(supabase, cookieToken);
+ if (cookieTokenUser) return cookieTokenUser;
}
+ return getUserFromRequestCookies(request);
+}
+
+export async function requireAuthenticatedUser(request: Request, supabase: AdminClient): Promise {
+ const user = await resolveOptionalAuthenticatedUser(request, supabase);
+ if (user) return user;
throw new AuthenticationError();
}
@@ -125,14 +146,8 @@ export async function getOptionalAuthenticatedUser(
request: Request,
supabase: AdminClient,
): Promise {
- const token = extractSessionAccessToken(request);
- if (token) {
- const { data, error } = await supabase.auth.getUser(token);
- if (!error && data.user?.id) {
- return { id: data.user.id };
- }
- // Invalid or expired Bearer token: fall through to cookie session, then anonymous.
- }
-
- return getUserFromRequestCookies(request);
+ return resolveOptionalAuthenticatedUser(request, supabase);
}
+
+// Retained for callers that only need a single token string.
+export { extractSessionAccessToken };
diff --git a/src/lib/use-registry-records.ts b/src/lib/use-registry-records.ts
index 0cf8a09da..79599d5af 100644
--- a/src/lib/use-registry-records.ts
+++ b/src/lib/use-registry-records.ts
@@ -82,8 +82,12 @@ export function useRegistryRecords(
// effect retries with a real header; never expire the session from an
// auth-loading 401. Demo/local API responses can still resolve fast.
if (authStatus === "loading") return;
- if (authStatus === "authenticated") markSessionExpired();
- setState(recordsState("unauthorized", kind));
+ if (authStatus === "authenticated") {
+ markSessionExpired();
+ setState(recordsState("unauthorized", kind));
+ return;
+ }
+ setState(recordsState("error", kind));
return;
}
if (!response.ok) {
@@ -140,8 +144,12 @@ export function useRegistryRecord(kind: RegistryRecordKind, slug: string): Regis
if (!active) return;
if (response.status === 401) {
if (authStatus === "loading") return;
- if (authStatus === "authenticated") markSessionExpired();
- setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null });
+ if (authStatus === "authenticated") {
+ markSessionExpired();
+ setState({ status: "unauthorized", record: null, linkedDocuments: [], demoMode: false, governance: null });
+ return;
+ }
+ setState({ status: "error", record: null, linkedDocuments: [], demoMode: false, governance: null });
return;
}
if (response.status === 404) {
diff --git a/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
new file mode 100644
index 000000000..473fdb90c
--- /dev/null
+++ b/supabase/migrations/20260705133000_tighten_search_document_chunks_owner_scope.sql
@@ -0,0 +1,72 @@
+-- Tighten search_document_chunks owner scoping so null p_owner_id only matches public
+-- documents (owner_id IS NULL) and authenticated callers can search both owned and public
+-- documents without matching other owners' private rows.
+
+create or replace function public.search_document_chunks(
+ p_document_id uuid,
+ p_query text,
+ match_count integer default 20,
+ p_owner_id uuid default null
+)
+returns table (
+ id uuid,
+ page_number integer,
+ chunk_index integer,
+ section_heading text,
+ content text,
+ image_ids uuid[],
+ text_rank real,
+ trigram_score real
+)
+language sql
+stable
+set search_path = public, extensions, pg_temp
+as $$
+ with normalized as (
+ select
+ websearch_to_tsquery('english', coalesce(p_query, '')) as query_tsv,
+ lower(trim(coalesce(p_query, ''))) as query_text
+ ),
+ tokens as (
+ select distinct token
+ from normalized,
+ lateral regexp_split_to_table(normalized.query_text, '\s+') as token
+ where length(token) >= 3
+ )
+ select
+ c.id,
+ c.page_number,
+ c.chunk_index,
+ c.section_heading,
+ c.content,
+ c.image_ids,
+ ts_rank_cd(c.search_tsv, normalized.query_tsv)::real as text_rank,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text)::real as trigram_score
+ from public.document_chunks c
+ join public.documents d on d.id = c.document_id
+ cross join normalized
+ where c.document_id = p_document_id
+ and d.status = 'indexed'
+ and (
+ (p_owner_id is null and d.owner_id is null)
+ or (p_owner_id is not null and (d.owner_id is null or d.owner_id = p_owner_id))
+ )
+ and (
+ c.search_tsv @@ normalized.query_tsv
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % normalized.query_text
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || normalized.query_text || '%'
+ or exists (
+ select 1
+ from tokens t
+ where lower(coalesce(c.section_heading, '') || ' ' || c.content) like '%' || t.token || '%'
+ or lower(coalesce(c.section_heading, '') || ' ' || c.content) % t.token
+ )
+ )
+ order by
+ ts_rank_cd(c.search_tsv, normalized.query_tsv) desc,
+ similarity(lower(coalesce(c.section_heading, '') || ' ' || c.content), normalized.query_text) desc,
+ c.chunk_index asc
+ limit least(greatest(match_count, 1), 80);
+$$;
+
+grant execute on function public.search_document_chunks(uuid, text, integer, uuid) to service_role;
diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts
new file mode 100644
index 000000000..c25bb36c5
--- /dev/null
+++ b/tests/api-rate-limit-fallback.test.ts
@@ -0,0 +1,23 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.resetModules();
+});
+
+describe("allowRateLimitInMemoryFallbackOnUnavailable", () => {
+ it("enables fallback for production deployments", async () => {
+ vi.stubEnv("NODE_ENV", "production");
+ const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit");
+ expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true);
+ });
+
+ it("enables fallback for local no-auth development", async () => {
+ vi.stubEnv("NODE_ENV", "development");
+ vi.doMock("@/lib/env", () => ({
+ isLocalNoAuthMode: () => true,
+ }));
+ const { allowRateLimitInMemoryFallbackOnUnavailable } = await import("../src/lib/api-rate-limit");
+ expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true);
+ });
+});
diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts
index 5d68487d8..a23c87c0e 100644
--- a/tests/api-validation-contract.test.ts
+++ b/tests/api-validation-contract.test.ts
@@ -147,7 +147,23 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([])) {
calls.push(call);
return new QueryBuilder(call, resolve);
}),
- rpc: vi.fn(async () => ok([])),
+ rpc: vi.fn(async (name: string) => {
+ if (name === "consume_api_subject_rate_limit" || name === "consume_api_rate_limit") {
+ return {
+ data: [
+ {
+ limited: false,
+ limit_value: 100,
+ remaining: 99,
+ retry_after_seconds: 60,
+ reset_at: new Date(Date.now() + 60_000).toISOString(),
+ },
+ ],
+ error: null,
+ };
+ }
+ return ok([]);
+ }),
storage: { from: storageFrom },
storageMocks: { upload, remove, createSignedUrl, storageFrom },
};
diff --git a/tests/eval-quality.test.ts b/tests/eval-quality.test.ts
index d4888a935..fa4c7e48c 100644
--- a/tests/eval-quality.test.ts
+++ b/tests/eval-quality.test.ts
@@ -9,12 +9,13 @@ import {
sourceWarningsForRagQualityAnswer,
type RagQualityResult,
} from "../scripts/eval-quality";
-import type { GoldenRetrievalResult } from "../scripts/eval-retrieval";
+import { evaluateGoldenRetrievalCase, type GoldenRetrievalResult } from "../scripts/eval-retrieval";
function retrievalResult(overrides: Partial = {}): GoldenRetrievalResult {
const base: GoldenRetrievalResult = {
id: "retrieval-1",
query: "What ANC threshold should withhold clozapine?",
+ forceEmbedding: false,
expectedQueryClass: "table_threshold",
actualQueryClass: "table_threshold",
expectedDocumentSubstrings: ["clozapine.pdf"],
@@ -166,6 +167,8 @@ describe("eval quality reporting", () => {
structured_threshold_text_match: 1,
});
expect(report.retrieval.summary.second_stage_rerank_rate).toBe(0.5);
+ expect(report.retrieval.summary.force_embedding_case_count).toBe(0);
+ expect(report.retrieval.summary.force_embedding_failure_count).toBe(0);
expect(report.rag.summary.unsupported_correct_rate).toBe(0);
expect(report.rag.summary.numeric_grounding_failure_rate).toBeCloseTo(0.3333, 4);
expect(report.rag.summary.source_governance_danger_failure_rate).toBeCloseTo(0.3333, 4);
@@ -180,6 +183,53 @@ describe("eval quality reporting", () => {
);
});
+ it("fails forced-embedding retrieval cases that return from cache, coverage, or lexical paths", () => {
+ const result = evaluateGoldenRetrievalCase({
+ testCase: {
+ id: "vector-regression",
+ query: "How is panic disorder managed?",
+ expectedQueryClass: "broad_summary",
+ expectedDocumentSubstrings: [],
+ expectedContentTerms: [],
+ topK: 8,
+ expectTableEvidence: false,
+ forceEmbedding: true,
+ },
+ results: [],
+ telemetry: {
+ query_class: "broad_summary",
+ retrieval_strategy: "text_fast_path",
+ embedding_skipped: true,
+ embedding_skip_reason: "strong_document_text_score",
+ text_fast_path_reason: "strong_document_text_score",
+ text_candidate_budget: 32,
+ text_candidate_count: 5,
+ vector_candidate_count: 0,
+ retrieval_layer_counts: { text_candidates: 5 },
+ coverage_gate_decision: "accepted",
+ coverage_gate_reason: "document_title_evidence_gate",
+ second_stage_rerank_used: false,
+ },
+ latencyMs: 10,
+ });
+
+ expect(result.forceEmbedding).toBe(true);
+ expect(result.failures).toEqual(
+ expect.arrayContaining([
+ "forceEmbedding expected embedding to run",
+ "forceEmbedding returned lexical strategy text_fast_path",
+ "forceEmbedding returned coverage gate",
+ "forceEmbedding found no vector-layer candidates",
+ ]),
+ );
+ const report = buildEvalQualityReport({ retrievalResults: [result], ragResults: [] });
+ expect(report.retrieval.summary.force_embedding_case_count).toBe(1);
+ expect(report.retrieval.summary.force_embedding_failure_count).toBe(1);
+ expect(report.blocking_threshold_failures).toEqual(
+ expect.arrayContaining([expect.stringContaining("retrieval force_embedding_failure_count 1 above 0")]),
+ );
+ });
+
it("derives source governance warnings for direct RAG answers without precomputed warnings", () => {
const warnings = sourceWarningsForRagQualityAnswer({
sourceGovernanceWarnings: undefined,
diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts
index 9d91205f7..92438c369 100644
--- a/tests/private-access-routes.test.ts
+++ b/tests/private-access-routes.test.ts
@@ -276,7 +276,14 @@ function createSupabaseMock(resolve: QueryResolver = defaultQueryResolver) {
function mockRuntime(
client: ReturnType,
ragMock?: Record,
- options: { localNoAuth?: boolean; localOwnerEmail?: string; providerMode?: string; openAiKey?: string } = {},
+ options: {
+ localNoAuth?: boolean;
+ localOwnerEmail?: string;
+ providerMode?: string;
+ openAiKey?: string;
+ publicUploadsEnabled?: boolean;
+ publicWorkspaceOwnerId?: string;
+ } = {},
) {
vi.resetModules();
vi.doUnmock("@/lib/rag");
@@ -298,11 +305,15 @@ function mockRuntime(
OPENAI_API_KEY: options.openAiKey ?? "sk-test",
RAG_PROVIDER_MODE: options.providerMode ?? "auto",
LOCAL_NO_AUTH_OWNER_EMAIL: options.localOwnerEmail,
+ PUBLIC_WORKSPACE_OWNER_ID: options.publicWorkspaceOwnerId,
+ NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED: options.publicUploadsEnabled ? "true" : undefined,
WORKER_STALE_AFTER_MINUTES: 10,
WORKER_MAX_ATTEMPTS: 3,
},
isDemoMode: () => false,
isLocalNoAuthMode: () => Boolean(options.localNoAuth),
+ publicWorkspaceOwnerId: () => options.publicWorkspaceOwnerId ?? null,
+ publicUploadsEnabled: () => Boolean(options.publicUploadsEnabled),
requireOpenAIEnv: () => undefined,
requireServerEnv: () => undefined,
}));
@@ -322,6 +333,15 @@ function localPortRequest(port: number, path: string, init?: RequestInit) {
return new Request(`http://localhost:${port}${path}`, init);
}
+function matchesOwnerReadScope(call: QueryCall, ownerId?: string | null) {
+ if (ownerId === undefined || ownerId === null) {
+ return call.filters.some((filter) => filter.column === "owner_id" && filter.value === null);
+ }
+ return call.orFilters.some(
+ (filter) => filter.includes(`owner_id.eq.${ownerId}`) && filter.includes("owner_id.is.null"),
+ );
+}
+
function authenticatedRequest(path: string, init?: RequestInit) {
return request(path, {
...init,
@@ -386,7 +406,7 @@ describe("private document API access", () => {
const body = await payload(response);
expect(response.status).toBe(200);
- expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
+ expect(body.documents).toEqual(documents);
expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: null });
expect(client.auth.getUser).not.toHaveBeenCalled();
});
@@ -416,7 +436,8 @@ describe("private document API access", () => {
}),
);
- expect(response.status).toBe(401);
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.from).not.toHaveBeenCalled();
});
@@ -438,7 +459,8 @@ describe("private document API access", () => {
}),
);
- expect(response.status).toBe(401);
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
expect(client.auth.getUser).not.toHaveBeenCalled();
expect(client.from).not.toHaveBeenCalled();
});
@@ -459,7 +481,7 @@ describe("private document API access", () => {
expect(client.calls[0].filters).not.toContainEqual({ column: "owner_id", value: userId });
});
- it("filters authenticated document listing by owner", async () => {
+ it("filters authenticated document listing by owner and public rows", async () => {
const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }];
const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
mockRuntime(client);
@@ -471,9 +493,8 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
expect(body.pagination).toMatchObject({ limit: 100, offset: 0, nextOffset: 1, hasMore: false });
- expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
- expect(client.calls[0].selected).toContain("id,owner_id,title");
- expect(client.calls[0].selected).not.toBe("*");
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
+ expect(client.calls[0].selected).toContain("storage_path");
expect(client.calls[0].range).toEqual({ from: 0, to: 99 });
});
@@ -489,7 +510,7 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(client.auth.getUser).toHaveBeenCalledWith(token);
expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
- expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
});
it("accepts Supabase auth token cookies for private document access", async () => {
@@ -504,7 +525,124 @@ describe("private document API access", () => {
expect(response.status).toBe(200);
expect(client.auth.getUser).toHaveBeenCalledWith(token);
expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
- expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId });
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
+ });
+
+ it("allows authenticated users to read public document detail", async () => {
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
+ return ok({
+ id: documentId,
+ owner_id: null,
+ title: "Public guideline",
+ file_name: "guideline.pdf",
+ file_type: "application/pdf",
+ page_count: 2,
+ chunk_count: 1,
+ metadata: { index_generation_id: "generation-a" },
+ });
+ }
+ if (call.table === "document_pages") return ok([]);
+ if (call.table === "document_images") return ok([]);
+ if (call.table === "document_chunks") return ok([]);
+ if (call.table === "document_table_facts") return ok([]);
+ return ok([]);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(body.document).toMatchObject({ id: documentId, title: "Public guideline", owner_id: null });
+ expect(client.calls[0].orFilters).toContain(`owner_id.eq.${userId},owner_id.is.null`);
+ });
+
+ it("allows authenticated users to open public document signed URLs", async () => {
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
+ return ok({ storage_path: "public/documents/guideline.pdf", file_type: "application/pdf" });
+ }
+ return ok(null);
+ });
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/[id]/signed-url/route");
+
+ const response = await GET(authenticatedRequest(`/api/documents/${documentId}/signed-url`), {
+ params: Promise.resolve({ id: documentId }),
+ });
+
+ expect(response.status).toBe(200);
+ expect((await payload(response)).url).toContain("public/documents/guideline.pdf");
+ });
+
+ it("recovers valid cookie auth when a stale bearer header is also present", async () => {
+ const documents = [{ id: documentId, owner_id: userId, title: "Owned document" }];
+ const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(
+ request("/api/documents", {
+ headers: {
+ authorization: "Bearer expired-token",
+ cookie: `sb-access-token=${token}`,
+ },
+ }),
+ );
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(body.documents).toEqual(documents.map((document) => ({ ...document, labels: [], summary: null })));
+ expect(client.auth.getUser).toHaveBeenCalledWith(token);
+ });
+
+ it("omits internal document list fields for anonymous callers", async () => {
+ const documents = [{ id: documentId, owner_id: null, title: "Public guideline", status: "indexed" }];
+ const client = createSupabaseMock((call) => (call.table === "documents" ? ok(documents) : ok([])));
+ mockRuntime(client);
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(request("/api/documents?includeMeta=true"));
+ const body = await payload(response);
+
+ expect(response.status).toBe(200);
+ expect(client.calls[0].selected).not.toContain("storage_path");
+ expect(client.calls[0].selected).not.toContain("content_hash");
+ expect(body.documents).toEqual(documents);
+ });
+
+ it("rate limits anonymous document read bursts", async () => {
+ const client = createSupabaseMock((call) => (call.table === "documents" ? ok([]) : ok([])));
+ mockRuntime(client);
+ client.rpc.mockImplementation(async (name: string, args?: Record) => {
+ if (name === "consume_api_subject_rate_limit" && args?.p_bucket === "document_read") {
+ return {
+ data: [rateLimitRow({ limited: true, remaining: 0, retry_after_seconds: 30 })],
+ error: null,
+ };
+ }
+ if (name === "consume_api_rate_limit") {
+ return { data: [rateLimitRow()], error: null };
+ }
+ return ok([]);
+ });
+ const { GET } = await import("../src/app/api/documents/route");
+
+ const response = await GET(request("/api/documents"));
+
+ expect(response.status).toBe(429);
+ expect(await payload(response)).toMatchObject({
+ error: "Document requests are rate limited. Try again shortly.",
+ retryAfterSeconds: 30,
+ });
+ expect(client.rpc).toHaveBeenCalledWith(
+ "consume_api_subject_rate_limit",
+ expect.objectContaining({ p_bucket: "document_read" }),
+ );
});
it("does not return raw internal database errors", async () => {
@@ -536,7 +674,7 @@ describe("private document API access", () => {
it("allows document signed URLs only for owned documents", async () => {
const client = createSupabaseMock((call) => {
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ storage_path: `${userId}/documents/${documentId}/source.pdf`, file_type: "application/pdf" });
}
return ok(null);
@@ -624,7 +762,7 @@ describe("private document API access", () => {
metadata: { index_generation_id: "generation-a" },
});
}
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ id: documentId, metadata: { index_generation_id: "generation-a" } });
}
return ok(null);
@@ -653,7 +791,7 @@ describe("private document API access", () => {
metadata: { index_generation_id: "generation-a" },
});
}
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ id: documentId, metadata: {} });
}
return ok(null);
@@ -682,7 +820,7 @@ describe("private document API access", () => {
metadata: { index_generation_id: "generation-new" },
});
}
- if (call.table === "documents" && call.filters.some((filter) => filter.value === userId)) {
+ if (call.table === "documents" && matchesOwnerReadScope(call, userId)) {
return ok({ id: documentId, metadata: { index_generation_id: "generation-old" } });
}
return ok(null);
@@ -722,6 +860,58 @@ describe("private document API access", () => {
expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled();
});
+ it("rejects anonymous upload with setup guidance when public uploads are not configured", async () => {
+ const client = createSupabaseMock();
+ mockRuntime(client);
+ const { POST } = await import("../src/app/api/upload/route");
+ const formData = new FormData();
+ formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" }));
+
+ const response = await POST(
+ request("/api/upload", {
+ method: "POST",
+ body: formData,
+ }),
+ );
+
+ expect(response.status).toBe(503);
+ expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." });
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(client.storageMocks.upload).not.toHaveBeenCalled();
+ });
+
+ it("uploads anonymous documents to the configured public workspace owner", async () => {
+ const publicOwnerId = "99999999-9999-4999-8999-999999999999";
+ const client = createSupabaseMock((call) => {
+ if (call.table === "documents" && call.operation === "select" && call.maybeSingle) return ok(null);
+ if (call.table === "documents" && call.operation === "insert") {
+ const inserted = call.insertPayload as { id: string; owner_id: string; storage_path: string };
+ return ok({ id: inserted.id, owner_id: inserted.owner_id, storage_path: inserted.storage_path });
+ }
+ if (call.table === "ingestion_jobs" && call.operation === "insert") return ok({ id: "job-1", document_id: documentId });
+ return ok([]);
+ });
+ mockRuntime(client, undefined, { publicUploadsEnabled: true, publicWorkspaceOwnerId: publicOwnerId });
+ const { POST } = await import("../src/app/api/upload/route");
+ const formData = new FormData();
+ formData.set("file", new File(["%PDF-1.7"], "guideline.pdf", { type: "application/pdf" }));
+
+ const response = await POST(
+ request("/api/upload", {
+ method: "POST",
+ body: formData,
+ }),
+ );
+
+ expect(response.status).toBe(201);
+ expect(client.auth.getUser).not.toHaveBeenCalled();
+ expect(
+ client.calls.find((call) => call.table === "documents" && call.operation === "insert")?.insertPayload,
+ ).toMatchObject({
+ owner_id: publicOwnerId,
+ });
+ });
+
it("stores uploaded documents with owner_id and a user-scoped storage path", async () => {
const client = createSupabaseMock((call) => {
if (call.table === "documents" && call.operation === "insert") {
@@ -2106,9 +2296,13 @@ describe("private document API access", () => {
}
return ok([]);
});
- client.rpc.mockImplementation(async (name: string) =>
- name === "search_document_chunks" ? fail("missing rpc") : ok([]),
- );
+ client.rpc.mockImplementation(async (name: string) => {
+ if (name === "search_document_chunks") return fail("missing rpc");
+ if (name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit") {
+ return { data: [rateLimitRow()], error: null };
+ }
+ return ok([]);
+ });
mockRuntime(client);
const { GET } = await import("../src/app/api/documents/[id]/search/route");
@@ -3003,7 +3197,9 @@ describe("private document API access", () => {
}));
const client = createSupabaseMock();
client.rpc.mockImplementation(async (name: string) =>
- name === "consume_api_rate_limit" ? fail("limiter table unavailable") : ok([]),
+ name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit"
+ ? fail("limiter table unavailable")
+ : ok([]),
);
mockRuntime(client, { searchChunksWithTelemetry });
const { POST } = await import("../src/app/api/search/route");
diff --git a/tests/retrieval-query-variants.test.ts b/tests/retrieval-query-variants.test.ts
index ddb62411e..a2f06474e 100644
--- a/tests/retrieval-query-variants.test.ts
+++ b/tests/retrieval-query-variants.test.ts
@@ -127,6 +127,18 @@ describe("retrieval query variants", () => {
expect(textCandidateBudgetForQueryClass("unsupported_or_general", 12)).toBe(24);
});
+ it("keeps forced-embedding retrieval out of the ordinary search cache key", () => {
+ const baseArgs = {
+ query: "How is panic disorder managed?",
+ topK: 8,
+ minSimilarity: 0.12,
+ };
+
+ expect(retrievalPlanCacheQuery(baseArgs, "broad_summary")).not.toBe(
+ retrievalPlanCacheQuery({ ...baseArgs, forceEmbedding: true }, "broad_summary"),
+ );
+ });
+
it("allows direct document title hits to skip embedding retrieval", () => {
expect(
decideTextFastPath(
diff --git a/tests/setup-status-route.test.ts b/tests/setup-status-route.test.ts
index 170aac7f5..f952b2ef8 100644
--- a/tests/setup-status-route.test.ts
+++ b/tests/setup-status-route.test.ts
@@ -7,10 +7,13 @@ afterEach(() => {
});
describe("/api/setup-status", () => {
- it("requires auth for non-local production requests before returning setup posture", async () => {
+ it("returns setup posture for anonymous production requests without exposing secret values", async () => {
vi.stubEnv("NODE_ENV", "production");
- const getUser = vi.fn();
- const createAdminClient = vi.fn(() => ({ auth: { getUser } }));
+ const from = vi.fn(async () => ({ error: null, data: [], count: 0 }));
+ const createAdminClient = vi.fn(() => ({
+ from,
+ rpc: vi.fn(),
+ }));
vi.doMock("@/lib/env", () => ({
env: {
NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co",
@@ -24,16 +27,29 @@ describe("/api/setup-status", () => {
isLocalNoAuthMode: () => false,
}));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient }));
+ vi.doMock("@/lib/supabase/health", () => ({
+ probeSupabaseHealth: vi.fn(async () => ({ ok: true })),
+ isSupabaseUnavailableError: () => false,
+ formatSupabaseUnavailableError: (error: unknown) => String(error),
+ }));
+ vi.doMock("@/lib/supabase/project", () => ({
+ checkSupabaseProjectConfig: () => ({ status: "ready", detail: "Clinical KB Database target is configured." }),
+ formatSupabaseProjectCheck: () => "Clinical KB Database target is configured.",
+ }));
const { GET } = await import("../src/app/api/setup-status/route");
const response = await GET(new Request("https://clinical.example/api/setup-status"));
const body = await response.json();
- expect(response.status).toBe(401);
- expect(body).toEqual({ error: "Authentication required." });
- expect(JSON.stringify(body)).not.toContain("OPENAI");
- expect(JSON.stringify(body)).not.toContain("Supabase");
- expect(createAdminClient).toHaveBeenCalledTimes(1);
- expect(getUser).not.toHaveBeenCalled();
+ expect(response.status).toBe(200);
+ expect(body).toMatchObject({
+ demoMode: false,
+ checks: expect.arrayContaining([
+ expect.objectContaining({ id: "env" }),
+ expect.objectContaining({ id: "openai" }),
+ ]),
+ });
+ expect(JSON.stringify(body)).not.toContain("service-role-key");
+ expect(JSON.stringify(body)).not.toContain("openai-key");
});
});
diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts
index 3d2bae636..2eb32ebbf 100644
--- a/tests/ui-smoke.spec.ts
+++ b/tests/ui-smoke.spec.ts
@@ -143,6 +143,7 @@ async function mockLocalProjectIdentity(page: Page) {
}
async function mockPrivateUnauthenticatedApi(page: Page) {
+ await mockLocalProjectIdentity(page);
await page.route("**/api/setup-status**", async (route) => {
await route.fulfill({
json: { demoMode: false, checks: readySetupChecks },
@@ -706,6 +707,36 @@ test.describe("Clinical KB UI smoke coverage", () => {
});
}
+ test("anonymous user can see enabled live search without a forced sign-in gate", async ({ page }) => {
+ await page.setViewportSize({ width: 1280, height: 900 });
+ await mockPrivateUnauthenticatedApi(page);
+ await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
+ await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } });
+ });
+ await gotoApp(page, "/");
+ await waitForDemoDashboardReady(page);
+
+ await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0);
+ await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0);
+ await expect(page.getByTestId("global-search-input")).toBeEnabled();
+ });
+
+ test("anonymous mobile user can search without a forced sign-in gate", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 820 });
+ await mockPrivateUnauthenticatedApi(page);
+ await page.route(/\/api\/search(?:\?.*)?$/, async (route) => {
+ await route.fulfill({ json: { results: [], telemetry: { retrieval_strategy: "text_fast_path" } } });
+ });
+ await gotoApp(page, "/");
+ await waitForDemoDashboardReady(page);
+
+ await expect(page.getByText("Create your Clinical Guide account")).toHaveCount(0);
+ await expect(page.getByText("Service unavailable")).toHaveCount(0);
+ await expect(page.getByText("API unavailable")).toHaveCount(0);
+ await expect(page.getByText("Search request was not authorized by the server.")).toHaveCount(0);
+ await expect(page.getByTestId("global-search-input")).toBeEnabled();
+ });
+
test("desktop sidebar mode sync and accessibility affordances stay coherent", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await mockDemoApi(page);
diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts
index ef9823b7d..3037cfaa4 100644
--- a/tests/ui-tools.spec.ts
+++ b/tests/ui-tools.spec.ts
@@ -524,12 +524,13 @@ test.describe("Clinical KB applications launcher", () => {
test("mode home deep links preserve focus=1 on initial load", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
- for (const path of ["/services?focus=1", "/forms?focus=1"]) {
- await gotoLauncher(page, path);
- const sharedSearch = page.getByTestId("global-search-input");
- await expect(sharedSearch).toBeVisible();
- await expect(sharedSearch).toBeFocused();
- }
+ await gotoLauncher(page, "/services?focus=1");
+ await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeVisible();
+ await expect(page.getByTestId("services-home").getByTestId("global-search-input")).toBeFocused();
+
+ await gotoLauncher(page, "/forms?focus=1");
+ await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeVisible();
+ await expect(page.getByTestId("forms-home").getByTestId("global-search-input")).toBeFocused();
});
test("services mode shows source-backed records in search results", async ({ page }) => {
@@ -573,11 +574,12 @@ test.describe("Clinical KB applications launcher", () => {
test("form detail pages keep the shared forms search wired to form results", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await gotoLauncher(page, "/forms/transport-crisis-form");
+ await expect(page.getByTestId("form-detail-page")).toBeVisible();
// Structural coverage — runs on every browser, WebKit included: the form
// detail page renders inside the shared shell with the Forms-mode composer
// present and no stale results.
- await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Mode Forms" })).toBeVisible({ timeout: 20_000 });
await expect(page.getByRole("heading", { level: 1, name: "Transport order" })).toBeVisible();
await expect(page.getByTestId("form-search-results")).toHaveCount(0);
const formsSearchInput = page.locator('input[placeholder="Search forms..."]:visible').first();
]