From c371f3ffbdd19cad21b65be9ab4a897cc8ba016d Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:16:12 +0800 Subject: [PATCH 1/6] fix: address 48-hour review findings --- .env.example | 3 + docs/branch-review-ledger.md | 62 ++++++++++--------- src/app/api/answer/stream/route.ts | 17 +---- src/app/layout.tsx | 30 +++++++-- src/components/DocumentViewer.tsx | 1 - src/lib/api-rate-limit.ts | 10 +-- src/lib/public-api-access.ts | 7 ++- src/lib/rag-extractive-answer.ts | 16 ++++- src/lib/validation/body.ts | 48 +++++++++++++- tests/api-rate-limit-fallback.test.ts | 29 +++++++++ tests/bounded-json-body.test.ts | 39 ++++++++++++ .../document-viewer-navigation-source.test.ts | 11 ++++ tests/private-access-routes.test.ts | 12 ++-- tests/production-metadata-source.test.ts | 11 ++++ tests/public-api-access.test.ts | 23 +++++++ ...ce-backed-recovery-cross-reference.test.ts | 51 +++++++++++++++ 16 files changed, 302 insertions(+), 68 deletions(-) create mode 100644 tests/bounded-json-body.test.ts create mode 100644 tests/document-viewer-navigation-source.test.ts create mode 100644 tests/production-metadata-source.test.ts create mode 100644 tests/source-backed-recovery-cross-reference.test.ts diff --git a/.env.example b/.env.example index ac2d9744e..5d71d0c4d 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,9 @@ 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. When omitted, metadata uses the request host. +#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/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index ac8343eff..ddd9eceec 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -18,33 +18,35 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD ## Review Records -| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | -| ---------- | ------------------------------------------------------ | ---------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval | -| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` | -| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | -| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | -| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | -| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | -| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | -| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | -| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | -| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | -| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | -| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` | -| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check | -| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | -| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | -| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | -| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | -| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | -| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | -| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | -| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | +| Date | Branch or ref | Reviewed HEAD | Scope | Outcome | Checks | +| ---------- | ------------------------------------------------------ | ---------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 2026-07-10 | codex/design-ux-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | design-system + UX + design | Five issue groups confirmed; scoped fixes applied in the worktree. | `npm run check:type-scale`; focused Vitest (19/19); `npm run typecheck`; `npm run lint`; `npm run sitemap:check`; browser/API-backed checks awaiting approval | +| 2026-07-11 | codex/design-ux-review-integration | 98093ec7b | branch-integration-review | Replayed the reviewed design and UX fixes onto current `origin/main`, preserved the lightweight evidence-panel boundary, and retained the merged quality fixes. | `npm run check:type-scale`; combined focused Vitest (8 files, 42 tests); runtime/action/sitemap/type-scale/lint stages of `verify:cheap`; typecheck blocked by stale worktree dependencies pending hosted clean install; `git diff --check` | +| 2026-07-09 | example/branch | abc1234 | branch-cleanup | Example: already merged into `main`; no unique patch content. | `git log --right-only --cherry-pick main...example/branch`; `git diff --name-status main...example/branch` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree diff: PR testing streamlining | Changes requested: 2 P1 clinical-gate defects and 7 P2/P3 scope, local parity, and UI-process defects. | `npm run check:ci-scope`; `npm run check:github-actions`; `npm run check:codex-autofix-workflow`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; targeted scope classifications; `git diff --check` | +| 2026-07-10 | codex/pr-testing-streamlining | 155c801cd58f797037d8aaa8b885405a1c599249 | working-tree remediation review | All recorded P1-P3 findings fixed; no remaining high-confidence issue in the changed scope. | `npm run verify:cheap`; `npm run verify:pr-local`; `npm run eval:rag:offline`; `npm run test:e2e:critical`; `npm run test:e2e:advisory`; CI YAML parse; scope/action/Codex guards; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow | Fixed exact connector authorization, trusted-marker deduplication, and strict self-trigger matching; added regression coverage. | `npm run check:codex-autofix-workflow`; focused Vitest (4 passed); `npm run verify:cheap` pre-test stages passed before tool timeout; `npm test` (1,415 passed, 1 skipped); focused Prettier check; `npm run check:github-actions` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-flow-followup | Fixed untrusted workflow-level concurrency interference and migrated the bridge from the Node 20 action runtime to `actions/github-script@v9`; added direct embedded-script execution coverage. | Focused Vitest (13 passed); targeted ESLint; `tsc --noEmit`; `npm run check:codex-autofix-workflow`; `npm run check:github-actions`; focused Prettier check; `git diff --check` | +| 2026-07-10 | codex/review-autofix-flow | 155c801cd58f797037d8aaa8b885405a1c599249 | codex-autofix-residual-fixes | Replaced one-shot PR deduplication with a three-cycle head-SHA cap, made comment permission failures fail visibly, and pinned `github-script` v9.0.0 to its verified immutable commit. | TDD red run (7 expected failures); focused Vitest green run (15 passed); `npm run verify:cheap` (152 files passed, 1 skipped; 1,426 tests passed, 1 skipped); focused Prettier check; `git diff --check`; official `git ls-remote` tag verification | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | architecture-review | Seven findings fixed in the working tree: three runtime cycles, unbounded owner caches, a client/server env boundary breach, reversed runtime-to-scripts ownership, and architecture-doc drift. | `npm run test -- tests/architecture-boundaries.test.ts tests/bounded-ttl-cache.test.ts tests/rag-score.test.ts tests/rag-cache-utils.test.ts tests/rag-cache-invalidation.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; `npm run check:production-readiness:ci` | +| 2026-07-10 | codex/architecture-review-fixes | 648abfa3f7c91395b5eeca543f70e0b6ea59e9e0 | frontend-architecture-review | Shared cycle/env findings confirmed and fixed; three additional findings fixed for defeated lazy boundaries, duplicate shell/dashboard subscriptions, and unstable search-context values. | `npm run test -- tests/architecture-boundaries.test.ts tests/evidence-panels.test.ts tests/clinical-dashboard-merge-artifacts.test.ts`; `npm run verify:cheap`; UI gate deferred pending explicit local-API approval | +| 2026-07-11 | codex/architecture-review-integration | b45df727b29aad8ba4ec5d4e96d1f0599d7dad8a | branch-integration-review | Replayed the reviewed architecture fixes onto current `origin/main`; preserved current CI/autofix history and found no new high-confidence defect in the integrated diff. | `npm run check:runtime`; `npm run check:github-actions`; `npm run sitemap:check`; `npm run lint`; `npm run typecheck`; focused Vitest (24 passed); full Vitest with `--testTimeout=30000` (1,433 passed, 1 skipped); `git diff --check` | +| 2026-07-10 | codex/quality-testing-typescript-fixes | 648abfa3f | code-quality + testing + TypeScript | 17 confirmed P2/P3 issues fixed; no P0/P1 findings; residual large-module complexity noted. | Focused Vitest and Playwright; full Vitest 1427 passed/1 skipped; coverage; lint; typecheck; production-readiness CI | +| 2026-07-11 | codex/quality-review-integration | d3fcef8bbc9ab12b929771421b532c1ed8b7e1e7 | branch-integration-review | Replayed the quality, testing, and TypeScript fixes onto current `origin/main` and consolidated the stronger standalone auth callback coverage into this branch. | Changed-file Prettier; focused Vitest (5 files, 23 tests); `git diff --check`; original branch full Vitest/coverage/lint/typecheck and targeted Chromium evidence retained | +| 2026-07-11 | codex/architecture-review-integration | 665103250ccc33b5870862b8d8467607a1ae5d23 | coderabbit-followup | Fixed POSIX project-root identity collisions and closed dynamic-import and self-cycle gaps in the architecture regression guard. | Local-server Vitest passed; architecture-boundaries Vitest passed (6 tests); `npm run typecheck`; focused Prettier; `git diff --check` | +| 2026-07-11 | codex/architecture-review-followup | f5deaaee98864f1d32c1060ae14966a4f5975872 | coderabbit-test-followup | Removed probabilistic no-collision assertions from the local identity test and replaced them with deterministic normalization, repeatability, ID-shape, and port-range checks. | Local-server Vitest (2 passed); focused Prettier; `git diff --check`; hosted CI/SAST/Secret Scan passed on the reviewed head | +| 2026-07-11 | codex/pr-check-followup | 298e8f5bec2a4673dd225da3f446f008b8f25953 | residual-pr-check-hardening | Ported only the three PR-check improvements not already merged by PR #454: pinned Supabase CLI/cache ownership, advisory Semgrep coverage for Edge Functions, and regression guards for both contracts. | `npm run check:github-actions`; `npm run check:ci-scope`; focused Prettier; `git diff --check` | +| 2026-07-11 | claude/mobile-search-bar-fix (PR #456) | b73196c2e2e4a536804cdcdb50879c29e2c582c5 | PR required-testing review | All 4 Advisory UI regression failures confirmed PR-caused via A/B against pre-merge main (01f2cee0d): the 640px mode-home query moved the phone composer out of the hero, contradicting the design tests; residual ≥640px vanish remained when the slot never mounts. PR merged (b32c17b34) before the rework landed; follow-up fix shipped on `claude/mode-home-composer-hero-fix` (0px hero query restored, portal-outcome inline fallback, new `@critical` composer-presence test). Also found: main CI red on every push — missing `RAG_QUERY_HASH_SECRET` secret fails the deployment boot smoke and skips `release-browser-matrix`; owner adding the secret. | Local chromium A/B (PR head 4/5 fail vs baseline product-pass); rework targeted run 6/6 pass incl. new `@critical`; `npm run typecheck`; `npm run lint`; focused Prettier check | +| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | b2c772606126f8323424bc9c0b636bac77c08789 | open-PR review, unresolved comments, and CI | Two findings fixed: expanded weak/unsupported prior answers retain an explicit source-review warning, and cross-mode search actions no longer log an incorrect detail-open telemetry event. Added a persisted prior-turn browser assertion. No additional high-confidence defect was found in the changed scope. | Focused answer-render and cross-mode Vitest (20/20); TypeScript; focused Prettier; `git diff --check`. Browser assertion delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #469 / claude/response-formatting-cleanup-b57a9c | f1864308e0b287bb83b2a13daca4c3aa2ab95a3e | open-PR review, unresolved comments, and CI | P2 fixed: the OCR bullet sanitizer now preserves line-start O blood values when followed by blood or red-cell noun tails while still stripping non-blood bullets such as `o Negative screen`. No additional high-confidence defect was found in the nine-file diff. | Focused sanitizer/extractive Vitest (75/75); TypeScript; focused Prettier; `git diff --check`. Production-readiness script ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | +| 2026-07-11 | PR #466 / claude/search-timeout-failure-s6aiuj | 54d52292eeb9e1c7856b3dad89d1b72e0d49fd53 | open-PR review, unresolved comments, and CI | P2 fixed: SSE progress/token/error emission now tolerates a client cancellation racing an enqueue, so the catch path cannot throw while reporting the original stream error. No additional high-confidence defect was found in the six-file diff. | Focused SSE and search utility Vitest (13/13); TypeScript; focused Prettier. Hosted advisory browser failure was shared stale assertion drift and is rerun after this push. | +| 2026-07-11 | PR #483 / claude/differentials-page-review-a3daaf | 36cca1bf7c13718dcc60a61b75272c7c4fa5cd44 | open-PR review, unresolved comments, and CI | P2 fixed: authenticated diagnosis detail responses now derive related links, overlap links, and comparison presentation from the owner's current diagnosis and presentation rows rather than the bundled snapshot. Added an owner-only catalog regression test. No additional high-confidence defect was found in the changed scope. | Focused differentials route/catalog Vitest (26/26); TypeScript; focused Prettier; `git diff --check`. Production readiness ran fail-closed with provider variables cleared and reported only expected missing provider configuration. | +| 2026-07-11 | PR #489 / claude/document-viewer-redesign-55b68b | 9130c8b15a22dbbc965464a247ae930c04f2da62 | open-PR review, unresolved comments, and CI | P2 fixed: document deep links now expand the mobile indexed-text details and scroll the branch-specific visible mobile or desktop chunk instead of the first duplicated DOM match. Added focused desktop/mobile assertions. No additional high-confidence defect was found in the three-file diff. | Focused Prettier; TypeScript; `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #488 / claude/code-review-42a2c3 | 7a8ea145013444f7cc29631499f48a8b0454937a | open-PR review, unresolved comments, and CI | Confirmed the remaining public error-code finding was already fixed at the reviewed head. Added the two focused advisory UI assertion stabilizations required by the hosted failure logs; no additional high-confidence defect was found in the changed scope. | `tests/http-error-response.test.ts` (3/3); Prettier check on affected files; `git diff --check`; hosted required CI passed before the test-only fix. Browser rerun deferred to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #473 / claude/mobile-search-bar-popup-bx163m | 7cef01852a9713ec51184578868212df5805adbf | open-PR review, unresolved comments, and CI | P1 merge-conflict markers removed from the shared search header while retaining the all-viewport hero portal and inline fallback. P2 fixed: phone-hidden command results can no longer open, report expanded state, receive keyboard navigation, or execute an invisible selection. The launcher and global-shell conflict findings were already resolved at the reviewed head. | No conflict markers; TypeScript; focused Prettier; app-mode/search/universal-search Vitest (37/37); `git diff --check`. Browser proof delegated to hosted CI because Turbopack rejects the isolated worktree's external node_modules junction. | +| 2026-07-11 | PR #461 / claude/differentials-search-ux-polish-f2ff06 | 8bf455325b0915898417dd66aa61d419080c5528 | open-PR review, unresolved comments, and CI | Preserved diagnosis selections through workflow-aware comparison routing, constrained cross-workflow IDs to supported candidates, and removed comparison controls from presentation rows. Restored all four required core UI smoke markers and hardened answer/search mocks against invalid payloads and stale-response races. | Focused differential Vitest (22/22); TypeScript; full required CI, advisory Chromium, CodeRabbit, Semgrep, Gitleaks, and GitGuardian passed on the final head. | +| 2026-07-11 | PR #485 / claude/home-answer-page-layout-rtx10n | 96dbd0394888d5a52c916dba52b94d0f83e4507e | open-PR review and CI | Integrated the all-viewport hero composer, retained the compact hero scale, made composer width continuous across 1024px, and restored a mobile centering height floor. Review ledger SHAs were expanded to full IDs and source guards cover the layout invariants. | Focused source guards (30/30); TypeScript; full Vitest (1,594 passed, 1 skipped); required and advisory UI, build, static, unit, CodeRabbit, Semgrep, Gitleaks, GitGuardian, and post-merge main CI passed. | +| 2026-07-11 | PR #481 / claude/perf-r2-auth-roundtrip | 24fad070fb9834510309e74e1dc0e216cd08646b | main-integration follow-up | The stacked PR had merged into an already-merged feature base, so its reviewed delta was not present on `main`. Replayed only PR #481's first-parent patch onto current `main`, preserving current answer-route behavior while adding client payload trimming and cookie-authenticated proxy refresh coverage. | `npm run verify:cheap`; focused proxy/payload/clinical-safety Vitest (18/18); `npm run check:production-readiness:ci`; focused Prettier; `git diff --check`. | +| 2026-07-11 | codex/responsive-accessibility-audit | 66883b7c86f606e617db4bee2bab6f85fff59bdc | responsive and accessibility audit | P2 fixed: the mobile expandable clinical table no longer wraps semantic table content in a duplicate ARIA button, and its full-screen dialog now traps keyboard focus while preserving Escape dismissal and focus return. Added responsive ARIA and focus regression coverage. No additional high-confidence responsive or accessibility defect was reproduced across audited primary app modes and 320px-1440px widths. | Multi-width DOM/geometry/contrast audit; a11y media (2/2); overlap (12/12); table Vitest (6/6); TypeScript; lint/static checks; full Vitest (1,598 passed, 1 skipped); `npm run verify:ui` (132/132); Prettier; `git diff --check`. Provider checks skipped. | +| 2026-07-13 | origin/main | 8ec61962bcb6171e5eddf835fca7af5ecd5de12a | first-parent integration changes from the preceding 48 hours | Changes requested: one P1 clinical-grounding defect and five P2 document-navigation, anonymous abuse-control, request-budget, rate-limit error-contract, and production-metadata defects. Reviewed the combined 68-commit, 306-file delta from `01f2cee0d335504cdc98d28afdb5e023838d704f` rather than repeating already-ledgered PR reviews. | `git diff --check` passed; GitHub Actions pin check passed; strict icon-scale check passed; conflict/TODO scan clean. `npm run verify:cheap` could not start because this isolated review worktree has no `node_modules` (`tsx` missing). Provider-backed, browser, build, and hosted checks skipped. | +| 2026-07-13 | codex/fix-48h-review-findings | 8ec61962bcb6171e5eddf835fca7af5ecd5de12a | working-tree remediation of the 48-hour integration review | Fixed all six findings: bare cross-reference answers now fail closed, useful-page links clear stale citation chunks, anonymous identity ignores the caller-controlled Cloudflare header and uploads have a global ceiling, JSON bodies are bounded, pre-stream 429 responses use the shared JSON contract, and metadata derives its origin from configuration or request headers. Added focused regression coverage. | TDD red run: 8 expected failures with 106 passing; focused green run: 114/114; `npm run verify:cheap` (184 files passed, 1 skipped; 1,711 tests passed, 1 skipped); `npm run eval:rag:offline` (60/60); `npm run check:production-readiness:ci` READY; focused Prettier and `git diff --check`. Browser QA blocked because Turbopack rejects the external `node_modules` junction. Provider-backed checks skipped. | diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index e44b57281..288bd9aa2 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -5,6 +5,7 @@ import { PublicApiError, jsonError } from "@/lib/http"; import { allowRateLimitInMemoryFallbackOnUnavailable, consumeSubjectApiRateLimit, + rateLimitJsonResponse, type ApiRateLimitResult, } from "@/lib/api-rate-limit"; import { publicAccessContext } from "@/lib/public-api-access"; @@ -57,21 +58,7 @@ function encodeSse(event: string, data: unknown) { } function rateLimitStream(rateLimit: ApiRateLimitResult) { - return new Response( - encodeSse("error", { - error: "Too many answer requests. Retry shortly.", - status: 429, - details: { retryAfterSeconds: rateLimit.retryAfterSeconds, resetAt: rateLimit.resetAt }, - }), - { - status: 429, - headers: { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache, no-transform", - "Retry-After": String(rateLimit.retryAfterSeconds), - }, - }, - ); + return rateLimitJsonResponse("Too many answer requests. Retry shortly.", rateLimit); } function streamErrorPayload(error: unknown) { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 19152338e..32d1b4f2f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,11 +15,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 +26,30 @@ export const metadata: Metadata = { }, }; +function requestMetadataBase(requestHeaders: Headers) { + const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (configured) { + const url = new URL(configured); + if (url.protocol === "http:" || url.protocol === "https:") return url; + } + + 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"; + try { + return new URL(`${protocol}://${host}`); + } catch { + return undefined; + } +} + +export async function generateMetadata(): Promise { + const requestHeaders = await headers(); + return { ...baseMetadata, metadataBase: requestMetadataBase(requestHeaders) }; +} + export const viewport: Viewport = { width: "device-width", initialScale: 1, diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index 6bbba325a..69e6d7c73 100644 --- a/src/components/DocumentViewer.tsx +++ b/src/components/DocumentViewer.tsx @@ -2430,7 +2430,6 @@ export function DocumentViewer({ : 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 canSummarizeDocument = viewerState === "ready" && !loadingSummary && canUsePrivateApis; diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index 1c1720090..c80df06d3 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/public-api-access.ts b/src/lib/public-api-access.ts index 2d238cf77..208433e76 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -17,7 +17,6 @@ function firstForwardedIp(value: string | null) { 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")) || "unknown-ip" @@ -25,8 +24,10 @@ function requestIpSignal(request: Request) { } 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 forwarding headers. Ignore the + // caller-controlled Cloudflare/User-Agent values: rotating either must not + // mint a fresh allowance. The deployment contract supplies the client as the + // first X-Forwarded-For address (followed by any proxy hops). // 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/src/lib/rag-extractive-answer.ts b/src/lib/rag-extractive-answer.ts index 46eb26cd0..43133b850 100644 --- a/src/lib/rag-extractive-answer.ts +++ b/src/lib/rag-extractive-answer.ts @@ -1638,9 +1638,21 @@ function finalQualityFailure(answer: RagAnswer, query: string, queryClass: RagQu }; } +const crossReferenceDirectivePattern = + /\b(?:refer(?:red|s|ring)?\s+to|(?:please\s+)?see|consult|as\s+(?:per|outlined|described|detailed|set\s+out))\b/i; +const crossReferenceRedirectPattern = + /\bfor\s+(?:further|more|additional|detailed|complete|full)\s+(?:information|detail|details|guidance|advice|reading|instruction|instructions)\b/i; + +/** True when the lead sentence only redirects the reader elsewhere for the answer. */ +export function isBareCrossReferenceAnswer(text: string) { + const lead = firstSentence(text).replace(/\*\*/g, ""); + return Boolean(lead && crossReferenceDirectivePattern.test(lead) && crossReferenceRedirectPattern.test(lead)); +} + /** Should preserve source backed generated answer. */ -function shouldPreserveSourceBackedGeneratedAnswer(answer: RagAnswer, reason: string) { +function shouldPreserveSourceBackedGeneratedAnswer(answer: RagAnswer, reason: string, cleanedAnswer: string) { if (reason !== "missing_query_intent" && reason !== "missing_query_overlap") return false; + if (isBareCrossReferenceAnswer(cleanedAnswer)) return false; if (!answer.grounded || answer.confidence === "unsupported" || answer.citations.length === 0) return false; if (hasInvalidModelEvidenceIds(answer)) return false; @@ -1784,7 +1796,7 @@ function finalizeRagAnswerQualityCore( : generatedAnswerQualityFailureReason(answer, query, queryClass); if (qualityFailureReason) { - if (shouldPreserveSourceBackedGeneratedAnswer(answer, qualityFailureReason)) { + if (shouldPreserveSourceBackedGeneratedAnswer(answer, qualityFailureReason, cleanedAnswer)) { answer = { ...answer, confidence: answer.confidence === "low" ? "medium" : answer.confidence, diff --git a/src/lib/validation/body.ts b/src/lib/validation/body.ts index f7a3bad5a..0fd4f7dd8 100644 --- a/src/lib/validation/body.ts +++ b/src/lib/validation/body.ts @@ -1,12 +1,53 @@ import { z } from "zod"; +import { PublicApiError } from "@/lib/http"; import { parseSchema } from "@/lib/validation/http"; +export const defaultJsonBodyLimitBytes = 256 * 1024; + +async function readBoundedJson(request: Request, maxBytes = defaultJsonBodyLimitBytes): Promise { + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new PublicApiError("Request body is too large.", 413, { code: "payload_too_large" }); + } + + if (!request.body) return null; + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel("payload_too_large"); + throw new PublicApiError("Request body is too large.", 413, { code: "payload_too_large" }); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)); + } catch { + return null; + } +} + export async function parseJsonBody( request: Request, schema: TSchema, message = "Invalid request body.", ): Promise> { - const body = await request.json().catch(() => null); + const body = await readBoundedJson(request); return parseSchema(schema, body, message, "invalid_body"); } @@ -15,7 +56,10 @@ export async function parseJsonBodyOrDefault( schema: TSchema, fallback: z.infer, ): Promise> { - const body = await request.json().catch(() => undefined); + const body = await readBoundedJson(request).catch((error) => { + if (error instanceof PublicApiError) throw error; + return undefined; + }); const parsed = schema.safeParse(body); return parsed.success ? parsed.data : fallback; } 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/bounded-json-body.test.ts b/tests/bounded-json-body.test.ts new file mode 100644 index 000000000..2a91e4ecb --- /dev/null +++ b/tests/bounded-json-body.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { parseJsonBody } from "../src/lib/validation/body"; + +const jsonBodyLimitBytes = 256 * 1024; + +describe("bounded JSON request parsing", () => { + it("rejects an oversized declared body before parsing", async () => { + const request = new Request("https://clinical.test/api/search", { + method: "POST", + headers: { "content-length": String(jsonBodyLimitBytes + 1) }, + body: "{}", + }); + + await expect(parseJsonBody(request, z.object({}))).rejects.toMatchObject({ + status: 413, + details: { code: "payload_too_large" }, + }); + }); + + it("rejects an oversized chunked body while reading", async () => { + const request = new Request("https://clinical.test/api/search", { + method: "POST", + headers: { "content-type": "application/json" }, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(jsonBodyLimitBytes + 1).fill(32)); + controller.close(); + }, + }), + duplex: "half", + } as RequestInit & { duplex: "half" }); + + await expect(parseJsonBody(request, z.object({}))).rejects.toMatchObject({ + status: 413, + details: { code: "payload_too_large" }, + }); + }); +}); diff --git a/tests/document-viewer-navigation-source.test.ts b/tests/document-viewer-navigation-source.test.ts new file mode 100644 index 000000000..9b13e382d --- /dev/null +++ b/tests/document-viewer-navigation-source.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("document viewer useful-page navigation", () => { + it("does not carry an unrelated citation chunk into a new page link", () => { + const source = readFileSync(new URL("../src/components/DocumentViewer.tsx", import.meta.url), "utf8"); + const pageHrefBody = source.match(/const documentPageHref = \(page: number\) => \{([\s\S]*?)\n \};/)?.[1] ?? ""; + + expect(pageHrefBody).not.toContain('params.set("chunk", chunkId)'); + }); +}); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index c759934d5..bb0adde93 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -3579,7 +3579,7 @@ describe("private document API access", () => { expect(client.auth.getUser).toHaveBeenCalledWith(token); }); - it("emits a structured SSE error when authenticated streaming answers are rate limited", async () => { + it("returns the shared JSON error contract when streaming answers are rate limited before streaming", async () => { const answerQuestionWithScope = vi.fn(async () => ({ answer: "Owned evidence.", grounded: true, @@ -3606,13 +3606,15 @@ describe("private document API access", () => { body: JSON.stringify({ query: "monitoring", documentId: otherDocumentId }), }), ); - const body = await response.text(); + const body = await payload(response); expect(response.status).toBe(429); expect(response.headers.get("Retry-After")).toBe("60"); - expect(body).toContain("event: error"); - expect(body).toContain('"status":429'); - expect(body).toContain("Too many answer requests. Retry shortly."); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(body).toEqual({ + error: "Too many answer requests. Retry shortly.", + retryAfterSeconds: 60, + }); expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); diff --git a/tests/production-metadata-source.test.ts b/tests/production-metadata-source.test.ts new file mode 100644 index 000000000..3bd37f83a --- /dev/null +++ b/tests/production-metadata-source.test.ts @@ -0,0 +1,11 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("production metadata origin", () => { + it("derives metadata from the request instead of a localhost production fallback", () => { + const source = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); + + expect(source).toContain("export async function generateMetadata"); + expect(source).not.toContain('metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000")'); + }); +}); diff --git a/tests/public-api-access.test.ts b/tests/public-api-access.test.ts index edaec20df..3054301e2 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 Cloudflare identity when the proxy address is stable", () => { + 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.20", "203.0.113.99")); + + expect(second).toBe(first); + }); + + it("keeps distinct client addresses separate when the proxy appends the same hop", () => { + 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.10")); + + expect(second).not.toBe(first); + }); }); diff --git a/tests/source-backed-recovery-cross-reference.test.ts b/tests/source-backed-recovery-cross-reference.test.ts new file mode 100644 index 000000000..a12055a2a --- /dev/null +++ b/tests/source-backed-recovery-cross-reference.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { finalizeRagAnswerQuality } from "../src/lib/rag-extractive-answer"; +import type { RagAnswer, RetrievalSelectionSummary } from "../src/lib/types"; + +function strongSourceSelection(): RagAnswer["smartApiPlan"] { + const sourceSelection = { + candidateCount: 4, + selectedCount: 3, + requiredSignalsSatisfied: true, + matchedSignals: ["document_title", "patient_education", "table"], + missingRequiredSignals: [], + rescueApplied: false, + topChunkTypes: { text: 2, table: 1, flowchart: 0, medication_chart: 0, patient_education: 2 }, + } satisfies RetrievalSelectionSummary; + return { answerPlan: { sourceSelection } } as unknown as RagAnswer["smartApiPlan"]; +} + +describe("source-backed recovery cross-reference guard", () => { + it("does not promote a bare refer-elsewhere redirect to a grounded answer", () => { + const answer = finalizeRagAnswerQuality( + { + answer: + "Refer to the RKPG Guidelines to Writing for Clinical Policy for further information about Scope of Practice.", + grounded: true, + confidence: "low", + citations: [ + { + chunk_id: "discharge-1", + document_id: "discharge-doc", + title: "Admission to Discharge for Mental Health Inpatients", + file_name: "Admission to Discharge.pdf", + page_number: 3, + chunk_index: 0, + }, + ], + sources: [], + routingMode: "extractive", + routingReason: "high_confidence_extractive_retrieval", + smartApiPlan: strongSourceSelection(), + }, + "What should discharge documentation include?", + "broad_summary", + ); + + expect(answer.grounded).toBe(false); + expect(answer.routingReason).toContain("final_quality_gate:missing_query_overlap"); + expect(answer.routingReason).not.toContain("source_backed_recovery"); + expect(answer.answer).not.toMatch(/RKPG|Refer to the/i); + }); +}); From 9cd455492f0a927c7262b95076c690329b79caf0 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:38:14 +0800 Subject: [PATCH 2/6] fix: harden metadata and navigation regressions --- src/app/layout.tsx | 25 ++++------------- src/components/DocumentViewer.tsx | 8 ++---- src/lib/document-viewer-navigation.ts | 5 ++++ src/lib/metadata-base.ts | 23 +++++++++++++++ .../document-viewer-navigation-source.test.ts | 15 ++++++---- tests/production-metadata-source.test.ts | 28 +++++++++++++++---- 6 files changed, 69 insertions(+), 35 deletions(-) create mode 100644 src/lib/document-viewer-navigation.ts create mode 100644 src/lib/metadata-base.ts diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 32d1b4f2f..a6e1e6427 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({ @@ -26,28 +27,12 @@ const baseMetadata: Metadata = { }, }; -function requestMetadataBase(requestHeaders: Headers) { - const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim(); - if (configured) { - const url = new URL(configured); - if (url.protocol === "http:" || url.protocol === "https:") return url; - } - - 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"; - try { - return new URL(`${protocol}://${host}`); - } catch { - return undefined; - } -} - export async function generateMetadata(): Promise { const requestHeaders = await headers(); - return { ...baseMetadata, metadataBase: requestMetadataBase(requestHeaders) }; + return { + ...baseMetadata, + metadataBase: resolveMetadataBase(requestHeaders, process.env.NEXT_PUBLIC_SITE_URL), + }; } export const viewport: Viewport = { diff --git a/src/components/DocumentViewer.tsx b/src/components/DocumentViewer.tsx index defc6d9e9..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,10 +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))) }); - 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]; @@ -2739,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/document-viewer-navigation.ts b/src/lib/document-viewer-navigation.ts new file mode 100644 index 000000000..f8561cd2b --- /dev/null +++ b/src/lib/document-viewer-navigation.ts @@ -0,0 +1,5 @@ +/** Build a useful-page link without retaining evidence pinned to a different page. */ +export function documentPageHref(documentId: string, page: number) { + const params = new URLSearchParams({ page: String(Math.max(1, Math.trunc(page))) }); + 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..3dc8b5010 --- /dev/null +++ b/src/lib/metadata-base.ts @@ -0,0 +1,23 @@ +/** Resolve the canonical metadata origin from validated configuration or trusted request headers. */ +export function resolveMetadataBase(requestHeaders: Headers, configuredSiteUrl?: string) { + const configured = configuredSiteUrl?.trim(); + if (configured) { + try { + const url = new URL(configured); + if (url.protocol === "http:" || url.protocol === "https:") return url; + } catch { + // Fall through to the request-derived origin. + } + } + + 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"; + try { + return new URL(`${protocol}://${host}`); + } catch { + return undefined; + } +} diff --git a/tests/document-viewer-navigation-source.test.ts b/tests/document-viewer-navigation-source.test.ts index 9b13e382d..d9332e173 100644 --- a/tests/document-viewer-navigation-source.test.ts +++ b/tests/document-viewer-navigation-source.test.ts @@ -1,11 +1,16 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { documentPageHref } from "../src/lib/document-viewer-navigation"; + describe("document viewer useful-page navigation", () => { - it("does not carry an unrelated citation chunk into a new page link", () => { - const source = readFileSync(new URL("../src/components/DocumentViewer.tsx", import.meta.url), "utf8"); - const pageHrefBody = source.match(/const documentPageHref = \(page: number\) => \{([\s\S]*?)\n \};/)?.[1] ?? ""; + 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="); + }); - expect(pageHrefBody).not.toContain('params.set("chunk", chunkId)'); + it("normalizes invalid page numbers to the first page", () => { + expect(documentPageHref("document-id", 0)).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 index 3bd37f83a..90c8b3037 100644 --- a/tests/production-metadata-source.test.ts +++ b/tests/production-metadata-source.test.ts @@ -1,11 +1,29 @@ -import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { resolveMetadataBase } from "../src/lib/metadata-base"; + describe("production metadata origin", () => { - it("derives metadata from the request instead of a localhost production fallback", () => { - const source = readFileSync(new URL("../src/app/layout.tsx", import.meta.url), "utf8"); + it.each(["http://clinical.test", "https://clinical.test"])( + "uses a configured HTTP(S) origin: %s", + (configuredOrigin) => { + expect(resolveMetadataBase(new Headers(), configuredOrigin)?.href).toBe(`${configuredOrigin}/`); + }, + ); + + it("derives the origin from forwarded request headers when configuration is absent", () => { + const requestHeaders = new Headers({ + host: "internal.test:3000", + "x-forwarded-host": "clinical.example.org, proxy.internal", + "x-forwarded-proto": "https, http", + }); + + expect(resolveMetadataBase(requestHeaders)?.href).toBe("https://clinical.example.org/"); + }); + + it("falls back to the request origin when the configured value is malformed", () => { + const requestHeaders = new Headers({ host: "clinical.example.org" }); - expect(source).toContain("export async function generateMetadata"); - expect(source).not.toContain('metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000")'); + expect(() => resolveMetadataBase(requestHeaders, "not a valid URL")).not.toThrow(); + expect(resolveMetadataBase(requestHeaders, "not a valid URL")?.href).toBe("https://clinical.example.org/"); }); }); From 6b280bd85aecab9339081db2df5fe93734c222ba Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:51:22 +0800 Subject: [PATCH 3/6] test: align browser storage with scoped sessions --- tests/ui-smoke.spec.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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); From faac19b3e11f92a05ae9886dbccdf7f8de2d5cb9 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:05:20 +0800 Subject: [PATCH 4/6] fix: trust deployment-owned request identity --- .env.example | 3 +- src/app/layout.tsx | 6 ++- src/lib/document-viewer-navigation.ts | 3 +- src/lib/metadata-base.ts | 43 ++++++++++++------- src/lib/public-api-access.ts | 19 ++++---- .../document-viewer-navigation-source.test.ts | 4 ++ tests/production-metadata-source.test.ts | 37 +++++++++++++--- tests/public-api-access.test.ts | 8 ++-- 8 files changed, 86 insertions(+), 37 deletions(-) diff --git a/.env.example b/.env.example index 75e80cb25..e90b81a83 100644 --- a/.env.example +++ b/.env.example @@ -81,7 +81,8 @@ RAG_AWAIT_QUERY_LOGS=false # 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. When omitted, metadata uses the request host. +# 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 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a6e1e6427..55a720ae2 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -31,7 +31,11 @@ export async function generateMetadata(): Promise { const requestHeaders = await headers(); return { ...baseMetadata, - metadataBase: resolveMetadataBase(requestHeaders, process.env.NEXT_PUBLIC_SITE_URL), + metadataBase: resolveMetadataBase(requestHeaders, { + configuredSiteUrl: process.env.NEXT_PUBLIC_SITE_URL, + trustedDeploymentDomain: process.env.RAILWAY_PUBLIC_DOMAIN, + allowRequestOrigin: process.env.NODE_ENV !== "production", + }), }; } diff --git a/src/lib/document-viewer-navigation.ts b/src/lib/document-viewer-navigation.ts index f8561cd2b..4e05e2911 100644 --- a/src/lib/document-viewer-navigation.ts +++ b/src/lib/document-viewer-navigation.ts @@ -1,5 +1,6 @@ /** Build a useful-page link without retaining evidence pinned to a different page. */ export function documentPageHref(documentId: string, page: number) { - const params = new URLSearchParams({ page: String(Math.max(1, Math.trunc(page))) }); + 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 index 3dc8b5010..ed334909d 100644 --- a/src/lib/metadata-base.ts +++ b/src/lib/metadata-base.ts @@ -1,23 +1,36 @@ -/** Resolve the canonical metadata origin from validated configuration or trusted request headers. */ -export function resolveMetadataBase(requestHeaders: Headers, configuredSiteUrl?: string) { - const configured = configuredSiteUrl?.trim(); - if (configured) { - try { - const url = new URL(configured); - if (url.protocol === "http:" || url.protocol === "https:") return url; - } catch { - // Fall through to the request-derived origin. - } +export type MetadataBaseOptions = { + configuredSiteUrl?: string; + trustedDeploymentDomain?: string; + allowRequestOrigin?: boolean; +}; + +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"; - try { - return new URL(`${protocol}://${host}`); - } catch { - return undefined; - } + return httpUrl(`${protocol}://${host}`); } diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 208433e76..22e4aac25 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -11,23 +11,26 @@ type AdminClient = ReturnType; export type RateLimitSubject = { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string }; -function firstForwardedIp(value: string | null) { - return value?.split(",")[0]?.trim() || ""; +function trustedProxyIp(value: string | null) { + const forwarded = value + ?.split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + return forwarded?.at(-1) ?? ""; } function requestIpSignal(request: Request) { return ( - 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" ); } export function anonymousApiSubjectKey(request: Request) { - // Trust only the deployment proxy's forwarding headers. Ignore the - // caller-controlled Cloudflare/User-Agent values: rotating either must not - // mint a fresh allowance. The deployment contract supplies the client as the - // first X-Forwarded-For address (followed by any proxy hops). + // 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/document-viewer-navigation-source.test.ts b/tests/document-viewer-navigation-source.test.ts index d9332e173..3b4574e38 100644 --- a/tests/document-viewer-navigation-source.test.ts +++ b/tests/document-viewer-navigation-source.test.ts @@ -12,5 +12,9 @@ describe("document viewer useful-page navigation", () => { 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 index 90c8b3037..6ac2a2c54 100644 --- a/tests/production-metadata-source.test.ts +++ b/tests/production-metadata-source.test.ts @@ -6,24 +6,47 @@ describe("production metadata origin", () => { it.each(["http://clinical.test", "https://clinical.test"])( "uses a configured HTTP(S) origin: %s", (configuredOrigin) => { - expect(resolveMetadataBase(new Headers(), configuredOrigin)?.href).toBe(`${configuredOrigin}/`); + expect(resolveMetadataBase(new Headers(), { configuredSiteUrl: configuredOrigin })?.href).toBe( + `${configuredOrigin}/`, + ); }, ); - it("derives the origin from forwarded request headers when configuration is absent", () => { + 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)?.href).toBe("https://clinical.example.org/"); + 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("falls back to the request origin when the configured value is malformed", () => { - const requestHeaders = new Headers({ host: "clinical.example.org" }); + 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, "not a valid URL")).not.toThrow(); - expect(resolveMetadataBase(requestHeaders, "not a valid URL")?.href).toBe("https://clinical.example.org/"); + expect(resolveMetadataBase(requestHeaders)).toBeUndefined(); }); }); diff --git a/tests/public-api-access.test.ts b/tests/public-api-access.test.ts index 3054301e2..e7c15d3da 100644 --- a/tests/public-api-access.test.ts +++ b/tests/public-api-access.test.ts @@ -34,16 +34,16 @@ describe("anonymous API rate-limit identity", () => { expect(second).not.toBe(first); }); - it("ignores caller-controlled Cloudflare identity when the proxy address is stable", () => { + 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.10, 198.51.100.20", "203.0.113.99")); + const second = anonymousApiSubjectKey(forwardedRequest("192.0.2.99, 198.51.100.20", "203.0.113.99")); expect(second).toBe(first); }); - it("keeps distinct client addresses separate when the proxy appends the same hop", () => { + 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.99, 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); }); From b2e60e0751084a4b41ab78d8996e0da2f4ca3aaa Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:18:10 +0800 Subject: [PATCH 5/6] test: match split-pane preview semantics --- tests/ui-tools-collapse.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); }); }); From a035fa7d7ce16ba2758886b16b69dee0ff86f820 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:21:34 +0800 Subject: [PATCH 6/6] docs: clarify trusted request helpers --- src/lib/metadata-base.ts | 1 + src/lib/public-api-access.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/lib/metadata-base.ts b/src/lib/metadata-base.ts index ed334909d..19da73187 100644 --- a/src/lib/metadata-base.ts +++ b/src/lib/metadata-base.ts @@ -4,6 +4,7 @@ export type MetadataBaseOptions = { 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; diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 22e4aac25..36e35fdd9 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -11,6 +11,7 @@ type AdminClient = ReturnType; export type RateLimitSubject = { kind: "owner"; ownerId: string } | { kind: "anonymous"; subjectKey: string }; +/** Read the deployment proxy's appended address from a forwarded IP chain. */ function trustedProxyIp(value: string | null) { const forwarded = value ?.split(",") @@ -19,6 +20,7 @@ function trustedProxyIp(value: string | null) { return forwarded?.at(-1) ?? ""; } +/** Select the strongest deployment-owned request identity signal available. */ function requestIpSignal(request: Request) { return ( trustedProxyIp(request.headers.get("x-forwarded-for")) || @@ -27,6 +29,7 @@ function requestIpSignal(request: Request) { ); } +/** Derive a stable, non-reversible quota subject for an anonymous caller. */ export function anonymousApiSubjectKey(request: Request) { // Trust only the deployment proxy's appended forwarding entry. Ignore the // caller-controlled Cloudflare/User-Agent values and any leading XFF entries: