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
15 changes: 15 additions & 0 deletions src/components/ClinicalDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { DashboardSearchErrorState } from "@/components/clinical-dashboard/dashb
import { useDashboardShellActions } from "@/components/clinical-dashboard/use-dashboard-shell-actions";
import { useDashboardSearchExecution } from "@/components/clinical-dashboard/use-dashboard-search-execution";
import {
checkSearchTextPatientDetail,
searchTextPatientDetailRefusal,
usePreloadSearchPatientCheck,
} from "@/components/clinical-dashboard/search-patient-detail-guard";
Expand Down Expand Up @@ -1208,6 +1209,20 @@ function ClinicalDashboardContent({
});
return;
}
// Every other mode writes the text into the address bar, history and recent searches too,
// so a patient detail is held back here as well (privacy audit CA-01, 2026-10-26).
if (crossQuery.trim()) {
const submittedSeq = searchRequestSeqRef.current;
void checkSearchTextPatientDetail(crossQuery).then((refusal) => {
if (submittedSeq !== searchRequestSeqRef.current) return;
if (refusal) {
setActionNotice({ tone: "warning", message: refusal });
return;
}
latestNavigateCrossModeSearch(mode, crossQuery);
});
return;
}
navigateCrossModeSearch(mode, crossQuery);
}

Expand Down
18 changes: 14 additions & 4 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"use client";

import {
checkSearchTextPatientDetail,
SearchHeldBack,
} from "@/components/clinical-dashboard/search-patient-detail-guard";
import { useRouter } from "next/navigation";
import {
ArrowLeft,
Expand Down Expand Up @@ -827,10 +831,16 @@ export function DocumentViewer({
const timeout = window.setTimeout(() => {
setSearchingDocument(true);
setDocumentSearchError(null);
fetch(`/api/documents/${documentId}/search?q=${encodeURIComponent(query)}&limit=30`, {
signal: controller.signal,
headers: clientDemoMode ? undefined : authorizationHeader,
})
// Typed text is checked for patient details before it leaves the page (privacy audit CA-02).
// A held-back search shows the held-back sentence through the error line below.
checkSearchTextPatientDetail(query)
.then((refusal) => {
if (refusal) throw new SearchHeldBack(refusal);
return fetch(`/api/documents/${documentId}/search?q=${encodeURIComponent(query)}&limit=30`, {
signal: controller.signal,
headers: clientDemoMode ? undefined : authorizationHeader,
});
})
.then(async (response) => {
if (response.status === 401) markSessionExpired();
if (!response.ok) throw await parseApiErrorResponse(response);
Expand Down
13 changes: 13 additions & 0 deletions src/components/applications-launcher-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"use client";

import {
searchTextPatientDetailRefusal,
usePreloadSearchPatientCheck,
} from "@/components/clinical-dashboard/search-patient-detail-guard";
import { useOptionalToast } from "@/components/ui/toast";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Expand Down Expand Up @@ -711,6 +716,7 @@ export function ApplicationsLauncherWorkspace({
canAccessFavourites: canAccessFavouritesProp,
}: ApplicationsLauncherWorkspaceProps) {
const router = useRouter();
const pushToast = useOptionalToast()?.push;
const auth = useAuthSession();
const clientDemoMode = resolveClientDemoMode({
explicitDemoMode: process.env.NEXT_PUBLIC_DEMO_MODE === "true",
Expand All @@ -727,6 +733,7 @@ export function ApplicationsLauncherWorkspace({
const launcherApps = useMemo(() => launcherAppsForSession(canAccessFavourites), [canAccessFavourites]);
const desktopFilters = useMemo(() => desktopFiltersForSession(canAccessFavourites), [canAccessFavourites]);
const query = localQuery ?? controlledQuery ?? searchCommand?.query ?? "";
usePreloadSearchPatientCheck(query);
const normalizedQuery = query.trim().toLowerCase();
const naturalSmartSearch = useMemo(() => interpretSmartSearch("tools", query).naturalLanguage, [query]);
const smartExpansions = useMemo(() => smartSearchExpansions("tools", query), [query]);
Expand Down Expand Up @@ -787,6 +794,12 @@ export function ApplicationsLauncherWorkspace({
function submitSearch() {
const submittedQuery = query.trim();
if (submittedQuery) {
// Text that looks like it holds a patient detail stays in the box, not the address (privacy audit CA-03).
const refusal = searchTextPatientDetailRefusal(submittedQuery);
if (refusal) {
pushToast?.({ tone: "warning", title: refusal });
return;
}
router.push(`/tools?q=${encodeURIComponent(submittedQuery)}&run=1`);
return;
}
Expand Down
9 changes: 7 additions & 2 deletions src/components/calculators/search-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"use client";

import {
searchTextPatientDetailRefusal,
usePreloadSearchPatientCheck,
} from "@/components/clinical-dashboard/search-patient-detail-guard";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowRight, Clock3, History, Info, LayoutGrid, ListChecks, Rows3, Search, Sigma } from "lucide-react";
import { useCallback, useEffect, useId, useMemo, useState, useSyncExternalStore } from "react";
Expand Down Expand Up @@ -330,6 +334,7 @@ export function CalculatorsSearchPage({
() => false,
);
const query = hydrated ? (searchCommand?.query ?? initialQuery) : initialQuery;
usePreloadSearchPatientCheck(query);
const normalizedQuery = normalizeCalculatorQuery(query);
const smartExpansions = useMemo(() => smartSearchExpansions("calculators", query), [query]);
const filterPanelId = useId();
Expand Down Expand Up @@ -382,7 +387,7 @@ export function CalculatorsSearchPage({
const nextParams = new URLSearchParams(
searchParams ? searchParams.toString() : typeof window !== "undefined" ? window.location.search : "",
);
if (!nextParams.has("q") && query.trim()) {
if (!nextParams.has("q") && query.trim() && !searchTextPatientDetailRefusal(query)) {
nextParams.set("q", query.trim());
}
nextParams.set("calculator", calculator.id);
Expand All @@ -394,7 +399,7 @@ export function CalculatorsSearchPage({
searchParams ? searchParams.toString() : typeof window !== "undefined" ? window.location.search : "",
);
nextParams.delete("calculator");
if (!nextParams.has("q") && query.trim()) {
if (!nextParams.has("q") && query.trim() && !searchTextPatientDetailRefusal(query)) {
nextParams.set("q", query.trim());
}
const searchString = nextParams.toString();
Expand Down
8 changes: 5 additions & 3 deletions src/components/clinical-dashboard/differentials-home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1115,12 +1115,14 @@ function SearchResultsView({
faultTitle={
catalog.status === "unauthorized"
? "Sign in again to search the differentials catalogue"
: "The differentials catalogue could not be searched"
: catalog.heldBack
? "This search was not sent"
: "The differentials catalogue could not be searched"
}
faultBody={
catalog.status === "unauthorized"
? "Sign in again to search, or browse the catalogue pages directly."
: "Retry the search shortly, or browse the catalogue pages directly."
: (catalog.heldBack ?? "Retry the search shortly, or browse the catalogue pages directly.")
}
// The fault copy promises two recoveries, so both have to exist: rerun the
// search, and the catalogue links that the removed error section used to
Expand All @@ -1131,7 +1133,7 @@ function SearchResultsView({
// parent document-evidence search; the catalogue hook keys on query + auth
// identity, neither of which changes when the reader asks to try again, so
// routing Retry through it left the band permanently faulted.
onRetry={catalog.status === "unauthorized" ? undefined : catalog.refetch}
onRetry={catalog.status === "unauthorized" || catalog.heldBack ? undefined : catalog.refetch}
faultAction={
<>
<Link
Expand Down
21 changes: 21 additions & 0 deletions src/components/clinical-dashboard/global-search-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "@/lib/recent-query-storage";
import { PatientProfileProvider } from "@/components/clinical-dashboard/patient-profile-context";
import {
checkSearchTextPatientDetail,
searchTextPatientDetailRefusal,
usePreloadSearchPatientCheck,
} from "@/components/clinical-dashboard/search-patient-detail-guard";
Expand Down Expand Up @@ -468,6 +469,26 @@ function GlobalStandaloneSearchShellBody({
const [query, setQuery] = useState(requestedQuery);
usePreloadSearchPatientCheck(query);
const toast = useOptionalToast();
const pushToast = toast?.push;
// A shared link whose ?q= looks like it holds a patient detail is cleaned on arrival on every
// search page, not only the dashboard, which cleans its own (privacy audit CA-05, 2026-10-26).
useEffect(() => {
if (!requestedQuery || pathname === "/") return undefined;
let cancelled = false;
void checkSearchTextPatientDetail(requestedQuery).then((refusal) => {
if (cancelled || !refusal) return;
const url = new URL(window.location.href);
if ((url.searchParams.get("q") ?? url.searchParams.get("query") ?? "").trim() !== requestedQuery) return;
url.searchParams.delete("q");
url.searchParams.delete("query");
url.searchParams.delete("run");
pushToast?.({ tone: "warning", title: refusal });
router.replace(`${url.pathname}${url.search}${url.hash}`, { scroll: false });
});
return () => {
cancelled = true;
};
}, [pathname, pushToast, requestedQuery, router]);
const deferredQuery = useDeferredValue(query);
const [, setLastAppMode] = useLastAppMode();
// Previous URL snapshot for during-render sync (React "adjusting state when a
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { AppModeId } from "@/lib/app-modes";
import type { ClinicalQueryMode } from "@/lib/types";
import type { ModeActionId } from "@/components/clinical-dashboard/mode-action-popup";
import { searchTextPatientDetailRefusal } from "@/components/clinical-dashboard/search-patient-detail-guard";

export interface MasterSearchHeaderModeActionContext {
actionId: ModeActionId;
Expand Down Expand Up @@ -112,7 +113,9 @@ export function executeMasterSearchHeaderModeAction({
return;
}
if (actionId === "dictionary-search") {
pushIfLeaveConfirmed(`/dictionary/search${trimmedQuery ? `?q=${encodeURIComponent(trimmedQuery)}` : ""}`);
// Text that looks like it holds a patient detail is left out of the address (privacy audit CA-03).
const carried = trimmedQuery && !searchTextPatientDetailRefusal(trimmedQuery) ? trimmedQuery : "";
pushIfLeaveConfirmed(`/dictionary/search${carried ? `?q=${encodeURIComponent(carried)}` : ""}`);
return;
}
if (actionId === "dictionary-topics") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ type PatientCheckModule = typeof import("@/lib/clinical-question-patient-check")
export const SEARCH_IDENTIFIER_HELD_BACK =
"This search looks like it holds a record number, date of birth, phone number or email. Remove it and search again.";

/** Held back while the full check is still loading: a name is not let through unchecked (privacy audit CA-04). */
export const SEARCH_CHECK_LOADING = "Checking this search for patient details. Try again in a moment.";

/** Held back when the full check could not load (a dropped connection); the next search tries the download again. */
export const SEARCH_CHECK_UNAVAILABLE =
"Could not check this search for patient details. Check your connection and try again.";

let loadedPatientCheck: PatientCheckModule | null = null;
let pendingPatientCheck: Promise<void> | null = null;

Expand All @@ -28,19 +35,26 @@ export function preloadSearchPatientCheck(): Promise<void> {
return pendingPatientCheck;
}

/** Loads the full check once there is search text, so a later submit or mode switch can use it. */
/**
* Loads the full check when a search box mounts, and again once there is text if that first
* download failed, so a quick submit or mode switch finds it ready. The guard fails closed until
* it has loaded (CA-04), so loading it early keeps a fast Enter from being held back as unchecked.
*/
export function usePreloadSearchPatientCheck(text: string) {
const hasText = Boolean(text.trim());
useEffect(() => {
if (hasText) void preloadSearchPatientCheck();
void preloadSearchPatientCheck();
}, [hasText]);
}

/**
* The held-back sentence when search text looks like it holds a patient detail, else null.
* Navigation that writes typed text into a page address (`?q=`) calls this first, so the
* detail never reaches the address bar, browser history or a request line (design audit
* D-03). Uses the full check once it has loaded, and the identifier-shape check until then.
* D-03). Uses the full check once it has loaded. Until then it fails closed (privacy audit
* CA-04, 2026-10-26): an identifier shape gets its own sentence, and anything else is held
* back as still being checked while the full check loads, so a name never slips through in
* that window.
*/
export function searchTextPatientDetailRefusal(text: string): string | null {
const trimmed = text.trim();
Expand All @@ -49,5 +63,43 @@ export function searchTextPatientDetailRefusal(text: string): string | null {
const problem = loadedPatientCheck.clinicalQuestionPatientDetail(trimmed);
return problem ? loadedPatientCheck.searchPatientDetailMessage(problem) : null;
}
return identifierShapeWarning(trimmed) ? SEARCH_IDENTIFIER_HELD_BACK : null;
void preloadSearchPatientCheck();
return identifierShapeWarning(trimmed) ? SEARCH_IDENTIFIER_HELD_BACK : SEARCH_CHECK_LOADING;
}

/**
* The same check for a caller that can wait: it waits for the full check to load, then reads
* the text with it. A check that cannot load holds the search back (fails closed). Used before
* a search request leaves the page, such as a typing-pause fetch or a deep link on arrival
* (privacy audit CA-02 and CA-05).
*/
export async function checkSearchTextPatientDetail(text: string): Promise<string | null> {
const trimmed = text.trim();
if (!trimmed) return null;
await preloadSearchPatientCheck();
if (!loadedPatientCheck)
return identifierShapeWarning(trimmed) ? SEARCH_IDENTIFIER_HELD_BACK : SEARCH_CHECK_UNAVAILABLE;
return searchTextPatientDetailRefusal(trimmed);
}

/** Thrown inside a search request chain when the check holds the text back; its message is the held-back sentence. */
export class SearchHeldBack extends Error {}

/**
* For a box that writes each keystroke into the page address: the text is written only once
* the check passes. Text held back clears the address's copy and says why (privacy audit CA-03).
*/
export function writeCheckedSearchText(
text: string,
write: (value: string) => void,
warn: (message: string) => void,
): void {
void checkSearchTextPatientDetail(text).then((refusal) => {
if (!refusal) {
write(text);
return;
}
write("");
warn(refusal);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ import type { VerifiedEvidencePreviewUnit } from "@/lib/answer-stream-contract";
import { createAnswerThreadSnapshotMetadata, maxStoredAnswerTurns } from "@/lib/answer-thread-storage";
import { appModeHomeHref, appModeQueryMode, appModeSearchConfig, type AppModeId } from "@/lib/app-modes";
import { identifierShapeWarning } from "@/lib/clinical-ask/context";
import { SEARCH_IDENTIFIER_HELD_BACK } from "@/components/clinical-dashboard/search-patient-detail-guard";
import {
SEARCH_CHECK_UNAVAILABLE,
SEARCH_IDENTIFIER_HELD_BACK,
searchTextPatientDetailRefusal,
} from "@/components/clinical-dashboard/search-patient-detail-guard";
import { incrementalEvidencePreviewRenderingEnabled } from "@/lib/client-env";
import { isDeployedClinicalKb } from "@/lib/deployed-app";
import { documentsSearchHref } from "@/lib/document-flow-routes";
Expand Down Expand Up @@ -845,9 +849,16 @@ export function useDashboardSearchExecution(deps: DashboardSearchExecutionDeps)
}

function setMedicationSearchQuery(searchText: string, updateUrl = true) {
modeChangeFromUiRef.current = true;
const trimmedSearchText = searchText.trim();
if (!trimmedSearchText) return;
// A suggested, recent or cross-mode Medication search writes the text into the address bar,
// history and recent searches, so it gets the same check as a typed one (privacy audit CA-01).
const refusal = searchTextPatientDetailRefusal(trimmedSearchText);
if (refusal) {
setActionNotice({ tone: "warning", message: refusal });
return;
}
modeChangeFromUiRef.current = true;
setSearchMode("prescribing");
setQuery(trimmedSearchText);
setModeSearchSubmitted(true, trimmedSearchText);
Expand Down Expand Up @@ -897,10 +908,10 @@ export function useDashboardSearchExecution(deps: DashboardSearchExecutionDeps)
}
} else if (sendsQueryToProvider) {
refusal = PATIENT_CHECK_UNAVAILABLE;
} else if (identifierShapeWarning(trimmedQuery)) {
// Searches that stay on our servers still go ahead when the full check cannot load,
// but never with a record number, date of birth, phone number or email in them.
refusal = SEARCH_IDENTIFIER_HELD_BACK;
} else {
// Searches that stay on our servers are held back too when the full check cannot load,
// since a name would otherwise reach the address bar and history (privacy audit CA-04).
refusal = identifierShapeWarning(trimmedQuery) ? SEARCH_IDENTIFIER_HELD_BACK : SEARCH_CHECK_UNAVAILABLE;
}
if (refusal) {
setActionNotice({ tone: "warning", message: refusal });
Expand Down
Loading
Loading