diff --git a/.env.example b/.env.example index 778152180..e90b81a83 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,10 @@ RAG_AWAIT_QUERY_LOGS=false # Design-exploration mockup routes (/mockups/*) 404 in production builds unless # explicitly opted in. Always reachable in dev/test. #NEXT_PUBLIC_MOCKUPS_ENABLED=false +# Optional canonical public origin for generated metadata, for example +# https://clinical-kb.example.org. Railway deployments otherwise use the +# provider-supplied RAILWAY_PUBLIC_DOMAIN; request hosts are only used in dev. +#NEXT_PUBLIC_SITE_URL= # Persist raw clinical query text in logs. Default off; blocked in production readiness. #RAG_PERSIST_RAW_QUERY_TEXT=false # Persist the full generated answer text (rag_queries.answer and promoted eval-case diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 19152338e..55a720ae2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { AuthProvider } from "@/lib/supabase/client"; import { WebVitalsReporter } from "@/components/web-vitals-reporter"; +import { resolveMetadataBase } from "@/lib/metadata-base"; import "./globals.css"; const geistSans = Geist({ @@ -15,11 +16,7 @@ const geistMono = Geist_Mono({ subsets: ["latin"], }); -export const metadata: Metadata = { - // Absolute base for OG/twitter image URLs (app/opengraph-image). Set - // NEXT_PUBLIC_SITE_URL in production; the localhost fallback only affects dev, - // where social unfurls aren't consumed. - metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"), +const baseMetadata: Metadata = { applicationName: "Clinical KB", title: "Clinical KB", description: "Private medical guideline RAG knowledge base", @@ -30,6 +27,18 @@ export const metadata: Metadata = { }, }; +export async function generateMetadata(): Promise { + const requestHeaders = await headers(); + return { + ...baseMetadata, + metadataBase: resolveMetadataBase(requestHeaders, { + configuredSiteUrl: process.env.NEXT_PUBLIC_SITE_URL, + trustedDeploymentDomain: process.env.RAILWAY_PUBLIC_DOMAIN, + allowRequestOrigin: process.env.NODE_ENV !== "production", + }), + }; +} + export const viewport: Viewport = { width: "device-width", initialScale: 1, diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 415c9d40f..55e2d101b 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -68,6 +68,7 @@ import { import { BadgeCluster } from "@/components/clinical-dashboard/clinical-badge"; import { clearCachedSignedUrl, getCachedSignedUrl, setCachedSignedUrl } from "@/lib/signed-url-cache"; import { readLocalProjectIdentity, unsafeLocalProjectMessage } from "@/lib/local-project-identity"; +import { documentPageHref } from "@/lib/document-viewer-navigation"; import { formatClinicalDate } from "@/lib/source-metadata"; import { partitionViewerImages } from "@/lib/image-filtering"; import { isLocalNoAuthMode } from "@/lib/client-env"; @@ -2453,11 +2454,7 @@ export function DocumentViewer({ const scopedDocumentHref = readyDocument ? `/?mode=documents&q=${encodeURIComponent(documentDisplayTitle(readyDocument))}&documentId=${encodeURIComponent(documentId)}` : documentHomeHref; - const documentPageHref = (page: number) => { - const params = new URLSearchParams({ page: String(Math.max(1, Math.trunc(page))) }); - if (chunkId) params.set("chunk", chunkId); - return `/documents/${encodeURIComponent(documentId)}?${params.toString()}#pdf-preview-section`; - }; + const usefulPageHref = (page: number) => documentPageHref(documentId, page); const canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; const summarizeTitle = canSummarizeDocument ? "Answer from this document" : "Load a source document before answering"; const selectedPage = pages.find((page) => page.page_number === initialPage) ?? pages[0]; @@ -2740,7 +2737,7 @@ export function DocumentViewer({ signedUrl={signedUrl} downloadUrl={downloadSignedUrl} pages={pages} - pageHref={documentPageHref} + pageHref={usefulPageHref} onAskFromDocument={() => void summarize()} onAddToScope={() => router.push(scopedDocumentHref)} canSummarizeDocument={canSummarizeDocument} diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 7cac4a695..505071bc0 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -223,18 +223,18 @@ export async function consumeSubjectApiRateLimit(args: { } satisfies ApiRateLimitResult; }; - if (args.bucket !== "answer") { + if (args.bucket !== "answer" && args.bucket !== "document_upload") { return consumeAnonymousLimit(args.subject.subjectKey, limit, windowSeconds); } // A stable global ceiling prevents rotated/spoofed network identities from - // multiplying paid provider capacity. Use the existing authenticated answer - // allowance as the aggregate anonymous ceiling to avoid a new magic budget. - const globalDefaults = apiRateLimitDefaults.answer; + // multiplying paid generation or upload/ingestion capacity. Reuse each + // bucket's authenticated allowance as the aggregate anonymous ceiling. + const globalDefaults = apiRateLimitDefaults[args.bucket]; const subjectResult = await consumeAnonymousLimit(args.subject.subjectKey, limit, windowSeconds); if (subjectResult.limited) return subjectResult; const globalResult = await consumeAnonymousLimit( - "anon:answer:global", + `anon:${args.bucket}:global`, globalDefaults.limit, globalDefaults.windowSeconds, ); diff --git a/src/lib/document-viewer-navigation.ts b/src/lib/document-viewer-navigation.ts new file mode 100644 index 000000000..4e05e2911 --- /dev/null +++ b/src/lib/document-viewer-navigation.ts @@ -0,0 +1,6 @@ +/** Build a useful-page link without retaining evidence pinned to a different page. */ +export function documentPageHref(documentId: string, page: number) { + const normalizedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1; + const params = new URLSearchParams({ page: String(normalizedPage) }); + return `/documents/${encodeURIComponent(documentId)}?${params.toString()}#pdf-preview-section`; +} diff --git a/src/lib/metadata-base.ts b/src/lib/metadata-base.ts new file mode 100644 index 000000000..19da73187 --- /dev/null +++ b/src/lib/metadata-base.ts @@ -0,0 +1,37 @@ +export type MetadataBaseOptions = { + configuredSiteUrl?: string; + trustedDeploymentDomain?: string; + allowRequestOrigin?: boolean; +}; + +/** Parse an absolute HTTP(S) URL without allowing invalid configuration to escape. */ +function httpUrl(value: string | undefined) { + const candidate = value?.trim(); + if (!candidate) return undefined; + try { + const url = new URL(candidate); + return url.protocol === "http:" || url.protocol === "https:" ? url : undefined; + } catch { + return undefined; + } +} + +/** Resolve metadata from validated configuration, trusted deployment state, or an explicit dev fallback. */ +export function resolveMetadataBase(requestHeaders: Headers, options: MetadataBaseOptions = {}) { + const configuredUrl = httpUrl(options.configuredSiteUrl); + if (configuredUrl) return configuredUrl; + + const deploymentDomain = options.trustedDeploymentDomain?.trim(); + const deploymentUrl = httpUrl( + deploymentDomain?.includes("://") ? deploymentDomain : deploymentDomain ? `https://${deploymentDomain}` : undefined, + ); + if (deploymentUrl) return deploymentUrl; + + if (!options.allowRequestOrigin) return undefined; + const forwardedHost = requestHeaders.get("x-forwarded-host")?.split(",")[0]?.trim(); + const host = forwardedHost || requestHeaders.get("host")?.trim(); + if (!host) return undefined; + const forwardedProtocol = requestHeaders.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase(); + const protocol = forwardedProtocol === "http" || forwardedProtocol === "https" ? forwardedProtocol : "https"; + return httpUrl(`${protocol}://${host}`); +} diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 2d238cf77..36e35fdd9 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -11,22 +11,29 @@ type AdminClient = ReturnType; export type RateLimitSubject = { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string }; -function firstForwardedIp(value: string | null) { - return value?.split(",")[0]?.trim() || ""; +/** Read the deployment proxy's appended address from a forwarded IP chain. */ +function trustedProxyIp(value: string | null) { + const forwarded = value + ?.split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + return forwarded?.at(-1) ?? ""; } +/** Select the strongest deployment-owned request identity signal available. */ function requestIpSignal(request: Request) { return ( - firstForwardedIp(request.headers.get("cf-connecting-ip")) || - firstForwardedIp(request.headers.get("x-forwarded-for")) || - firstForwardedIp(request.headers.get("x-real-ip")) || + trustedProxyIp(request.headers.get("x-forwarded-for")) || + trustedProxyIp(request.headers.get("x-real-ip")) || "unknown-ip" ); } +/** Derive a stable, non-reversible quota subject for an anonymous caller. */ export function anonymousApiSubjectKey(request: Request) { - // User-Agent is caller-controlled and therefore must not partition a quota: - // rotating it would mint a fresh paid-answer allowance for every request. + // Trust only the deployment proxy's appended forwarding entry. Ignore the + // caller-controlled Cloudflare/User-Agent values and any leading XFF entries: + // Railway appends the trusted client address at the right edge of the chain. // If no trusted proxy IP is available, every unknown caller intentionally // shares the same conservative quota rather than failing open. const source = requestIpSignal(request); diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts index cba820e8a..db44d8786 100644 --- a/tests/api-rate-limit-fallback.test.ts +++ b/tests/api-rate-limit-fallback.test.ts @@ -148,4 +148,33 @@ describe("document_upload fail-closed limiter", () => { // document_read degrades to the per-instance limiter instead of failing closed. expect(result.limited).toBe(false); }); + + it("enforces a global anonymous upload quota as well as the caller quota", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const rpc = vi.fn(async (_name: string, args: Record) => ({ + data: { + limited: false, + limit_value: args.p_limit, + remaining: Number(args.p_limit) - 1, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + error: null, + })); + + await consumeSubjectApiRateLimit({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "document_upload", + }); + + expect(rpc).toHaveBeenCalledTimes(2); + expect(rpc.mock.calls.map(([, args]) => args.p_subject_key)).toEqual( + expect.arrayContaining(["anon:caller", "anon:document_upload:global"]), + ); + }); }); diff --git a/tests/document-viewer-navigation-source.test.ts b/tests/document-viewer-navigation-source.test.ts new file mode 100644 index 000000000..3b4574e38 --- /dev/null +++ b/tests/document-viewer-navigation-source.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { documentPageHref } from "../src/lib/document-viewer-navigation"; + +describe("document viewer useful-page navigation", () => { + it("creates a destination-page URL without carrying an unrelated citation chunk", () => { + const href = documentPageHref("document/id", 3); + + expect(href).toBe("/documents/document%2Fid?page=3#pdf-preview-section"); + expect(href).not.toContain("chunk="); + }); + + it("normalizes invalid page numbers to the first page", () => { + expect(documentPageHref("document-id", 0)).toBe("/documents/document-id?page=1#pdf-preview-section"); + expect(documentPageHref("document-id", Number.NaN)).toBe("/documents/document-id?page=1#pdf-preview-section"); + expect(documentPageHref("document-id", Number.POSITIVE_INFINITY)).toBe( + "/documents/document-id?page=1#pdf-preview-section", + ); + }); +}); diff --git a/tests/production-metadata-source.test.ts b/tests/production-metadata-source.test.ts new file mode 100644 index 000000000..6ac2a2c54 --- /dev/null +++ b/tests/production-metadata-source.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { resolveMetadataBase } from "../src/lib/metadata-base"; + +describe("production metadata origin", () => { + it.each(["http://clinical.test", "https://clinical.test"])( + "uses a configured HTTP(S) origin: %s", + (configuredOrigin) => { + expect(resolveMetadataBase(new Headers(), { configuredSiteUrl: configuredOrigin })?.href).toBe( + `${configuredOrigin}/`, + ); + }, + ); + + it("uses Railway's trusted deployment domain when configuration is absent", () => { + expect(resolveMetadataBase(new Headers(), { trustedDeploymentDomain: "clinical-kb.up.railway.app" })?.href).toBe( + "https://clinical-kb.up.railway.app/", + ); + }); + + it("uses request headers only when the caller explicitly allows a development fallback", () => { + const requestHeaders = new Headers({ + host: "internal.test:3000", + "x-forwarded-host": "clinical.example.org, proxy.internal", + "x-forwarded-proto": "https, http", + }); + + expect(resolveMetadataBase(requestHeaders, { allowRequestOrigin: true })?.href).toBe( + "https://clinical.example.org/", + ); + }); + + it("falls back to Railway's trusted domain when the configured value is malformed", () => { + const options = { + configuredSiteUrl: "not a valid URL", + trustedDeploymentDomain: "clinical-kb.up.railway.app", + }; + + expect(() => resolveMetadataBase(new Headers(), options)).not.toThrow(); + expect(resolveMetadataBase(new Headers(), options)?.href).toBe("https://clinical-kb.up.railway.app/"); + }); + + it("does not trust request-controlled host headers in production", () => { + const requestHeaders = new Headers({ + host: "internal.test:3000", + "x-forwarded-host": "attacker.example", + "x-forwarded-proto": "https", + }); + + expect(resolveMetadataBase(requestHeaders)).toBeUndefined(); + }); +}); diff --git a/tests/public-api-access.test.ts b/tests/public-api-access.test.ts index edaec20df..e7c15d3da 100644 --- a/tests/public-api-access.test.ts +++ b/tests/public-api-access.test.ts @@ -10,6 +10,15 @@ function anonymousRequest(ip: string, userAgent: string) { }); } +function forwardedRequest(forwardedFor: string, cloudflareIp: string) { + return new Request("http://localhost/api/upload", { + headers: { + "x-forwarded-for": forwardedFor, + "cf-connecting-ip": cloudflareIp, + }, + }); +} + describe("anonymous API rate-limit identity", () => { it("does not let callers rotate the quota by changing user-agent", () => { const first = anonymousApiSubjectKey(anonymousRequest("198.51.100.10", "client-a")); @@ -24,4 +33,18 @@ describe("anonymous API rate-limit identity", () => { expect(second).not.toBe(first); }); + + it("ignores caller-controlled identity entries before Railway's appended address", () => { + const first = anonymousApiSubjectKey(forwardedRequest("192.0.2.10, 198.51.100.20", "203.0.113.10")); + const second = anonymousApiSubjectKey(forwardedRequest("192.0.2.99, 198.51.100.20", "203.0.113.99")); + + expect(second).toBe(first); + }); + + it("keeps distinct Railway-appended client addresses separate", () => { + const first = anonymousApiSubjectKey(forwardedRequest("192.0.2.10, 198.51.100.20", "203.0.113.10")); + const second = anonymousApiSubjectKey(forwardedRequest("192.0.2.10, 198.51.100.99", "203.0.113.10")); + + expect(second).not.toBe(first); + }); }); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index f3720027a..0db72a376 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -1,6 +1,7 @@ import type { Route } from "playwright-core"; import { expect, test, type Locator, type Page } from "playwright/test"; import { scrollPrimarySurface } from "./playwright-scroll"; +import { recentQueryStorageKey } from "../src/components/clinical-dashboard/dashboard-contracts"; import { answerThreadStorageKey } from "../src/lib/answer-thread-storage"; import { demoAnswer, demoDocuments, getDemoDocument, getDemoDocumentPayload } from "../src/lib/demo-data"; import { deriveGovernanceFromSections } from "../src/lib/medication-records"; @@ -16,6 +17,9 @@ const dashboardViewports = [ { name: "mobile-landscape", width: 667, height: 375 }, ] as const; const uiAssertionTimeoutMs = 5_000; +const demoAnswerThreadOwnerId = "local-demo-session"; +const demoAnswerThreadStorageKey = `${answerThreadStorageKey}:${demoAnswerThreadOwnerId}`; +const demoRecentQueryStorageKey = `${recentQueryStorageKey}:${demoAnswerThreadOwnerId}`; async function expectNoPageHorizontalOverflow(page: Page) { const overflow = await page.evaluate(() => { @@ -656,14 +660,14 @@ async function waitForPersistedAnswerThread(page: Page, minPriorTurns = 1) { .poll(async () => page.evaluate((storageKey) => { try { - const raw = window.localStorage.getItem(storageKey); + const raw = window.sessionStorage.getItem(storageKey); if (!raw) return 0; const parsed = JSON.parse(raw) as { priorTurns?: unknown[] }; return Array.isArray(parsed.priorTurns) ? parsed.priorTurns.length : 0; } catch { return 0; } - }, answerThreadStorageKey), + }, demoAnswerThreadStorageKey), ) .toBeGreaterThanOrEqual(minPriorTurns); } @@ -1455,11 +1459,13 @@ test.describe("Clinical KB UI smoke coverage", () => { const answerRequests: string[] = []; await mockDemoApi(page, { onAnswerRequest: (query) => answerRequests.push(query) }); const recent = "clozapine monitoring schedule"; - // Seed persisted recent queries before the app loads (key mirrors - // `recentQueryStorageKey` in ClinicalDashboard.tsx). - await page.addInitScript((value) => { - window.localStorage.setItem("clinical-kb-recent-queries", JSON.stringify([value])); - }, recent); + // Seed the owner-scoped session history before the app loads. + await page.addInitScript( + ({ storageKey, value }) => { + window.sessionStorage.setItem(storageKey, JSON.stringify([value])); + }, + { storageKey: demoRecentQueryStorageKey, value: recent }, + ); await gotoApp(page, "/"); await waitForDemoDashboardReady(page); diff --git a/tests/ui-tools-collapse.spec.ts b/tests/ui-tools-collapse.spec.ts index 71f20f451..20a690605 100644 --- a/tests/ui-tools-collapse.spec.ts +++ b/tests/ui-tools-collapse.spec.ts @@ -50,6 +50,6 @@ test.describe("Tools mockups collapse the primary region when filtering", () => await page.getByRole("searchbox").first().fill("medication"); await expect(page.getByRole("heading", { name: "Launcher overview" })).toHaveCount(0); await expect(page.getByRole("heading", { name: "Results" })).toBeVisible(); - await expect(page.getByLabel("Open Medication Prescribing")).toBeVisible(); + await expect(page.getByRole("button", { name: /Medication Prescribing/ })).toBeVisible(); }); });