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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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",
Expand All @@ -30,6 +27,18 @@ export const metadata: Metadata = {
},
};

export async function generateMetadata(): Promise<Metadata> {
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,
Expand Down
9 changes: 3 additions & 6 deletions src/components/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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}
Expand Down
10 changes: 5 additions & 5 deletions src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/document-viewer-navigation.ts
Original file line number Diff line number Diff line change
@@ -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`;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
37 changes: 37 additions & 0 deletions src/lib/metadata-base.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment thread
BigSimmo marked this conversation as resolved.
return httpUrl(`${protocol}://${host}`);
}
21 changes: 14 additions & 7 deletions src/lib/public-api-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,29 @@ type AdminClient = ReturnType<typeof createAdminClient>;

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);
Expand Down
29 changes: 29 additions & 0 deletions tests/api-rate-limit-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => ({
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"]),
);
});
});
20 changes: 20 additions & 0 deletions tests/document-viewer-navigation-source.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
52 changes: 52 additions & 0 deletions tests/production-metadata-source.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
23 changes: 23 additions & 0 deletions tests/public-api-access.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand All @@ -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);
});
});
20 changes: 13 additions & 7 deletions tests/ui-smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion tests/ui-tools-collapse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});