diff --git a/.prettierignore b/.prettierignore index 2c40481a2..a8f1682ce 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ out/ build/ coverage/ test-results/ +test-results-codex-remediation/ playwright-report/ sample-documents/ dev-server*.log diff --git a/AGENTS.md b/AGENTS.md index fc7060c6e..5c8c100f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,7 +164,7 @@ When a branch or PR review completes, record the reviewed branch/ref, HEAD SHA, # Process hardening phases -- For non-trivial source/config/test changes, prefer `npm run verify:cheap` as the first broad gate. +- For non-trivial source/config/test changes, prefer `npm run verify:cheap` as the first broad gate and `npm run verify:pr-local` before PR handoff when the change is ready. The PR-local gate runs format plus `verify:cheap`, then conditionally adds the production build/client-bundle scan and code-backed offline RAG tests. Browser, dependency-audit, Docker/Supabase replay, and provider-backed checks remain separate gates. Use `npm run verify:pr-local -- --dry-run --files ` to inspect selection without running commands. The broader `--extended` plan is dry-run only unless explicit approval is reflected by `ALLOW_EXTENDED_PR_LOCAL=true`. - For UI, frontend, browser, routing, styling, reduced-motion, or forced-colors changes, run `npm run ensure` before browser work and use `npm run verify:ui` as the Chromium UI gate. - For release or handoff confidence, use `npm run verify:release`; this includes the full Playwright project set. - For clinical ingestion, answer generation, source governance, privacy, production-readiness, or environment changes, run the smallest relevant domain check plus `npm run check:production-readiness`. @@ -414,4 +414,4 @@ Durable notes for Cloud Agents. Standard commands live in `README.md` and `packa - Live-mode caveat: `RAG_PROVIDER_MODE=auto` attempts OpenAI (fast → strong route); if generation fails the built-in quality gates it silently degrades to a deterministic "Source-only" answer that still cites real documents — this is expected, not a failure. The header sign-in UI exposes magic-link + OAuth only (no password field), but the `/api/answer` + retrieval flow works server-side without a browser session. - What still won't run in this VM even with secrets: `npm run worker` also needs the Python OCR stack (`worker/python/requirements.txt`) and heavy parsing deps; Supabase edge functions need Deno v2.x + deployment. `verify:release` additionally runs governance/eval gates. Treat missing-secret failures of `check:supabase-project`/`verify:release` in demo mode as expected, not regressions. - Dev server: `npm run dev` selects a stable per-project localhost port (e.g. `4461`), binds `0.0.0.0`, and prints the exact URL. Never assume port 3000/3001/3002. `npm run ensure` starts/verifies it in the background. -- Verification without secrets: `npm run lint`, `npm run typecheck`, and `npm run test` (vitest) all pass offline. `npm run verify:cheap` also runs `check:runtime` + `sitemap:check` and is safe offline. +- Verification without secrets: `npm run lint`, `npm run typecheck`, and `npm run test` (vitest) all pass offline. `npm run verify:cheap` also runs runtime, GitHub Actions pin, CI-scope, and sitemap checks. `npm run verify:pr-local` adds format, conditional build/client-bundle scanning, and code-backed offline RAG tests; browser, Docker/Supabase, audit, and provider checks remain separate. diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index 6891d4ae9..cd4b75f63 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -42,3 +42,4 @@ Use this ledger to prevent repeated branch and PR reviews when the reviewed HEAD | 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. | diff --git a/docs/database-drift-detection.md b/docs/database-drift-detection.md index 852291ec0..c64fcf327 100644 --- a/docs/database-drift-detection.md +++ b/docs/database-drift-detection.md @@ -1,6 +1,6 @@ # Database drift detection (`npm run check:drift`) -Last updated: 2026-07-07 +Last updated: 2026-07-10 This repo's worst operational incidents were live-vs-repo schema drift: hybrid retrieval RPCs silently broken on live for an unknown period, and migrations @@ -99,6 +99,13 @@ live project need explicit operator approval. > found live had diverged _forward_ from its retrieval bodies (item 0, new). > Live is under active concurrent multi-session editing, so the allowlist is a > point-in-time snapshot needing periodic regeneration. +> +> **2026-07-10 update:** local `check:drift` surfaced five repo-ahead live debts +> for the July 8 hardening batch: fail-closed `retrieval_owner_matches`, R17's +> one-open-ingestion-job index, and R5's document metadata deep-merge helpers / +> `commit_document_index_generation` body. These are now allowlisted as known +> pending live-apply work. Applying them to live remains an explicit +> operator-approved migration action. 0. **NEW — forward-codify the live-ahead retrieval RPCs** (was the "apply 20260705210000" item, inverted). Live carries newer raw-SQL retrieval bodies @@ -124,6 +131,13 @@ live project need explicit operator approval. 2. ✅ **DONE 2026-07-08** — `20260703030000`'s effects (recorded-but-absent on live) re-applied via `20260708000000_reapply_storage_cleanup_jobs_indexes`; live storage_cleanup_jobs indexes now match schema.sql. + 2a. **Apply the remaining July 8 hardening batch to live**: run the approved + migration workflow for `20260708160001_retrieval_owner_matches_fail_closed`, + `20260708170000_ingestion_jobs_one_open_per_document`, and + `20260708310000_r5_document_metadata_merge`. Remove the matching allowlist + entries for `retrieval_owner_matches`, `ingestion_jobs_one_open_per_document_uidx`, + `jsonb_merge_deep`, `apply_document_metadata_patch`, and + `commit_document_index_generation` after `check:drift` verifies live parity. 3. **Codify the remaining live-only functions**: `get_visual_evidence_cards`, `repair_enrichment_quality_batch`, `run_all_visual_eval_cases`, `run_visual_eval_case` (same pattern as `20260707000000`). diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index c3495a4eb..b2130c7b0 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -69,9 +69,12 @@ them only after the shared `rag_response_cache` hit rate is confirmed healthy. - `node:24-bookworm-slim` in all stages — respects `engines`/`engine-strict` and the `preinstall` engine guard. - The build stage runs the repo's own `npm run build` - (`guard-next-build.mjs` + `next build --webpack`) — **the image build fails - exactly where a local build would**. The build allocates an 8 GiB heap; give - the Docker builder ≥ 10 GiB memory. + (`guard-next-build.mjs` + `next build --webpack` + the client-bundle secret + scan) — **the image build fails exactly where a local build would**. The + `--webpack` flag is deliberate: `next.config.ts` carries a webpack-specific + WasmHash workaround and the CSP-nonce work was validated against webpack + prod chunks, so switching bundlers needs its own verified change. The build + allocates an 8 GiB heap; give the Docker builder ≥ 10 GiB memory. - `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY` are build args (they inline into the client bundle). The publishable key is public by design; the placeholder default exists so CI can build without diff --git a/docs/operator-apply-july8-batch.md b/docs/operator-apply-july8-batch.md index 6c6afe2da..a011ebdce 100644 --- a/docs/operator-apply-july8-batch.md +++ b/docs/operator-apply-july8-batch.md @@ -23,15 +23,15 @@ is live — `worker/main.ts` already passes `p_worker_id` to completion RPCs. All steps below are safe through a single `supabase db push` when the ingestion queue is quiet. R17 uses its **own migration version** (`20260708170000`, not -`20260708160000`) so history/repair cannot collide with the fail-closed tenancy -migration at `20260708160000`. +`20260708160001`) so history/repair cannot collide with the fail-closed tenancy +migration at `20260708160001`. | Step | Migration | What | How | | ---- | --------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------- | | 1 | `20260708140000_drop_ingestion_job_stages_job_id_fk.sql` | R24e — drop phantom FK from fresh-env schema | Normal `supabase db push` (no-op on live) | | 2 | `20260708130000_ingestion_concurrency_rpc_hardening.sql` | R1/R2 lease fences, R7/R9/R23 RPC hardening | Normal push — **apply before worker redeploy** | | 3 | `20260708150000_ensure_retrieval_owner_matches.sql` | Ensure helper exists before fail-closed | Normal push | -| 4 | `20260708160000_retrieval_owner_matches_fail_closed.sql` | Tenancy fail-closed (#409) | Normal push | +| 4 | `20260708160001_retrieval_owner_matches_fail_closed.sql` | Tenancy fail-closed (#409) | Normal push | | 5 | `20260708310000_r5_document_metadata_merge.sql` | R5 metadata deep-merge (#408) | Normal push (safe before worker) | | 6 | `20260708170000_ingestion_jobs_one_open_per_document.sql` | R17 one-open-job index (#405) | Normal push when queue quiet — see below | @@ -48,7 +48,7 @@ create unique index concurrently if not exists ingestion_jobs_one_open_per_docum where status in ('pending', 'processing'); ``` -Then mark only the R17 version as applied (never `20260708160000`): +Then mark only the R17 version as applied (never `20260708160001`): ```bash supabase migration repair --linked --status applied 20260708170000 diff --git a/docs/rag-hybrid-findings-and-todo.md b/docs/rag-hybrid-findings-and-todo.md index ef4558a80..4c65954f3 100644 --- a/docs/rag-hybrid-findings-and-todo.md +++ b/docs/rag-hybrid-findings-and-todo.md @@ -303,12 +303,13 @@ denied to set parameter`)** — the RC11 blocker. The only method hosted allows answer-confidence label — "high" requires a genuine-cosine citation; synthetic-origin evidence caps at "medium" (strictly tightening, ordering/routing untouched, unit-tested in tests/rag-score.test.ts). -22. 🔶 **Registry-to-corpus embedding (universal search Phase 5) — implementation restored and live - owner embedded (2026-07-09).** Medications/services/forms/differentials are federated into +22. ✅ **Registry-to-corpus embedding (universal search Phase 5) — implemented behind the + default-off flag, live owner embedded, and blocking gates clean (2026-07-09).** Medications/services/forms/differentials are federated into `/api/search/universal` but were not retrieval-corpus entities, so Answer mode could not cite them. Implemented pieces: `RAG_REGISTRY_CORPUS_EMBEDDING` default-off flag, `scripts/embed-registry-records.ts` dry-run/write/list-owner tool, synthetic document/chunk mapping with `metadata.source_kind = 'registry_record'`, source-governance labelling, comparator tooling, + seed/reseed embedding paths, `reembedRegistryRecordAfterEdit` / `bestEffortReembedRegistryRecordAfterEdit`, and registry corpus tests. The sentinel-owner dry-run (`00000000-0000-0000-0000-000000000000`) correctly found zero rows. `--list-owners` found the real registry owner `4f1b3c19-3c39-4597-b9df-168c8e6007ff` with 739 eligible rows; guarded write with @@ -316,12 +317,14 @@ denied to set parameter`)** — the RC11 blocker. The only method hosted allows chunks. Post-write retrieval eval passed with `document_recall_at_5=1`, `content_recall_at_5=1`, `top_k_hit_rate=1`, `force_embedding_failure_count=0`, `failed_cases=[]`. Post-write `eval:quality -- --rag-only` completed under budget; invented-term controls still refused and - numeric grounding failure rate was `0`. Remaining blockers are not registry regressions: - citation failure rate `0.0227` and RAG latency thresholds (`p95=44847ms`; route p95 extractive - `46745ms`, fast `27131ms`, strong `63613ms`). Remaining implementation before treating this as - fully productized: re-embed-on-edit hooks for registry updates and a dedicated answer-mode UX check - that registry-backed citations are labelled as curated registry records rather than primary source - documents. + numeric grounding failure rate was `0`. The post-routing RAG-only rerun then cleared the former + blocker metrics: `citation_failure_rate=0`, `numeric_grounding_failure_rate=0`, no blocking threshold + failures, and `p95_latency_ms=20385`. Two individual cases still exceeded the 20-second latency target; + that non-blocking performance debt remains tracked in item 25. Current productization boundary: there are no mutating registry + edit routes today, so the re-embed-on-edit helper is present but not wired to a route; any future + registry `POST`/`PATCH`/`PUT` path must call `bestEffortReembedRegistryRecordAfterEdit` after the + write commits. Remaining non-blocking UX follow-up: an answer-mode check that registry-backed + citations render as curated registry records rather than primary source documents. 23. ⏳ **Finding #11 full fix (RAG optimisation Phase 2)** — the classifier-verdict memo (shipped 2026-07-06) makes zero-result behaviour deterministic per query but does not close the gap: the deterministic analyzer still cannot tell in-corpus topics from out-of-corpus ones. @@ -333,9 +336,15 @@ denied to set parameter`)** — the RC11 blocker. The only method hosted allows (compare table-cell tokens against the document's own clean chunk text — "p ycho ocial" aligns to "psychosocial" within the same page's raw text) rather than heuristic detection at query time. Scope to `worker/` table extraction; requires the Python OCR stack to test. -25. ⏳ **Retrieval latency p90 ~8.6s (local)** — remaining sequential layers after the 2026-07-01 - parallelisation. Cheapest next step (measure first): overlap `embedTextWithTelemetry` with - the text fast path unconditionally (today preload only fires when `shouldPreloadEmbedding`), - and collapse the repeated `attachDocumentRankingMetadata` calls to one batched fetch per - request. Both are perf-only; gate with the golden eval unchanged + p90 from - `rag_retrieval_logs` before/after. +25. ⏳ **Retrieval/RAG latency remains a performance backlog, not a current gate blocker.** + Post-registry retrieval stayed quality-clean (`top_k_hit_rate=1`, `document_recall_at_5=1`, + `content_recall_at_5=1`, `force_embedding_failure_count=0`) with local `p90_latency_ms=13145`. + Post-routing RAG-only passed with `p95_latency_ms=20385` and no blocking threshold failures, but + local-machine→remote-DB latency and remaining sequential layers still justify a dedicated perf pass. + Next step (measure first): instrument the existing per-request `documentRankingMetadataCache` to + quantify incremental cache misses/fetches before changing enrichment, then test starting + `embedTextWithTelemetry` concurrently only when `forceEmbedding` is set or routing has already ruled + out an accepted lexical/document fast path. Do not preload embeddings unconditionally: preserve + source-only and lexical-only behavior, and record provider-call count plus whether each embedding + result was consumed so any p90 gain is weighed against cost. Gate with the golden eval unchanged, + provider usage unchanged or explicitly accepted, and p90 from `rag_retrieval_logs` before/after. diff --git a/docs/tenancy-defense-in-depth-review.md b/docs/tenancy-defense-in-depth-review.md index b2047541e..b3b591c11 100644 --- a/docs/tenancy-defense-in-depth-review.md +++ b/docs/tenancy-defense-in-depth-review.md @@ -24,7 +24,7 @@ That said, this is a **single-layer** design with one structural weakness that * > **The database had no independent tenancy floor for NULL `owner_filter`.** Before #409, the shared > `retrieval_owner_matches` helper returned _every_ row when `owner_filter IS NULL` (fail-open). PR -> #409 (`20260708160000_retrieval_owner_matches_fail_closed.sql`) makes `NULL` match **no rows**; the +> #409 (`20260708160001_retrieval_owner_matches_fail_closed.sql`) makes `NULL` match **no rows**; the > app routes demo/test/local-no-auth through the public sentinel (`00000000-…`) instead of `NULL` > ([owner-scope.ts](src/lib/owner-scope.ts)). Production paths that lack an owner still throw before > any RPC is called. @@ -80,7 +80,7 @@ Every retrieval RPC gates rows through `retrieval_owner_matches`. **As of PR #40 fail-closed on `NULL`: ```sql --- migration 20260708160000_retrieval_owner_matches_fail_closed.sql +-- migration 20260708160001_retrieval_owner_matches_fail_closed.sql create function public.retrieval_owner_matches(owner_filter uuid, row_owner_id uuid) returns boolean as $$ select case when owner_filter is null then false -- fail-closed (was fail-open pre-#409) diff --git a/package.json b/package.json index 830a4b8c2..950af5169 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "dev": "node scripts/dev-free-port.mjs", "preinstall": "node scripts/check-node-engine.cjs", "ensure": "node scripts/ensure-local-server.mjs", - "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack", + "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack && node scripts/check-client-bundle-secrets.mjs", "build:analyze": "node scripts/build-analyze.mjs", "start": "node scripts/dev-free-port.mjs start", "lint": "node --max-old-space-size=8192 ./node_modules/eslint/bin/eslint.js src tests scripts worker supabase playwright eslint.config.mjs next.config.ts playwright.config.ts playwright.visual.config.ts vitest.config.mts --no-error-on-unmatched-pattern", @@ -31,60 +31,61 @@ "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", "ci:env-check": "node scripts/check-ci-env.mjs", "check:github-actions": "node scripts/check-github-action-pins.mjs", + "check:client-bundle-secrets": "node scripts/check-client-bundle-secrets.mjs", "check:ci-scope": "node scripts/ci-change-scope.mjs --self-test", - "sitemap:update": "tsx scripts/generate-site-map.ts", - "sitemap:check": "tsx scripts/generate-site-map.ts --check", - "check:runtime": "tsx scripts/check-runtime.ts", + "sitemap:update": "node scripts/run-tsx.mjs scripts/generate-site-map.ts", + "sitemap:check": "node scripts/run-tsx.mjs scripts/generate-site-map.ts --check", + "check:runtime": "node scripts/run-tsx.mjs scripts/check-runtime.ts", "check:codex-autofix-workflow": "node scripts/check-codex-autofix-workflow.mjs", "check:deployment-readiness": "node scripts/deployment-boot-smoke.mjs", "check:edge:functions": "node scripts/check-edge-functions.mjs", - "check:production-readiness": "tsx scripts/production-readiness.ts", - "check:production-readiness:ci": "tsx scripts/production-readiness.ts --ci", + "check:production-readiness": "node scripts/run-tsx.mjs scripts/production-readiness.ts", + "check:production-readiness:ci": "node scripts/run-tsx.mjs scripts/production-readiness.ts --ci", "format": "prettier --write .", "format:check": "prettier --check .", - "worker": "tsx worker/index.ts", - "worker:once": "tsx worker/index.ts --once", - "import:docs": "tsx scripts/import-documents.ts", - "import:docs:20": "tsx scripts/import-documents.ts --queue-batch-size 20", - "measure:wrapped-dose-units": "tsx scripts/measure-wrapped-dose-prevalence.ts", - "enrich:documents": "tsx scripts/enrich-documents.ts", - "enrich:backfill": "tsx scripts/backfill-enrichment.ts", - "classify:documents": "tsx scripts/classify-documents.ts", - "audit:source-governance": "tsx scripts/audit-source-governance.ts", + "worker": "node scripts/run-tsx.mjs worker/index.ts", + "worker:once": "node scripts/run-tsx.mjs worker/index.ts --once", + "import:docs": "node scripts/run-tsx.mjs scripts/import-documents.ts", + "import:docs:20": "node scripts/run-tsx.mjs scripts/import-documents.ts --queue-batch-size 20", + "measure:wrapped-dose-units": "node scripts/run-tsx.mjs scripts/measure-wrapped-dose-prevalence.ts", + "enrich:documents": "node scripts/run-tsx.mjs scripts/enrich-documents.ts", + "enrich:backfill": "node scripts/run-tsx.mjs scripts/backfill-enrichment.ts", + "classify:documents": "node scripts/run-tsx.mjs scripts/classify-documents.ts", + "audit:source-governance": "node scripts/run-tsx.mjs scripts/audit-source-governance.ts", "audit:source-governance:release": "npm run audit:source-governance -- --debt-policy docs/release-source-metadata-debt-2026-06-30.json", "governance:release": "npm run check:document-label-coverage && npm run check:document-label-governance && npm run audit:source-governance:release", - "check:document-label-coverage": "tsx scripts/check-document-label-coverage.ts", - "check:document-label-governance": "tsx scripts/check-document-label-governance.ts", - "check:retrieval-owner-migration": "tsx scripts/check-retrieval-owner-migration.ts", - "backfill:gold-labels": "tsx scripts/backfill-gold-document-labels.ts", + "check:document-label-coverage": "node scripts/run-tsx.mjs scripts/check-document-label-coverage.ts", + "check:document-label-governance": "node scripts/run-tsx.mjs scripts/check-document-label-governance.ts", + "check:retrieval-owner-migration": "node scripts/run-tsx.mjs scripts/check-retrieval-owner-migration.ts", + "backfill:gold-labels": "node scripts/run-tsx.mjs scripts/backfill-gold-document-labels.ts", "backfill:smart-v2-labels": "npm run classify:documents -- --all-owners --limit 5000 --only-missing-smart-v2", - "tags:backfill": "tsx scripts/backfill-document-tags.ts", - "index:backfill": "tsx scripts/backfill-smart-index.ts", - "visual:backfill": "tsx scripts/backfill-visual-intelligence.ts", - "backfill:text-normalization": "tsx scripts/backfill-text-normalization.ts", - "backfill:source-metadata": "tsx scripts/backfill-source-metadata.ts", - "backfill:unknown-status": "tsx scripts/derive-unknown-status.ts", - "check:supabase-project": "tsx scripts/check-supabase-project.ts", - "check:indexing": "tsx scripts/check-indexing.ts", - "check:m13-migration": "tsx scripts/check-m13-migration.ts", - "check:july8-live-batch": "tsx scripts/check-july8-live-batch.ts", + "tags:backfill": "node scripts/run-tsx.mjs scripts/backfill-document-tags.ts", + "index:backfill": "node scripts/run-tsx.mjs scripts/backfill-smart-index.ts", + "visual:backfill": "node scripts/run-tsx.mjs scripts/backfill-visual-intelligence.ts", + "backfill:text-normalization": "node scripts/run-tsx.mjs scripts/backfill-text-normalization.ts", + "backfill:source-metadata": "node scripts/run-tsx.mjs scripts/backfill-source-metadata.ts", + "backfill:unknown-status": "node scripts/run-tsx.mjs scripts/derive-unknown-status.ts", + "check:supabase-project": "node scripts/run-tsx.mjs scripts/check-supabase-project.ts", + "check:indexing": "node scripts/run-tsx.mjs scripts/check-indexing.ts", + "check:m13-migration": "node scripts/run-tsx.mjs scripts/check-m13-migration.ts", + "check:july8-live-batch": "node scripts/run-tsx.mjs scripts/check-july8-live-batch.ts", "check:type-scale": "node scripts/check-type-scale.mjs --strict", - "recover:ingestion": "tsx scripts/recover-ingestion-queue.ts", - "registry:seed": "tsx scripts/seed-registry-records.ts", - "registry:embed": "tsx scripts/embed-registry-records.ts", - "services:import": "tsx scripts/import-services-export.ts", - "differentials:import": "tsx scripts/import-differentials-export.ts", - "differentials:seed": "tsx scripts/seed-differential-records.ts", - "medications:import": "tsx scripts/import-medications-export.ts", - "medications:seed": "tsx scripts/seed-medication-records.ts", - "reindex": "tsx scripts/reindex.ts", - "reindex:health": "tsx scripts/reindex-health.ts", - "reindex:cleanup-staged": "tsx scripts/cleanup-abandoned-reindex-generations.ts", - "images:re-stamp-generation": "tsx scripts/reindex-image-generation-metadata.ts", - "supabase:recovery-status": "tsx scripts/supabase-recovery-status.ts", - "promote:query-misses": "tsx scripts/promote-query-misses.ts", - "promote:public-documents": "tsx scripts/promote-public-documents.ts", - "promote:public-documents:batch": "tsx scripts/promote-public-documents-batch.ts", + "recover:ingestion": "node scripts/run-tsx.mjs scripts/recover-ingestion-queue.ts", + "registry:seed": "node scripts/run-tsx.mjs scripts/seed-registry-records.ts", + "registry:embed": "node scripts/run-tsx.mjs scripts/embed-registry-records.ts", + "services:import": "node scripts/run-tsx.mjs scripts/import-services-export.ts", + "differentials:import": "node scripts/run-tsx.mjs scripts/import-differentials-export.ts", + "differentials:seed": "node scripts/run-tsx.mjs scripts/seed-differential-records.ts", + "medications:import": "node scripts/run-tsx.mjs scripts/import-medications-export.ts", + "medications:seed": "node scripts/run-tsx.mjs scripts/seed-medication-records.ts", + "reindex": "node scripts/run-tsx.mjs scripts/reindex.ts", + "reindex:health": "node scripts/run-tsx.mjs scripts/reindex-health.ts", + "reindex:cleanup-staged": "node scripts/run-tsx.mjs scripts/cleanup-abandoned-reindex-generations.ts", + "images:re-stamp-generation": "node scripts/run-tsx.mjs scripts/reindex-image-generation-metadata.ts", + "supabase:recovery-status": "node scripts/run-tsx.mjs scripts/supabase-recovery-status.ts", + "promote:query-misses": "node scripts/run-tsx.mjs scripts/promote-query-misses.ts", + "promote:public-documents": "node scripts/run-tsx.mjs scripts/promote-public-documents.ts", + "promote:public-documents:batch": "node scripts/run-tsx.mjs scripts/promote-public-documents-batch.ts", "eval:rag": "node scripts/run-eval-safe.mjs scripts/eval-rag.ts", "eval:rag:offline": "node scripts/eval-rag-offline.mjs", "eval:answer-quality": "node scripts/run-eval-safe.mjs scripts/eval-answer-quality.ts", @@ -93,19 +94,19 @@ "eval:retrieval": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts", "eval:retrieval:quality": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts --mode quality", "eval:retrieval:index-unit-vector": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts --mode quality --force-embedding --json-out artifacts/retrieval/index-unit-vector.json", - "eval:retrieval:compare": "tsx scripts/compare-retrieval-eval.ts", + "eval:retrieval:compare": "node scripts/run-tsx.mjs scripts/compare-retrieval-eval.ts", "eval:retrieval:latency": "node scripts/run-eval-safe.mjs scripts/eval-retrieval.ts --mode latency --case-timeout-ms 25000 --p90-ms 20000", - "eval:search": "tsx scripts/eval-search.ts", - "eval:search:api": "tsx scripts/eval-search-api.ts", - "retrieval:health": "tsx scripts/retrieval-health.ts", - "profile:retrieval": "tsx scripts/profile-retrieval-rpcs.ts", - "warm:retrieval": "tsx scripts/warm-retrieval-cache.ts", - "tune:search-weights": "tsx scripts/tune-search-weights.ts", - "cleanup:storage": "tsx scripts/cleanup-storage.ts", - "purge:query-logs": "tsx scripts/purge-query-logs.ts", - "audit:tables": "tsx scripts/audit-tables.ts", - "samples": "tsx scripts/generate-sample-documents.ts", - "samples:check": "tsx scripts/check-sample-extraction.ts", + "eval:search": "node scripts/run-tsx.mjs scripts/eval-search.ts", + "eval:search:api": "node scripts/run-tsx.mjs scripts/eval-search-api.ts", + "retrieval:health": "node scripts/run-tsx.mjs scripts/retrieval-health.ts", + "profile:retrieval": "node scripts/run-tsx.mjs scripts/profile-retrieval-rpcs.ts", + "warm:retrieval": "node scripts/run-tsx.mjs scripts/warm-retrieval-cache.ts", + "tune:search-weights": "node scripts/run-tsx.mjs scripts/tune-search-weights.ts", + "cleanup:storage": "node scripts/run-tsx.mjs scripts/cleanup-storage.ts", + "purge:query-logs": "node scripts/run-tsx.mjs scripts/purge-query-logs.ts", + "audit:tables": "node scripts/run-tsx.mjs scripts/audit-tables.ts", + "samples": "node scripts/run-tsx.mjs scripts/generate-sample-documents.ts", + "samples:check": "node scripts/run-tsx.mjs scripts/check-sample-extraction.ts", "workflow:run": "node ../.local-dev/workflow-run.mjs", "workflow:status": "node ../.local-dev/workflow-status.mjs", "workflow:verify": "node ../.local-dev/workflow-verify.mjs", @@ -113,8 +114,8 @@ "workflow:clean-state": "node ../.local-dev/workflow-clean-state.mjs", "workflow:export": "node ../.local-dev/workflow-export.mjs", "workflow:handoff": "node ../.local-dev/workflow-handoff.mjs", - "check:drift": "tsx scripts/check-drift.ts", - "drift:manifest": "tsx scripts/generate-drift-manifest.ts" + "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", + "drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts" }, "dependencies": { "@next/env": "16.2.10", diff --git a/scripts/check-client-bundle-secrets.mjs b/scripts/check-client-bundle-secrets.mjs new file mode 100644 index 000000000..89e44b74c --- /dev/null +++ b/scripts/check-client-bundle-secrets.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; + +const projectRoot = process.cwd(); +const textExtensions = new Set([".css", ".html", ".js", ".json", ".map", ".md", ".svg", ".txt", ".xml"]); +const forbiddenMarkers = [ + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", + "OPENAI_ORG_ID", + "OPENAI_PROJECT_ID", + "RAG_QUERY_HASH_SECRET", + "sb_secret_", + "sk-proj-", + "sk-svcacct-", +]; + +function textFiles(root) { + if (!existsSync(root)) return []; + const files = []; + + function visit(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.isFile() && textExtensions.has(extname(entry.name).toLowerCase())) { + files.push(entryPath); + } + } + } + + visit(root); + return files; +} + +const publicRoot = join(projectRoot, "public"); +const clientBuildRoot = join(projectRoot, ".next", "static"); + +if (!existsSync(clientBuildRoot)) { + console.error("Client bundle secret surface check requires .next/static. Run it after next build."); + process.exit(1); +} + +const offenders = new Map(); +for (const file of [...textFiles(publicRoot), ...textFiles(clientBuildRoot)]) { + const content = readFileSync(file, "utf8"); + for (const marker of forbiddenMarkers) { + if (content.includes(marker)) { + const relativePath = relative(projectRoot, file).replaceAll("\\", "/"); + offenders.set(`${relativePath}\0${marker}`, { marker, relativePath }); + } + } +} + +if (offenders.size > 0) { + console.error("Client bundle secret surface check failed:"); + for (const { marker, relativePath } of [...offenders.values()].sort((a, b) => { + return a.relativePath.localeCompare(b.relativePath) || a.marker.localeCompare(b.marker); + })) { + console.error(`- ${relativePath}: ${marker}`); + } + process.exit(1); +} + +console.log("Client bundle secret surface check passed."); diff --git a/scripts/check-july8-live-batch.ts b/scripts/check-july8-live-batch.ts index ef226c71a..233b11168 100644 --- a/scripts/check-july8-live-batch.ts +++ b/scripts/check-july8-live-batch.ts @@ -100,7 +100,7 @@ async function checkFailClosedRetrieval(supabase: AdminClient) { if (data === true) { throw new Error( - "retrieval_owner_matches still fail-OPEN on NULL owner_filter — apply 20260708160000_retrieval_owner_matches_fail_closed.sql", + "retrieval_owner_matches still fail-OPEN on NULL owner_filter — apply 20260708160001_retrieval_owner_matches_fail_closed.sql", ); } diff --git a/scripts/check-retrieval-owner-migration.ts b/scripts/check-retrieval-owner-migration.ts index ecf51018a..72b25bee8 100644 --- a/scripts/check-retrieval-owner-migration.ts +++ b/scripts/check-retrieval-owner-migration.ts @@ -50,7 +50,7 @@ async function main() { if (nullFilterCheck === true) { console.error("[Retrieval Owner Migration] FAIL: retrieval_owner_matches is still fail-OPEN on NULL owner_filter."); console.error( - "Apply 20260708160000_retrieval_owner_matches_fail_closed.sql — see docs/operator-apply-july8-batch.md", + "Apply 20260708160001_retrieval_owner_matches_fail_closed.sql — see docs/operator-apply-july8-batch.md", ); process.exit(1); } diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index 881913e7e..317714125 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -243,7 +243,13 @@ function changedFilesFromRange(base, head) { try { return parseNameStatus(runGitRaw(["diff", "--name-status", "-z", "--find-renames", `${base}...${head}`])); } catch { - return parseNameStatus(runGitRaw(["diff", "--name-status", "-z", "--find-renames", base, head])); + try { + return parseNameStatus(runGitRaw(["diff", "--name-status", "-z", "--find-renames", base, head])); + } catch { + // Unreachable base (e.g. force-push). Fall through to the full-run + // sentinel rather than failing the whole changes job. + return null; + } } } diff --git a/scripts/enable-server-only-stub.mjs b/scripts/enable-server-only-stub.mjs new file mode 100644 index 000000000..1bbaa76c0 --- /dev/null +++ b/scripts/enable-server-only-stub.mjs @@ -0,0 +1,3 @@ +import { registerServerOnlyHook } from "./register-server-only.mjs"; + +registerServerOnlyHook(); diff --git a/scripts/register-server-only.mjs b/scripts/register-server-only.mjs new file mode 100644 index 000000000..c0b737566 --- /dev/null +++ b/scripts/register-server-only.mjs @@ -0,0 +1,12 @@ +import { registerHooks } from "node:module"; + +export function registerServerOnlyHook() { + return registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "server-only") { + return { url: new URL("../tests/stubs/server-only.ts", import.meta.url).href, shortCircuit: true }; + } + return nextResolve(specifier, context); + }, + }); +} diff --git a/scripts/run-eval-safe.mjs b/scripts/run-eval-safe.mjs index 4116a343a..3f3c9f371 100644 --- a/scripts/run-eval-safe.mjs +++ b/scripts/run-eval-safe.mjs @@ -1,8 +1,6 @@ #!/usr/bin/env node -import { existsSync } from "node:fs"; import { spawn, spawnSync } from "node:child_process"; -import { createRequire } from "node:module"; import { resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; @@ -55,6 +53,10 @@ function shouldTerminateCandidate(commandLine) { return false; } + if (hasRepoScripts && normalizedCommandLine.includes("\\scripts\\run-tsx.mjs")) { + return normalizedCommandLine.includes("\\scripts\\eval"); + } + if ( hasRepoScripts && normalizedCommandLine.includes("\\node_modules\\tsx\\dist\\preflight.cjs") && @@ -207,104 +209,16 @@ function terminateEvalProcess(pid) { terminateEvalProcessTree(pid); } -function resolveFromAncestorNodeModules(startDir) { - let dir = startDir; - for (;;) { - const candidate = resolve(dir, "node_modules", "tsx", "dist", "cli.mjs"); - if (existsSync(candidate)) return candidate; - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -function listGitWorktreeRoots() { - const result = spawnSync("git", ["worktree", "list", "--porcelain"], { - cwd: projectRoot, - encoding: "utf8", - windowsHide: true, - }); - if (result.status !== 0) return []; - - return (result.stdout || "") - .split(/\r?\n/) - .filter((line) => line.startsWith("worktree ")) - .map((line) => line.slice("worktree ".length).trim()) - .filter(Boolean); -} - -// Resolve the tsx CLI without assuming node_modules sits directly under the -// repo root. Fresh git worktrees frequently have a junctioned or hoisted -// node_modules (or none at all), so honour Node's real resolution algorithm -// before walking sibling worktrees and falling back to the historical path. -function resolveTsxCliBin() { - const candidates = []; - - // `tsx/cli` maps to `./dist/cli.mjs` via the package's exports map. - try { - candidates.push(fileURLToPath(import.meta.resolve("tsx/cli"))); - } catch { - // Not resolvable via import.meta.resolve - try the next strategy. - } - - // CJS fallback: resolve the (always-exported) package.json and read its `bin`. - // Deep specifiers like `tsx/dist/cli.mjs` are blocked by the package exports - // map, so we derive the bin path from the manifest instead. - try { - const require = createRequire(import.meta.url); - const pkgPath = require.resolve("tsx/package.json"); - const bin = require("tsx/package.json").bin; - if (typeof bin === "string") candidates.push(resolve(dirname(pkgPath), bin)); - } catch { - // Not resolvable via require - try the next strategy. - } - - const ancestorMatch = resolveFromAncestorNodeModules(projectRoot); - if (ancestorMatch) candidates.push(ancestorMatch); - - for (const root of listGitWorktreeRoots()) { - const worktreeMatch = resolveFromAncestorNodeModules(root); - if (worktreeMatch) candidates.push(worktreeMatch); - } - - candidates.push(resolve(projectRoot, "node_modules", "tsx", "dist", "cli.mjs")); - - return candidates.find((candidate) => candidate && existsSync(candidate)) ?? null; -} - -// cmd.exe does not auto-quote args when spawning with `shell: true`, so wrap -// anything that isn't a bare token (paths with spaces, etc.) ourselves. -function quoteForCmd(arg) { - if (/^[A-Za-z0-9_.,:=\\/-]+$/.test(arg)) return arg; - return `"${arg.replaceAll('"', '""')}"`; -} - function runEvalScript() { const targetPath = resolve(projectRoot, targetScript); - const tsxBin = resolveTsxCliBin(); - - let command; - let commandArgs; - let useShell = false; - - if (tsxBin) { - command = process.execPath; - commandArgs = [tsxBin, targetPath, ...forwardArgs]; - } else { - // Last resort: let npx locate a tsx runtime (e.g. from a global cache or a - // parent workspace) without reaching out to the network to install it. - console.warn("[eval] tsx not found in node_modules; falling back to `npx --no-install tsx`."); - useShell = isWindows; // npx is a .cmd shim on Windows and needs a shell. - command = isWindows ? "npx.cmd" : "npx"; - commandArgs = ["--no-install", "tsx", targetPath, ...forwardArgs]; - if (useShell) commandArgs = commandArgs.map(quoteForCmd); - } + const command = process.execPath; + const commandArgs = [resolve(projectRoot, "scripts", "run-tsx.mjs"), targetPath, ...forwardArgs]; const child = spawn(command, commandArgs, { cwd: projectRoot, stdio: "inherit", windowsHide: true, - shell: useShell, + shell: false, }); const stopEvalProcess = () => { diff --git a/scripts/run-tsx.mjs b/scripts/run-tsx.mjs new file mode 100644 index 000000000..9357ef3fe --- /dev/null +++ b/scripts/run-tsx.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +if (args.length === 0) { + console.error("Usage: node scripts/run-tsx.mjs [args...]"); + process.exit(1); +} + +const tsxCli = fileURLToPath(import.meta.resolve("tsx/cli")); +const hook = new URL("./enable-server-only-stub.mjs", import.meta.url).href; +const child = spawn(process.execPath, [tsxCli, "--import", hook, ...args], { + cwd: process.cwd(), + env: process.env, + stdio: "inherit", + windowsHide: true, +}); + +child.once("error", (error) => { + console.error(error.message); + process.exit(1); +}); +child.once("close", (code, signal) => { + process.exit(signal ? 1 : (code ?? 1)); +}); diff --git a/scripts/run-vitest.mjs b/scripts/run-vitest.mjs index 7d49a01af..61a24f75d 100644 --- a/scripts/run-vitest.mjs +++ b/scripts/run-vitest.mjs @@ -1,16 +1,16 @@ #!/usr/bin/env node -import { execSync, spawnSync } from "node:child_process"; +import { spawnSync } from "node:child_process"; import path from "node:path"; import { fileURLToPath } from "node:url"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const repoNeedle = `${projectRoot.toLowerCase()}\\`; +const vitestBin = path.join(projectRoot, "node_modules", "vitest", "vitest.mjs"); +const vitestNeedle = vitestBin.toLowerCase(); const powershell = [ "Get-CimInstance Win32_Process", "| Where-Object {", " $_.CommandLine -and", - ` $_.CommandLine.ToLower().Contains('${repoNeedle.replaceAll("\\", "\\\\")}') -and`, - " $_.CommandLine -match 'vitest\\\\.mjs' -and", + ` $_.CommandLine.ToLower().Contains('${vitestNeedle}') -and`, " $_.CommandLine -notlike '*run-vitest.mjs*' -and", " $_.CommandLine -notlike '*Get-CimInstance*'", "}", @@ -19,7 +19,11 @@ const powershell = [ let runningPids = []; try { - const output = execSync(`powershell -NoProfile -Command "${powershell}"`, { encoding: "utf8" }).trim(); + const probe = spawnSync("powershell.exe", ["-NoProfile", "-Command", powershell], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + const output = probe.status === 0 ? probe.stdout.trim() : ""; runningPids = output .split(/\r?\n/) .map((line) => Number.parseInt(line.trim(), 10)) @@ -36,7 +40,6 @@ for (const pid of runningPids) { } } -const vitestBin = path.join(projectRoot, "node_modules", "vitest", "vitest.mjs"); const args = process.argv.slice(2); const result = spawnSync(process.execPath, [vitestBin, ...args], { stdio: "inherit", diff --git a/src/app/api/answer/stream/route.ts b/src/app/api/answer/stream/route.ts index cb41834f6..8061d377f 100644 --- a/src/app/api/answer/stream/route.ts +++ b/src/app/api/answer/stream/route.ts @@ -24,6 +24,7 @@ import { logAnswerDiagnostics } from "@/lib/answer-telemetry"; import { isSupabaseApiKeyConfigurationError, nonProductionSupabaseDemoFallbackReason } from "@/lib/supabase/errors"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { logger } from "@/lib/logger"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { startSseHeartbeat } from "@/lib/sse-heartbeat"; import { parseJsonBody } from "@/lib/validation/body"; import type { RagAnswer } from "@/lib/types"; @@ -108,11 +109,7 @@ function streamErrorPayload(error: unknown) { } function logStreamError(error: unknown) { - logger.error("Search stream failed", { - name: error instanceof Error ? error.name : typeof error, - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); + logger.error("Search stream failed", safeErrorLogDetails(error)); } function buildDemoStreamAnswer(body: AnswerBody, fallbackReason?: string) { diff --git a/src/app/api/differentials/[slug]/route.ts b/src/app/api/differentials/[slug]/route.ts index b4bf555c3..16d51546b 100644 --- a/src/app/api/differentials/[slug]/route.ts +++ b/src/app/api/differentials/[slug]/route.ts @@ -18,8 +18,8 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { getDifferentialDetailContext, getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; -import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery } from "@/lib/validation/query"; @@ -146,13 +146,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s .eq("owner_id", access.ownerId); if (countError) throw new Error(countError.message); if ((count ?? 0) === 0) { + let seedError: unknown = null; try { await ensureDifferentialsSeeded(supabase, access.ownerId); - } catch (seedError) { - console.error(`[differentials] auto-seed failed for owner ${access.ownerId}`, seedError); - if (registryCorpusEmbeddingEnabled()) throw seedError; + } catch (error) { + seedError = error; + console.error("[differentials] auto-seed failed", safeErrorLogDetails(error)); } row = await fetchRecord(); + if (!row && seedError) throw seedError; } } if (!row) return notFoundResponse(normalizedSlug); diff --git a/src/app/api/differentials/presentations/[slug]/route.ts b/src/app/api/differentials/presentations/[slug]/route.ts index 334ae96d7..ff5be6a18 100644 --- a/src/app/api/differentials/presentations/[slug]/route.ts +++ b/src/app/api/differentials/presentations/[slug]/route.ts @@ -17,8 +17,8 @@ import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/diffe import { getDifferentialRecord, getPresentationWorkflow } from "@/lib/differentials"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; -import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -127,13 +127,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s .eq("owner_id", access.ownerId); if (countError) throw new Error(countError.message); if ((count ?? 0) === 0) { + let seedError: unknown = null; try { await ensureDifferentialsSeeded(supabase, access.ownerId); - } catch (seedError) { - console.error(`[differentials] auto-seed failed for owner ${access.ownerId}`, seedError); - if (registryCorpusEmbeddingEnabled()) throw seedError; + } catch (error) { + seedError = error; + console.error("[differentials] auto-seed failed", safeErrorLogDetails(error)); } row = await fetchPresentation(); + if (!row && seedError) throw seedError; } } if (!row) return notFoundResponse(normalizedSlug); diff --git a/src/app/api/differentials/route.ts b/src/app/api/differentials/route.ts index d44da8707..28361fec2 100644 --- a/src/app/api/differentials/route.ts +++ b/src/app/api/differentials/route.ts @@ -12,9 +12,8 @@ import { rowToDifferentialRecord, rowToPresentationWorkflow, type DifferentialRecordKind, - type DifferentialRecordRow, } from "@/lib/differential-records"; -import { ensureDifferentialsSeeded, loadDifferentialSnapshot } from "@/lib/differential-seed"; +import { fetchOwnerDifferentialRowsWithSeed, loadDifferentialSnapshot } from "@/lib/differential-seed"; import { differentialRecords, rankDifferentialRecords, @@ -25,7 +24,6 @@ import { import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; -import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; import { parseRequestQuery, queryInteger } from "@/lib/validation/query"; @@ -117,28 +115,7 @@ export async function GET(request: Request) { }); } - const fetchRecords = async (recordKind: DifferentialRecordKind) => { - const { data, error } = await supabase - .from("differential_records") - .select("*") - .eq("owner_id", access.ownerId) - .eq("kind", recordKind) - .order("title") - .limit(DIFFERENTIAL_MAX_RECORDS); - if (error) throw new Error(error.message); - return (data ?? []) as DifferentialRecordRow[]; - }; - - let rows = await fetchRecords(kind); - if (rows.length === 0) { - try { - await ensureDifferentialsSeeded(supabase, access.ownerId); - } catch (seedError) { - console.error(`[differentials] auto-seed failed for owner ${access.ownerId}`, seedError); - if (registryCorpusEmbeddingEnabled()) throw seedError; - } - rows = await fetchRecords(kind); - } + const rows = await fetchOwnerDifferentialRowsWithSeed(supabase, access.ownerId, kind, DIFFERENTIAL_MAX_RECORDS); if (kind === "presentation") { const presentations = rows.map(rowToPresentationWorkflow); diff --git a/src/app/api/medications/[slug]/route.ts b/src/app/api/medications/[slug]/route.ts index 9db651ed1..9dc043993 100644 --- a/src/app/api/medications/[slug]/route.ts +++ b/src/app/api/medications/[slug]/route.ts @@ -9,6 +9,7 @@ import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; import { getMedicationRecord } from "@/lib/medication-snapshot"; import { ensureMedicationsSeeded } from "@/lib/medication-seed"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { deriveGovernanceFromSections, normalizeMedicationSlug, @@ -17,7 +18,6 @@ import { type MedicationRecordRow, } from "@/lib/medication-records"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; -import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { createAdminClient } from "@/lib/supabase/admin"; import { AuthenticationError, unauthorizedResponse } from "@/lib/supabase/auth"; @@ -111,13 +111,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s .eq("owner_id", access.ownerId); if (countError) throw new Error(countError.message); if ((count ?? 0) === 0) { + let seedError: unknown = null; try { await ensureMedicationsSeeded(supabase, access.ownerId); - } catch (seedError) { - console.error(`[medications] auto-seed failed for owner ${access.ownerId}`, seedError); - if (registryCorpusEmbeddingEnabled()) throw seedError; + } catch (error) { + seedError = error; + console.error("[medications] auto-seed failed", safeErrorLogDetails(error)); } row = await fetchRecord(); + if (!row && seedError) throw seedError; } } if (!row) return notFoundResponse(normalizedSlug); diff --git a/src/app/api/registry/records/[slug]/route.ts b/src/app/api/registry/records/[slug]/route.ts index 0797e19c1..10978d62c 100644 --- a/src/app/api/registry/records/[slug]/route.ts +++ b/src/app/api/registry/records/[slug]/route.ts @@ -8,6 +8,7 @@ import { } from "@/lib/api-rate-limit"; import { isDemoMode, isLocalNoAuthMode } from "@/lib/env"; import { jsonError } from "@/lib/http"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { publicAccessContext, shouldResolvePublicCatalogAccess } from "@/lib/public-api-access"; import { getFormRecord } from "@/lib/forms"; import { @@ -17,7 +18,6 @@ import { rowToServiceRecord, type RegistryRecordRow, } from "@/lib/registry-records"; -import { registryCorpusEmbeddingEnabled } from "@/lib/registry-corpus"; import { ensureRegistrySeeded } from "@/lib/registry-seed"; import { getServiceRecord } from "@/lib/services"; import { createAdminClient } from "@/lib/supabase/admin"; @@ -125,13 +125,15 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s if ((count ?? 0) === 0) { // Only the seed write is best-effort; the re-read stays outside the try // so a genuine read failure surfaces rather than a misleading 404. + let seedError: unknown = null; try { await ensureRegistrySeeded(supabase, access.ownerId, kind); - } catch (seedError) { - console.error(`[registry] auto-seed failed for owner ${access.ownerId} (${kind})`, seedError); - if (registryCorpusEmbeddingEnabled()) throw seedError; + } catch (error) { + seedError = error; + console.error("[registry] auto-seed failed", { kind, ...safeErrorLogDetails(error) }); } row = await fetchRecord(); + if (!row && seedError) throw seedError; } } if (!row) return notFoundResponse(normalizedSlug); diff --git a/src/app/globals.css b/src/app/globals.css index e7aa21a27..f356b5aa8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1033,7 +1033,7 @@ summary::-webkit-details-marker { .answer-footer-search-chip { display: inline-flex; - min-height: 2.5rem; + min-height: 2.75rem; align-items: center; gap: 0.4rem; border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); @@ -1074,6 +1074,13 @@ summary::-webkit-details-marker { .answer-suggestion-row-scroll { min-width: 0; + scrollbar-width: none; + mask-image: linear-gradient(90deg, black calc(100% - 1rem), transparent); + -webkit-mask-image: linear-gradient(90deg, black calc(100% - 1rem), transparent); +} + +.answer-suggestion-row-scroll::-webkit-scrollbar { + display: none; } /* Answer-thread follow-ups: the label reads as an eyebrow above the chips so @@ -1348,11 +1355,16 @@ summary::-webkit-details-marker { } .answer-footer-search-chip { - min-height: 2.25rem; + min-height: 2.75rem; padding-inline: 0.9rem; font-size: 0.8125rem; } + .answer-suggestion-row-scroll { + mask-image: none; + -webkit-mask-image: none; + } + .document-mobile-search-edge { left: auto; right: auto; diff --git a/src/components/clinical-dashboard/DocumentManagerPanel.tsx b/src/components/clinical-dashboard/DocumentManagerPanel.tsx index 74ddae38d..4bbaae83d 100644 --- a/src/components/clinical-dashboard/DocumentManagerPanel.tsx +++ b/src/components/clinical-dashboard/DocumentManagerPanel.tsx @@ -96,8 +96,11 @@ export const fallbackSetupChecks: SetupCheck[] = [ }, ]; -const publicSearchSetupCheckIds = new Set(["env", "project", "schema", "search", "openai"]); -const requiredPublicSearchConfigCheckIds = new Set(["env", "project", "schema", "openai"]); +// OpenAI is intentionally excluded from both gates: browse/search only needs Supabase. +// The answer path validates OPENAI_API_KEY at request time (requireOpenAIEnv), so a +// missing key surfaces as a real API error there rather than blocking every mode here. +const publicSearchSetupCheckIds = new Set(["env", "project", "schema", "search"]); +const requiredPublicSearchConfigCheckIds = new Set(["env", "project", "schema"]); export function hasReadyPublicSearchSetup(checks: SetupCheck[]) { return Array.from(publicSearchSetupCheckIds).every( diff --git a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx index 14023d501..c02e3cee1 100644 --- a/src/components/clinical-dashboard/medication-prescribing-workspace.tsx +++ b/src/components/clinical-dashboard/medication-prescribing-workspace.tsx @@ -49,6 +49,7 @@ type MedicationPrescribingWorkspaceProps = { type Capability = { label: string; description: string; + query: string; icon: LucideIcon; }; @@ -83,29 +84,45 @@ const medicationCapabilities: Capability[] = [ { label: "Dose", description: "Dosing and adjustment", + query: "medication dose adjustment", icon: CalendarDays, }, { label: "Safety", - description: "Avoid and cautions", + description: "Contraindications and cautions", + query: "medication contraindications and cautions", icon: ShieldCheck, }, { label: "Monitoring", description: "Baseline and follow-up", + query: "medication baseline and follow-up monitoring", icon: Activity, }, { label: "Access", description: "PBS and brand", + query: "medication PBS access and brand availability", icon: Lock, }, ]; const medicationPrompts = [ - { label: "acamprosate renal dose", icon: Pill }, - { label: "naltrexone opioid use", icon: UserRound }, - { label: "sertraline max dose", icon: ShieldCheck }, + { + label: "acamprosate renal dose", + description: "Check renal dosing and contraindications.", + icon: Pill, + }, + { + label: "naltrexone opioid use", + description: "Review opioid-use precautions before prescribing.", + icon: UserRound, + }, + { + label: "sertraline max dose", + description: "Check maximum dose and titration guidance.", + icon: ShieldCheck, + }, ]; function IconTile({ @@ -163,7 +180,7 @@ function StatusNotice({ function QueryChip({ query }: { query: string }) { return ( - + @@ -190,7 +207,7 @@ function MedicationHome({ actionsLabel="Medication prompts" actions={medicationPrompts.map((prompt) => ({ title: prompt.label, - description: "Open a prescribing-focused search.", + description: prompt.description, icon: prompt.icon, onClick: () => onSuggestedSearch(prompt.label), disabled: loading, @@ -200,6 +217,7 @@ function MedicationHome({ pills={medicationCapabilities.map((item) => ({ label: item.label, icon: item.icon, + onClick: () => onSuggestedSearch(item.query), }))} footer={
@@ -243,7 +261,7 @@ function FilterStrip({ }) { return (
{medicationResultFilters.map((filter) => { @@ -256,7 +274,7 @@ function FilterStrip({ aria-pressed={active} onClick={() => onFilterChange(filter.id)} className={cn( - "inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-2xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:px-3 sm:text-xs", + "inline-flex min-h-tap shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-2xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:px-3 sm:text-xs", active ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" : "border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text-heading)]", diff --git a/src/components/clinical-dashboard/medication-record-page.tsx b/src/components/clinical-dashboard/medication-record-page.tsx index 6e5411b60..6b8502f7d 100644 --- a/src/components/clinical-dashboard/medication-record-page.tsx +++ b/src/components/clinical-dashboard/medication-record-page.tsx @@ -229,7 +229,7 @@ function MedicationRecordDetail({ aria-pressed={activeTab === id} onClick={() => setActiveTab(id)} className={cn( - "min-h-8 rounded-lg border px-2.5 text-2xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:px-3 sm:text-xs", + "min-h-tap rounded-lg border px-2.5 text-2xs font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:px-3 sm:text-xs", activeTab === id ? "border-[color:var(--clinical-accent)] bg-[color:var(--clinical-accent-soft)] text-[color:var(--clinical-accent)]" : "border-[color:var(--border)] bg-[color:var(--surface-raised)] text-[color:var(--text-muted)] hover:border-[color:var(--border-strong)] hover:text-[color:var(--text-heading)]", @@ -312,11 +312,11 @@ export function MedicationRecordPage({ slug }: { slug: string }) {
diff --git a/src/components/mode-home-template.tsx b/src/components/mode-home-template.tsx index 1d1b73716..2cf3a8f9d 100644 --- a/src/components/mode-home-template.tsx +++ b/src/components/mode-home-template.tsx @@ -298,7 +298,7 @@ export function ModeHomeTemplate({ {pillsAction}
) : null} -
+
{pills.map((pill) => { const PillIcon = pill.icon; const displayLabel = pill.shortLabel ?? pill.label; @@ -315,16 +315,20 @@ export function ModeHomeTemplate({ ); const pillClassName = - "inline-flex min-h-tap items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/35 hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:text-sm lg:min-h-9"; + "inline-flex min-h-tap shrink-0 items-center justify-center gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-3 py-2 text-xs font-semibold text-[color:var(--text)] shadow-[var(--shadow-inset)] transition hover:border-[color:var(--clinical-accent)]/35 hover:bg-[color:var(--surface-subtle)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--focus)] sm:text-sm lg:min-h-9"; const pillA11y = pill.shortLabel ? { "aria-label": pill.label, title: pill.label } : {}; return pill.href ? ( {content} - ) : ( + ) : pill.onClick ? ( + ) : ( + + {content} + ); })}
diff --git a/src/lib/api-rate-limit.ts b/src/lib/api-rate-limit.ts index ed5e4af39..d786934ca 100644 --- a/src/lib/api-rate-limit.ts +++ b/src/lib/api-rate-limit.ts @@ -10,6 +10,10 @@ export function allowRateLimitInMemoryFallbackOnUnavailable() { } function allowAnonymousRateLimitFallback(bucket: ApiRateLimitBucket, allowInMemoryFallbackOnUnavailable?: boolean) { + // Paid answer generation must not fall back to a per-instance limiter in a + // distributed production runtime. If the durable limiter is unavailable, + // fail closed before retrieval or provider generation can start. + if (bucket === "answer" && !isLocalNoAuthMode()) return false; if (allowInMemoryFallbackOnUnavailable) return true; // Anonymous public read/search paths must stay reachable if the durable limiter @@ -145,6 +149,9 @@ export async function consumeSubjectApiRateLimit(args: { windowSeconds?: number; allowInMemoryFallbackOnUnavailable?: boolean; }): Promise { + const allowInMemoryFallbackOnUnavailable = + args.bucket === "answer" && !isLocalNoAuthMode() ? false : args.allowInMemoryFallbackOnUnavailable; + if (args.subject.kind === "owner") { return consumeApiRateLimit({ supabase: args.supabase, @@ -152,56 +159,79 @@ export async function consumeSubjectApiRateLimit(args: { bucket: args.bucket, limit: args.limit, windowSeconds: args.windowSeconds, - allowInMemoryFallbackOnUnavailable: args.allowInMemoryFallbackOnUnavailable, + allowInMemoryFallbackOnUnavailable, }); } const defaults = anonymousApiRateLimitDefaults[args.bucket] ?? apiRateLimitDefaults[args.bucket]; const limit = args.limit ?? defaults.limit; const windowSeconds = args.windowSeconds ?? defaults.windowSeconds; - const { data, error } = await args.supabase.rpc("consume_api_subject_rate_limit", { - p_subject_key: args.subject.subjectKey, - p_bucket: args.bucket, - p_limit: limit, - p_window_seconds: windowSeconds, - }); + const consumeAnonymousLimit = async (subjectKey: string, requestedLimit: number, requestedWindowSeconds: number) => { + const { data, error } = await args.supabase.rpc("consume_api_subject_rate_limit", { + p_subject_key: subjectKey, + p_bucket: args.bucket, + p_limit: requestedLimit, + p_window_seconds: requestedWindowSeconds, + }); - if (error) { - if (allowAnonymousRateLimitFallback(args.bucket, args.allowInMemoryFallbackOnUnavailable)) { - console.warn("Durable anonymous API rate limit check unavailable; using local in-memory fallback.", { - bucket: args.bucket, - code: error.code, - message: error.message, - }); - return consumeInMemoryApiRateLimit({ - ownerId: args.subject.subjectKey, - bucket: args.bucket, - limit, - windowSeconds, - }); + if (error) { + if (allowAnonymousRateLimitFallback(args.bucket, allowInMemoryFallbackOnUnavailable)) { + console.warn("Durable anonymous API rate limit check unavailable; using local in-memory fallback.", { + bucket: args.bucket, + code: error.code, + message: error.message, + }); + return consumeInMemoryApiRateLimit({ + ownerId: subjectKey, + bucket: args.bucket, + limit: requestedLimit, + windowSeconds: requestedWindowSeconds, + }); + } + throw new ApiRateLimitUnavailableError(); } - throw new ApiRateLimitUnavailableError(); - } - const row = parseRateLimitRow(data); - if (!row || typeof row.limited !== "boolean") { - if (allowAnonymousRateLimitFallback(args.bucket, args.allowInMemoryFallbackOnUnavailable)) { - return consumeInMemoryApiRateLimit({ - ownerId: args.subject.subjectKey, - bucket: args.bucket, - limit, - windowSeconds, - }); + const row = parseRateLimitRow(data); + if (!row || typeof row.limited !== "boolean") { + if (allowAnonymousRateLimitFallback(args.bucket, allowInMemoryFallbackOnUnavailable)) { + return consumeInMemoryApiRateLimit({ + ownerId: subjectKey, + bucket: args.bucket, + limit: requestedLimit, + windowSeconds: requestedWindowSeconds, + }); + } + throw new ApiRateLimitUnavailableError(); } - throw new ApiRateLimitUnavailableError(); + + return { + limited: row.limited, + limit: Number(row.limit_value ?? requestedLimit), + remaining: Number(row.remaining ?? 0), + retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? requestedWindowSeconds)), + resetAt: String(row.reset_at ?? new Date(Date.now() + requestedWindowSeconds * 1000).toISOString()), + } satisfies ApiRateLimitResult; + }; + + if (args.bucket !== "answer") { + 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; + const subjectResult = await consumeAnonymousLimit(args.subject.subjectKey, limit, windowSeconds); + if (subjectResult.limited) return subjectResult; + const globalResult = await consumeAnonymousLimit( + "anon:answer:global", + globalDefaults.limit, + globalDefaults.windowSeconds, + ); + if (globalResult.limited) return globalResult; return { - limited: row.limited, - limit: Number(row.limit_value ?? limit), - remaining: Number(row.remaining ?? 0), - retryAfterSeconds: Math.max(1, Number(row.retry_after_seconds ?? windowSeconds)), - resetAt: String(row.reset_at ?? new Date(Date.now() + windowSeconds * 1000).toISOString()), + ...subjectResult, + remaining: Math.min(subjectResult.remaining, globalResult.remaining), }; } diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index ec55f486b..b7c2f9b3c 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -63,21 +63,21 @@ export const appModeDefinitions = [ { id: "documents", label: "Documents", - description: "Search indexed PDFs and notes", + description: "Find source PDFs, notes, and evidence passages", search: { kind: "documents", - placeholder: "Search documents...", - inputAriaLabel: "Search indexed documents", + placeholder: "Search source documents...", + inputAriaLabel: "Search indexed source documents", submitIdleLabel: "Docs", submitBusyLabel: "Docs", submitAriaLabel: "Find matching documents", emptyTitle: "Enter a document search term", - readyTitle: "Find matching documents", + readyTitle: "Find matching source documents", progressLabel: "Finding matching documents.", resultKind: "documents", resultHeading: "Document matches", statusLabel: "Docs", - nextStep: "Review matching documents", + nextStep: "Open a source document or evidence passage", badgeLabel: null, }, }, @@ -175,20 +175,20 @@ export const appModeDefinitions = [ { id: "prescribing", label: "Medication", - description: "Prescribing checks and guidance", + description: "Medication dosing, safety, and monitoring checks", href: "/?mode=prescribing", search: { // Deliberately kind:"documents" (unlike forms): prescribing intentionally searches the // document corpus for dosing/threshold guidance (defaultQueryMode dose_threshold_lookup). // The medication registry joins cross-entity search via /api/search/universal instead. kind: "documents", - placeholder: "Search medications...", - inputAriaLabel: "Search medication guidance", + placeholder: "Search medication dosing or safety...", + inputAriaLabel: "Search medication dosing, safety, and monitoring guidance", submitIdleLabel: "Meds", submitBusyLabel: "Meds", - submitAriaLabel: "Search medication guidance", + submitAriaLabel: "Search medication prescribing guidance", emptyTitle: "Enter a medication search term", - readyTitle: "Search medication guidance", + readyTitle: "Search medication prescribing guidance", progressLabel: "Searching medication guidance.", resultKind: "documents", resultHeading: "Medication matches", diff --git a/src/lib/citations.ts b/src/lib/citations.ts index b6fc748fa..7b026604b 100644 --- a/src/lib/citations.ts +++ b/src/lib/citations.ts @@ -1,4 +1,5 @@ import { normalizeExtractedGlyphs, stripClassificationBanner } from "@/lib/source-text-sanitizer"; +import { registryCorpusDetailHref } from "@/lib/registry-corpus-links"; import type { Citation, SearchResult } from "@/lib/types"; // Citation titles come straight from document extraction, so repair glyph @@ -83,17 +84,11 @@ function registryCitationHref(citation: Citation) { const slug = metadata.registry_record_slug; if (!slug) return null; - const encodedSlug = encodeURIComponent(slug); - if (metadata.registry_record_kind === "service") return `/services/${encodedSlug}`; - if (metadata.registry_record_kind === "form") return `/forms/${encodedSlug}`; - if (metadata.registry_record_kind === "medication") return `/medications/${encodedSlug}`; - if (metadata.registry_record_kind === "differential") { - return metadata.registry_record_subkind === "presentation" - ? `/differentials/presentations/${encodedSlug}` - : `/differentials/diagnoses/${encodedSlug}`; - } - - return null; + return registryCorpusDetailHref({ + kind: metadata.registry_record_kind, + slug: encodeURIComponent(slug), + subkind: metadata.registry_record_subkind, + }); } export function documentCitationHref(citation: Citation) { diff --git a/src/lib/differential-seed.ts b/src/lib/differential-seed.ts index 521778143..bb80f69f3 100644 --- a/src/lib/differential-seed.ts +++ b/src/lib/differential-seed.ts @@ -1,5 +1,6 @@ import { buildDefaultDifferentialRows } from "@/lib/differential-fixtures"; import { type DifferentialRecordInsert, type DifferentialRecordRow } from "@/lib/differential-records"; +import { safeErrorLogDetails } from "@/lib/privacy"; type AdminClient = ReturnType; @@ -20,13 +21,44 @@ export async function ensureDifferentialsSeeded( .select("*"); if (error) throw new Error(`Differential seed failed: ${error.message}`); const seededRows = (data ?? []) as DifferentialRecordRow[]; - const { embedDifferentialRows, registryCorpusEmbeddingEnabled } = await loadRegistryCorpus(); - if (registryCorpusEmbeddingEnabled()) { - await embedDifferentialRows(supabase, seededRows); - } + const { bestEffortSyncDifferentialRows } = await loadRegistryCorpus(); + await bestEffortSyncDifferentialRows(supabase, seededRows); return seededRows; } +export async function fetchOwnerDifferentialRowsWithSeed( + supabase: AdminClient, + ownerId: string, + kind: DifferentialRecordRow["kind"], + maxRecords = 500, +): Promise { + const fetchRecords = async () => { + const { data, error } = await supabase + .from("differential_records") + .select("*") + .eq("owner_id", ownerId) + .eq("kind", kind) + .order("title") + .limit(maxRecords); + if (error) throw new Error(error.message); + return (data ?? []) as DifferentialRecordRow[]; + }; + + let rows = await fetchRecords(); + if (rows.length === 0) { + let seedError: unknown = null; + try { + await ensureDifferentialsSeeded(supabase, ownerId); + } catch (error) { + seedError = error; + console.error("[differentials] auto-seed failed", safeErrorLogDetails(error)); + } + rows = await fetchRecords(); + if (rows.length === 0 && seedError) throw seedError; + } + return rows; +} + export function buildDifferentialSeedRows(ownerId: string): DifferentialRecordInsert[] { return buildDefaultDifferentialRows(ownerId); } diff --git a/src/lib/env.ts b/src/lib/env.ts index dccf68890..ae308cd6a 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,3 +1,5 @@ +import "server-only"; + import { z } from "zod"; import { assertExpectedSupabaseProjectConfig, checkSupabaseProjectConfig } from "@/lib/supabase/project"; diff --git a/src/lib/extractors/document.ts b/src/lib/extractors/document.ts index 59c25e538..ebdfe86ab 100644 --- a/src/lib/extractors/document.ts +++ b/src/lib/extractors/document.ts @@ -6,6 +6,7 @@ import ExcelJS from "exceljs"; import mammoth from "mammoth"; import { PDFParse } from "pdf-parse"; import JSZip from "jszip"; +import { safeBufferFrom } from "@/lib/safe-buffer"; import { z } from "zod"; import type { ExtractedDocument, ExtractedPage } from "@/lib/types"; @@ -108,7 +109,8 @@ async function extractPdf(buffer: Buffer) { const mimeType = dataUrlMatch?.[1] ?? "image/png"; const extension = mimeType.includes("jpeg") ? "jpg" : "png"; const outputPath = path.join(imageDir, `fallback-page-${page.pageNumber}-image-${index + 1}.${extension}`); - const bytes = dataUrlMatch ? Buffer.from(dataUrlMatch[2], "base64") : Buffer.from(image.data); + const bytes = dataUrlMatch ? safeBufferFrom(dataUrlMatch[2], "base64") : Buffer.from(image.data); + if (!bytes) continue; await writeFile(outputPath, bytes); images.push({ pageNumber: page.pageNumber, diff --git a/src/lib/http.ts b/src/lib/http.ts index 19f4ed2b1..90113b4ac 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { ZodError } from "zod"; import { logger } from "@/lib/logger"; +import { safeErrorLogDetails } from "@/lib/privacy"; export class PublicApiError extends Error { constructor( @@ -28,25 +29,47 @@ function publicErrorMessage(error: unknown, status: number) { return "Request could not be completed."; } +function publicErrorCode(error: unknown, status: number) { + if (error instanceof PublicApiError && error.details?.code) { + // Only return the code if it matches a stable lowercase snake_case identifier + const code = error.details.code; + if (/^[a-z][a-z0-9]*(_[a-z0-9]+)*$/.test(code)) { + return code; + } + } + if (error instanceof ZodError) return "invalid_request"; + if (status === 401) return "authentication_required"; + if (status === 404) return "not_found"; + if (status >= 500) return "internal_error"; + return "request_failed"; +} + function logSafeError(error: unknown, status: number) { const details = error instanceof PublicApiError ? error.details : undefined; logger.error("API request failed", { status, - name: error instanceof Error ? error.name : typeof error, - message: error instanceof Error ? error.message : String(error), - code: details?.code, - requestId: details?.requestId, - causeName: details?.causeName, - causeMessage: details?.causeMessage, - sqlState: details?.sqlState, - stack: error instanceof Error ? error.stack : undefined, + ...safeErrorLogDetails(error), + ...(details?.code ? { code: details.code } : {}), + ...(details?.requestId ? { requestId: details.requestId } : {}), + ...(details?.sqlState ? { sqlState: details.sqlState } : {}), }); } export function jsonError(error: unknown, status = 500) { const responseStatus = error instanceof PublicApiError ? error.status : status; + const message = publicErrorMessage(error, responseStatus); + const code = publicErrorCode(error, responseStatus); + const requestId = error instanceof PublicApiError ? error.details?.requestId : undefined; logSafeError(error, responseStatus); - return NextResponse.json({ error: publicErrorMessage(error, responseStatus) }, { status: responseStatus }); + return NextResponse.json( + { + error: message, + message, + code, + ...(requestId ? { requestId } : {}), + }, + { status: responseStatus }, + ); } export function assertAllowedFile(file: File, maxUploadMb: number) { diff --git a/src/lib/medication-seed.ts b/src/lib/medication-seed.ts index d040e91a0..fcac4ee32 100644 --- a/src/lib/medication-seed.ts +++ b/src/lib/medication-seed.ts @@ -1,5 +1,6 @@ import { buildDefaultMedicationRows, defaultMedicationRecords } from "@/lib/medication-fixtures"; import { type MedicationRecordInsert, type MedicationRecordRow } from "@/lib/medication-records"; +import { safeErrorLogDetails } from "@/lib/privacy"; type AdminClient = ReturnType; @@ -19,10 +20,8 @@ export async function ensureMedicationsSeeded(supabase: AdminClient, ownerId: st .select("*"); if (error) throw new Error(`Medication seed failed: ${error.message}`); const seededRows = (data ?? []) as MedicationRecordRow[]; - const { embedMedicationRows, registryCorpusEmbeddingEnabled } = await loadRegistryCorpus(); - if (registryCorpusEmbeddingEnabled()) { - await embedMedicationRows(supabase, seededRows); - } + const { bestEffortSyncMedicationRows } = await loadRegistryCorpus(); + await bestEffortSyncMedicationRows(supabase, seededRows); return seededRows; } @@ -51,14 +50,15 @@ export async function fetchOwnerMedicationRowsWithSeed( let rows = await fetchRecords(); if (rows.length === 0) { + let seedError: unknown = null; try { await ensureMedicationsSeeded(supabase, ownerId); - } catch (seedError) { - console.error(`[medications] auto-seed failed for owner ${ownerId}`, seedError); - const { registryCorpusEmbeddingEnabled } = await loadRegistryCorpus(); - if (registryCorpusEmbeddingEnabled()) throw seedError; + } catch (error) { + seedError = error; + console.error("[medications] auto-seed failed", safeErrorLogDetails(error)); } rows = await fetchRecords(); + if (rows.length === 0 && seedError) throw seedError; } return rows; } diff --git a/src/lib/owner-scope.ts b/src/lib/owner-scope.ts index d126d4253..404de3503 100644 --- a/src/lib/owner-scope.ts +++ b/src/lib/owner-scope.ts @@ -7,7 +7,7 @@ export const PUBLIC_OWNER_FILTER_SENTINEL = "00000000-0000-0000-0000-00000000000 // retrieval_owner_matches(NULL, …) previously failed OPEN (matched every tenant's // rows). Return the public sentinel instead, so these modes see only the shared // public (null-owner) corpus. Combined with the DB fail-closed change (migration -// 20260708160000_retrieval_owner_matches_fail_closed), no legitimate caller ever +// 20260708160001_retrieval_owner_matches_fail_closed), no legitimate caller ever // passes NULL. See docs/tenancy-defense-in-depth-review.md §6. export function requireOwnerScope(ownerId: string | null | undefined): string { if (ownerId) return ownerId; diff --git a/src/lib/public-api-access.ts b/src/lib/public-api-access.ts index 4de54ea73..330f75e80 100644 --- a/src/lib/public-api-access.ts +++ b/src/lib/public-api-access.ts @@ -25,8 +25,11 @@ function requestIpSignal(request: Request) { } export function anonymousApiSubjectKey(request: Request) { - const userAgent = request.headers.get("user-agent")?.slice(0, 180) || "unknown-agent"; - const source = `${requestIpSignal(request)}\n${userAgent}`; + // User-Agent is caller-controlled and therefore must not partition a quota: + // rotating it would mint a fresh paid-answer allowance for every request. + // If no trusted proxy IP is available, every unknown caller intentionally + // shares the same conservative quota rather than failing open. + const source = requestIpSignal(request); return `anon:${createHash("sha256").update(source).digest("hex").slice(0, 32)}`; } diff --git a/src/lib/registry-corpus.ts b/src/lib/registry-corpus.ts index cec9c29e3..0e2fbcb20 100644 --- a/src/lib/registry-corpus.ts +++ b/src/lib/registry-corpus.ts @@ -10,6 +10,7 @@ import { import { env } from "@/lib/env"; import { formRecordSearchText } from "@/lib/forms"; import { rowToMedicationRecord, type MedicationRecordRow } from "@/lib/medication-records"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { registryCorpusDetailHref } from "@/lib/registry-corpus-links"; import { rowToServiceRecord, type RegistryRecordKind, type RegistryRecordRow } from "@/lib/registry-records"; import { serviceRecordSearchText } from "@/lib/services"; @@ -99,6 +100,11 @@ function registryDocumentId(entry: RegistryCorpusEntry) { return deterministicUuid(`registry-document:${entry.kind}:${entry.recordId}`); } +/** Registry chunk id. */ +function registryChunkId(entry: RegistryCorpusEntry) { + return deterministicUuid(`registry-chunk:${entry.kind}:${entry.recordId}`); +} + /** Registry entry metadata. */ function registryEntryMetadata(entry: RegistryCorpusEntry): Record { return { ...registryBaseMetadata(entry), ...entry.metadata }; @@ -147,7 +153,7 @@ function registryDocumentRow(entry: RegistryCorpusEntry): TablesInsert<"document function registryChunkRow(entry: RegistryCorpusEntry, embedding: Vector): TablesInsert<"document_chunks"> { const { documentId, metadata } = registryCorpusIdentity(entry); return { - id: deterministicUuid(`registry-chunk:${entry.kind}:${entry.recordId}`), + id: registryChunkId(entry), document_id: documentId, chunk_index: 0, page_number: 1, @@ -163,6 +169,30 @@ function registryChunkRow(entry: RegistryCorpusEntry, embedding: Vector): Tables }; } +/** Order-insensitive JSON encoding so built rows compare stably against jsonb + * columns, whose object key order Postgres normalizes. */ +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson((value as Record)[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +/** True when the stored document row already matches every field the registry + * derivation would write — content_hash alone cannot see drift in derived + * metadata (kind/subkind/slug/detail href), so compare the full expected row. */ +function registryDocumentRowCurrent( + existing: Record | null | undefined, + expected: TablesInsert<"documents">, +) { + if (!existing) return false; + return Object.entries(expected).every(([key, value]) => stableJson(existing[key]) === stableJson(value)); +} + /** Registry corpus embedding enabled. */ export function registryCorpusEmbeddingEnabled() { return env.RAG_REGISTRY_CORPUS_EMBEDDING === true; @@ -302,10 +332,9 @@ export async function embedRegistryCorpusEntries(supabase: AdminClient, entries: for (let start = 0; start < entries.length; start += REGISTRY_EMBEDDING_WRITE_BATCH_SIZE) { const batch = entries.slice(start, start + REGISTRY_EMBEDDING_WRITE_BATCH_SIZE); - const embeddings = await embedTexts(batch.map((entry) => entry.content)); const documents = batch.map(registryDocumentRow); - const chunks = batch.map((entry, index) => registryChunkRow(entry, embeddings[index] as Vector)); const documentIds = documents.map((document) => document.id).filter((id): id is string => typeof id === "string"); + const chunkIds = batch.map(registryChunkId); const { data: existingDocuments, error: existingDocumentError } = await supabase .from("documents") @@ -314,32 +343,74 @@ export async function embedRegistryCorpusEntries(supabase: AdminClient, entries: if (existingDocumentError) { throw new Error(`Registry corpus preflight failed: ${existingDocumentError.message}`); } - const existingDocumentIds = new Set((existingDocuments ?? []).map((document) => document.id)); - const existingDocumentSnapshots = existingDocuments ?? []; + const { data: existingChunks, error: existingChunkError } = await supabase + .from("document_chunks") + .select("id, content_hash, embedding") + .in("id", chunkIds); + if (existingChunkError) { + throw new Error(`Registry corpus chunk preflight failed: ${existingChunkError.message}`); + } + + const existingDocumentById = new Map((existingDocuments ?? []).map((document) => [document.id, document])); + const existingChunkById = new Map((existingChunks ?? []).map((chunk) => [chunk.id, chunk])); + const pendingEntries = batch.flatMap((entry, index) => { + const document = documents[index]; + if (!document?.id) return []; + const existingDocument = existingDocumentById.get(document.id); + const existingChunk = existingChunkById.get(registryChunkId(entry)); + const expectedHash = sha256(entry.content); + const needsEmbedding = existingChunk?.content_hash !== expectedHash || existingChunk?.embedding == null; + // Row drift that content_hash cannot see (a metadata/title derivation + // change) still refreshes the stored rows, reusing the current embedding. + if (!needsEmbedding && registryDocumentRowCurrent(existingDocument, document)) return []; + return [{ entry, document, needsEmbedding, existingEmbedding: existingChunk?.embedding ?? null }]; + }); + + if (pendingEntries.length === 0) continue; + + const pendingDocuments = pendingEntries.map((pending) => pending.document); + const entriesToEmbed = pendingEntries.filter((pending) => pending.needsEmbedding); + const embeddings = + entriesToEmbed.length > 0 ? await embedTexts(entriesToEmbed.map((pending) => pending.entry.content)) : []; + let embeddingIndex = 0; + const pendingChunks = pendingEntries.map((pending) => + registryChunkRow( + pending.entry, + (pending.needsEmbedding ? embeddings[embeddingIndex++] : pending.existingEmbedding) as Vector, + ), + ); + const pendingDocumentIds = pendingDocuments + .map((document) => document.id) + .filter((id): id is string => typeof id === "string"); + const existingPendingDocuments = pendingDocumentIds.flatMap((id) => { + const document = existingDocumentById.get(id); + return document ? [document] : []; + }); + const existingPendingDocumentIds = new Set(existingPendingDocuments.map((document) => document.id)); - const { error: documentError } = await supabase.from("documents").upsert(documents, { onConflict: "id" }); + const { error: documentError } = await supabase.from("documents").upsert(pendingDocuments, { onConflict: "id" }); if (documentError) throw new Error(`Registry corpus document upsert failed: ${documentError.message}`); - const { error: chunkError } = await supabase.from("document_chunks").upsert(chunks, { onConflict: "id" }); + const { error: chunkError } = await supabase.from("document_chunks").upsert(pendingChunks, { onConflict: "id" }); if (chunkError) { - const insertedDocumentIds = documentIds.filter((id) => !existingDocumentIds.has(id)); + const insertedDocumentIds = pendingDocumentIds.filter((id) => !existingPendingDocumentIds.has(id)); const rollbackErrors: string[] = []; if (insertedDocumentIds.length > 0) { const { error: deleteError } = await supabase.from("documents").delete().in("id", insertedDocumentIds); if (deleteError) rollbackErrors.push(`delete failed: ${deleteError.message}`); } - if (existingDocumentSnapshots.length > 0) { + if (existingPendingDocuments.length > 0) { const { error: restoreError } = await supabase .from("documents") - .upsert(existingDocumentSnapshots, { onConflict: "id" }); + .upsert(existingPendingDocuments, { onConflict: "id" }); if (restoreError) rollbackErrors.push(`restore failed: ${restoreError.message}`); } const suffix = rollbackErrors.length > 0 ? `; rollback errors: ${rollbackErrors.join(", ")}` : ""; throw new Error(`Registry corpus chunk upsert failed: ${chunkError.message}${suffix}`); } - documentCount += documents.length; - chunkCount += chunks.length; + documentCount += pendingDocuments.length; + chunkCount += pendingChunks.length; } return { documentCount, chunkCount }; @@ -360,6 +431,66 @@ export function embedDifferentialRows(supabase: AdminClient, rows: DifferentialR return embedRegistryCorpusEntries(supabase, differentialRowsToCorpusEntries(rows)); } +async function bestEffortRegistryCorpusSync( + enabled: boolean, + sync: () => Promise, + scope: string, +): Promise { + if (!enabled) return skippedRegistryEmbedResult("disabled"); + + try { + return await sync(); + } catch (error) { + console.error(`[${scope}] registry corpus sync failed`, safeErrorLogDetails(error)); + return { + documentCount: 0, + chunkCount: 0, + skipped: true, + reason: "failed", + errorMessage: "Registry corpus synchronization failed.", + }; + } +} + +/** Best-effort corpus reconciliation for stored service and form rows. */ +export function bestEffortSyncClinicalRegistryRows( + supabase: AdminClient, + rows: RegistryRecordRow[], + scope = "registry", +) { + return bestEffortRegistryCorpusSync( + registryCorpusEmbeddingEnabled(), + () => embedClinicalRegistryRows(supabase, rows), + scope, + ); +} + +/** Best-effort corpus reconciliation for stored medication rows. */ +export function bestEffortSyncMedicationRows( + supabase: AdminClient, + rows: MedicationRecordRow[], + scope = "medications", +) { + return bestEffortRegistryCorpusSync( + registryCorpusEmbeddingEnabled(), + () => embedMedicationRows(supabase, rows), + scope, + ); +} + +/** Best-effort corpus reconciliation for stored differential rows. */ +export function bestEffortSyncDifferentialRows( + supabase: AdminClient, + rows: DifferentialRecordRow[], + scope = "differentials", +) { + return bestEffortRegistryCorpusSync( + registryCorpusEmbeddingEnabled(), + () => embedDifferentialRows(supabase, rows), + scope, + ); +} + /** Reload an owner's rows for a table and embed them, returning the chunk count * written. Shared by the registry/medication/differential seed CLIs, which each * re-read their own rows (rather than reusing the upsert response) so the @@ -461,22 +592,9 @@ export async function bestEffortReembedRegistryRecordAfterEdit(args: { target: RegistryCorpusEditTarget; scope: string; }) { - if (!registryCorpusEmbeddingEnabled()) return skippedRegistryEmbedResult("disabled"); - - try { - return await reembedRegistryRecordAfterEdit(args.supabase, args.target); - } catch (embedError) { - const errorMessage = embedError instanceof Error ? embedError.message : String(embedError); - console.error( - `[${args.scope}] corpus re-embedding failed after registry edit for ${args.target.ownerId}/${args.target.corpusKind}/${args.target.slug}`, - embedError, - ); - return { - documentCount: 0, - chunkCount: 0, - skipped: true, - reason: "failed", - errorMessage, - } satisfies RegistryCorpusEmbedResult; - } + return bestEffortRegistryCorpusSync( + registryCorpusEmbeddingEnabled(), + () => reembedRegistryRecordAfterEdit(args.supabase, args.target), + args.scope, + ); } diff --git a/src/lib/registry-seed.ts b/src/lib/registry-seed.ts index 1649ed81e..a0849c556 100644 --- a/src/lib/registry-seed.ts +++ b/src/lib/registry-seed.ts @@ -1,4 +1,5 @@ import { formRecords } from "@/lib/forms"; +import { safeErrorLogDetails } from "@/lib/privacy"; import { buildDefaultFormRows, buildDefaultServiceRows, defaultServiceRecords } from "@/lib/registry-fixtures"; import { type RegistryRecordInsert, type RegistryRecordKind, type RegistryRecordRow } from "@/lib/registry-records"; @@ -47,10 +48,8 @@ export async function ensureRegistrySeeded( .select("*"); if (error) throw new Error(`Registry seed failed: ${error.message}`); const seededRows = (data ?? []) as RegistryRecordRow[]; - const { embedClinicalRegistryRows, registryCorpusEmbeddingEnabled } = await loadRegistryCorpus(); - if (registryCorpusEmbeddingEnabled()) { - await embedClinicalRegistryRows(supabase, seededRows); - } + const { bestEffortSyncClinicalRegistryRows } = await loadRegistryCorpus(); + await bestEffortSyncClinicalRegistryRows(supabase, seededRows); return seededRows; } @@ -81,14 +80,15 @@ export async function fetchOwnerRegistryRowsWithSeed( let rows = await fetchRecords(); if (rows.length === 0) { + let seedError: unknown = null; try { await ensureRegistrySeeded(supabase, ownerId, kind); - } catch (seedError) { - console.error(`[registry] auto-seed failed for owner ${ownerId} (${kind})`, seedError); - const { registryCorpusEmbeddingEnabled } = await loadRegistryCorpus(); - if (registryCorpusEmbeddingEnabled()) throw seedError; + } catch (error) { + seedError = error; + console.error("[registry] auto-seed failed", { kind, ...safeErrorLogDetails(error) }); } rows = await fetchRecords(); + if (rows.length === 0 && seedError) throw seedError; } return rows; } diff --git a/src/lib/safe-buffer.ts b/src/lib/safe-buffer.ts new file mode 100644 index 000000000..713f00368 --- /dev/null +++ b/src/lib/safe-buffer.ts @@ -0,0 +1,23 @@ +export function safeBufferFrom(input: unknown, encoding?: BufferEncoding): Buffer | null { + if (Buffer.isBuffer(input)) return Buffer.from(input); + if (input instanceof ArrayBuffer) return Buffer.from(input); + if (ArrayBuffer.isView(input)) { + return Buffer.from(input.buffer, input.byteOffset, input.byteLength); + } + if (typeof input !== "string") return null; + + if (encoding === "base64") { + const normalized = input.replace(/\s+/g, ""); + if (!normalized || normalized.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) return null; + const buffer = Buffer.from(normalized, "base64"); + const canonicalInput = normalized.replace(/=+$/, ""); + const canonicalOutput = buffer.toString("base64").replace(/=+$/, ""); + return canonicalOutput === canonicalInput ? buffer : null; + } + + try { + return Buffer.from(input, encoding); + } catch { + return null; + } +} diff --git a/src/lib/search-scope.ts b/src/lib/search-scope.ts index 8bf4fb5d9..a24c90312 100644 --- a/src/lib/search-scope.ts +++ b/src/lib/search-scope.ts @@ -191,6 +191,17 @@ export async function resolveSearchScope(args: { const warnings: string[] = []; const publicOnly = args.publicOnly ?? !args.ownerId; + if (activeFilterCount === 0 && publicOnly && explicitIds.length === 0) { + return { + documentIds: undefined, + filters, + activeFilterCount, + matchedDocumentCount: null, + warnings, + summary: "All public documents", + }; + } + if (activeFilterCount === 0 && !publicOnly && !(args.ownerId && explicitIds.length)) { return { documentIds: explicitIds.length ? explicitIds : undefined, diff --git a/supabase/drift-allowlist.json b/supabase/drift-allowlist.json index 5c3ea0a09..576035e7a 100644 --- a/supabase/drift-allowlist.json +++ b/supabase/drift-allowlist.json @@ -36,6 +36,20 @@ "reason": "Live retains default PUBLIC execute (security-invoker fn, RLS still applies); schema.sql declares service-role-only. Revoke on live via an approved hardening migration. Remove after apply.", "ref": "docs/database-drift-detection.md#reconciliation-backlog" }, + { + "category": "functions", + "key": "public.apply_document_metadata_patch(uuid,jsonb)", + "kind": "missing_live", + "reason": "Repo schema is ahead of live for R5 document metadata deep-merge. Apply migration 20260708310000_r5_document_metadata_merge.sql through the approved live migration workflow, then remove this entry.", + "ref": "docs/database-drift-detection.md#reconciliation-backlog" + }, + { + "category": "functions", + "key": "public.commit_document_index_generation(uuid,uuid,text,integer,integer,integer,jsonb,jsonb,jsonb)", + "kind": "mismatch", + "reason": "Repo schema is ahead of live for R5 metadata merge inside commit_document_index_generation. Apply migration 20260708310000_r5_document_metadata_merge.sql through the approved live migration workflow, then remove this entry.", + "ref": "docs/database-drift-detection.md#reconciliation-backlog" + }, { "category": "functions", "key": "public.get_related_document_metadata(uuid[],uuid)", @@ -92,6 +106,13 @@ "reason": "LIVE IS AHEAD of the repo: live carries newer raw-SQL retrieval bodies (e.g. match_document_chunks has an hnsw.ef_search=100 wrapper; chunks_text/table_facts_text carry richer multi-strategy implementations). Migration 20260705210000 (which had OLDER bodies) was NEUTRALIZED 2026-07-08 so a db push can never regress live. DO NOT apply it. Resolution is forward-codification of the live bodies into schema.sql + a migration (drift backlog); until then these are allowlisted. Note: live retrieval RPCs are under active concurrent multi-session edits.", "ref": "docs/database-drift-detection.md#reconciliation-backlog" }, + { + "category": "functions", + "key": "public.jsonb_merge_deep(jsonb,jsonb)", + "kind": "missing_live", + "reason": "Repo schema is ahead of live for R5 document metadata deep-merge. Apply migration 20260708310000_r5_document_metadata_merge.sql through the approved live migration workflow, then remove this entry.", + "ref": "docs/database-drift-detection.md#reconciliation-backlog" + }, { "category": "functions", "key": "public.repair_enrichment_quality_batch(integer)", @@ -106,6 +127,13 @@ "reason": "LIVE IS AHEAD of the repo: live carries newer raw-SQL retrieval bodies (e.g. match_document_chunks has an hnsw.ef_search=100 wrapper; chunks_text/table_facts_text carry richer multi-strategy implementations). Migration 20260705210000 (which had OLDER bodies) was NEUTRALIZED 2026-07-08 so a db push can never regress live. DO NOT apply it. Resolution is forward-codification of the live bodies into schema.sql + a migration (drift backlog); until then these are allowlisted. Note: live retrieval RPCs are under active concurrent multi-session edits.", "ref": "docs/database-drift-detection.md#reconciliation-backlog" }, + { + "category": "functions", + "key": "public.retrieval_owner_matches(uuid,uuid)", + "kind": "mismatch", + "reason": "Repo schema is ahead of live for the fail-closed retrieval owner predicate. Apply migration 20260708160001_retrieval_owner_matches_fail_closed.sql through the approved live migration workflow, then remove this entry.", + "ref": "docs/database-drift-detection.md#reconciliation-backlog" + }, { "category": "functions", @@ -579,6 +607,13 @@ "reason": "Declared in schema.sql (and migration chain) but absent on live - likely raw-SQL cleanup or never applied. Decide per index: recreate via migration or remove from schema.sql.", "ref": "docs/database-drift-detection.md#reconciliation-backlog" }, + { + "category": "indexes", + "key": "ingestion_jobs_one_open_per_document_uidx", + "kind": "missing_live", + "reason": "Repo schema is ahead of live for the R17 one-open-job partial unique index. Apply migration 20260708170000_ingestion_jobs_one_open_per_document.sql through the approved live migration workflow, then remove this entry.", + "ref": "docs/database-drift-detection.md#reconciliation-backlog" + }, { "category": "indexes", "key": "ingestion_jobs_document_status_idx", diff --git a/tests/api-rate-limit-fallback.test.ts b/tests/api-rate-limit-fallback.test.ts index c25bb36c5..aa23292cf 100644 --- a/tests/api-rate-limit-fallback.test.ts +++ b/tests/api-rate-limit-fallback.test.ts @@ -21,3 +21,86 @@ describe("allowRateLimitInMemoryFallbackOnUnavailable", () => { expect(allowRateLimitInMemoryFallbackOnUnavailable()).toBe(true); }); }); + +describe("paid anonymous answer limits", () => { + it("fails closed when the durable limiter is unavailable in production", async () => { + vi.stubEnv("NODE_ENV", "production"); + vi.doMock("@/lib/env", () => ({ + isLocalNoAuthMode: () => false, + })); + const { ApiRateLimitUnavailableError, consumeSubjectApiRateLimit } = await import("../src/lib/api-rate-limit"); + const supabase = { + rpc: vi.fn(async () => ({ data: null, error: { code: "PGRST202", message: "missing RPC" } })), + }; + + await expect( + consumeSubjectApiRateLimit({ + supabase: supabase as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "answer", + allowInMemoryFallbackOnUnavailable: true, + }), + ).rejects.toBeInstanceOf(ApiRateLimitUnavailableError); + }); + + it("enforces a global durable 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: "answer", + }); + + expect(rpc).toHaveBeenCalledTimes(2); + expect(rpc.mock.calls.map(([, args]) => args.p_subject_key)).toEqual( + expect.arrayContaining(["anon:caller", "anon:answer:global"]), + ); + }); + + it("does not consume the global quota after the caller quota denies the request", 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) => { + void _name; + void _args; + return { + data: { + limited: true, + limit_value: 6, + remaining: 0, + retry_after_seconds: 60, + reset_at: new Date(Date.now() + 60_000).toISOString(), + }, + error: null, + }; + }); + + const result = await consumeSubjectApiRateLimit({ + supabase: { rpc } as never, + subject: { kind: "anonymous", subjectKey: "anon:caller" }, + bucket: "answer", + }); + + expect(result.limited).toBe(true); + expect(rpc).toHaveBeenCalledTimes(1); + expect(rpc.mock.calls[0]?.[1]).toMatchObject({ p_subject_key: "anon:caller" }); + }); +}); diff --git a/tests/api-validation-contract.test.ts b/tests/api-validation-contract.test.ts index a23c87c0e..dce352385 100644 --- a/tests/api-validation-contract.test.ts +++ b/tests/api-validation-contract.test.ts @@ -348,7 +348,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Invalid ingestion jobs query." }); + expect(body).toMatchObject({ error: "Invalid ingestion jobs query." }); expect(client.from).not.toHaveBeenCalled(); }); @@ -436,11 +436,11 @@ describe("API validation contracts", () => { ); expect(retryResponse.status).toBe(400); - expect(await payload(retryResponse)).toEqual({ error: "Invalid ingestion job id." }); + expect(await payload(retryResponse)).toMatchObject({ error: "Invalid ingestion job id." }); expect(summarizeResponse.status).toBe(400); - expect(await payload(summarizeResponse)).toEqual({ error: "Invalid document id." }); + expect(await payload(summarizeResponse)).toMatchObject({ error: "Invalid document id." }); expect(labelsResponse.status).toBe(400); - expect(await payload(labelsResponse)).toEqual({ error: "Invalid document id." }); + expect(await payload(labelsResponse)).toMatchObject({ error: "Invalid document id." }); expect(client.from).not.toHaveBeenCalled(); }); @@ -472,7 +472,7 @@ describe("API validation contracts", () => { authenticatedRequest("/api/upload", { method: "POST", body: formData }), ); expect(uploadResponse.status).toBe(500); - expect(await payload(uploadResponse)).toEqual({ error: "Request failed." }); + expect(await payload(uploadResponse)).toMatchObject({ error: "Request failed." }); const retryClient = createSupabaseMock((call) => { if (call.table === "ingestion_jobs" && call.operation === "select" && call.maybeSingle) { @@ -489,7 +489,7 @@ describe("API validation contracts", () => { }, ); expect(retryResponse.status).toBe(500); - expect(await payload(retryResponse)).toEqual({ error: "Request failed." }); + expect(await payload(retryResponse)).toMatchObject({ error: "Request failed." }); const reindexClient = createSupabaseMock((call) => { if (call.table === "documents" && call.operation === "select" && call.maybeSingle) { @@ -508,7 +508,7 @@ describe("API validation contracts", () => { { params: Promise.resolve({ id: documentId }) }, ); expect(reindexResponse.status).toBe(500); - expect(await payload(reindexResponse)).toEqual({ error: "Request failed." }); + expect(await payload(reindexResponse)).toMatchObject({ error: "Request failed." }); const summarizeClient = createSupabaseMock(); summarizeClient.rpc.mockImplementation(async () => availableRateLimit); @@ -523,7 +523,7 @@ describe("API validation contracts", () => { { params: Promise.resolve({ id: documentId }) }, ); expect(summarizeResponse.status).toBe(500); - expect(await payload(summarizeResponse)).toEqual({ error: "Request failed." }); + expect(await payload(summarizeResponse)).toMatchObject({ error: "Request failed." }); }); it("rejects invalid upload metadata before storage upload or database writes", async () => { @@ -538,7 +538,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Upload metadata is invalid." }); + expect(body).toMatchObject({ error: "Upload metadata is invalid." }); expect(client.storageMocks.upload).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -555,7 +555,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Upload metadata is invalid." }); + expect(body).toMatchObject({ error: "Upload metadata is invalid." }); expect(client.storageMocks.upload).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -575,7 +575,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Invalid upload form data." }); + expect(body).toMatchObject({ error: "Invalid upload form data." }); expect(client.storageMocks.upload).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -595,7 +595,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Invalid upload form data." }); + expect(body).toMatchObject({ error: "Invalid upload form data." }); expect(client.storageMocks.upload).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -651,7 +651,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Enter a document title between 1 and 180 characters." }); + expect(body).toMatchObject({ error: "Enter a document title between 1 and 180 characters." }); expect(client.from).not.toHaveBeenCalled(); }); @@ -676,9 +676,13 @@ describe("API validation contracts", () => { ); expect(missingResponse.status).toBe(400); - expect(await payload(missingResponse)).toEqual({ error: "Enter a document title between 1 and 180 characters." }); + expect(await payload(missingResponse)).toMatchObject({ + error: "Enter a document title between 1 and 180 characters.", + }); expect(unknownResponse.status).toBe(400); - expect(await payload(unknownResponse)).toEqual({ error: "Enter a document title between 1 and 180 characters." }); + expect(await payload(unknownResponse)).toMatchObject({ + error: "Enter a document title between 1 and 180 characters.", + }); expect(client.from).not.toHaveBeenCalled(); }); @@ -693,7 +697,7 @@ describe("API validation contracts", () => { const body = await payload(response); expect(response.status).toBe(400); - expect(body).toEqual({ error: "Invalid document id." }); + expect(body).toMatchObject({ error: "Invalid document id." }); expect(client.from).not.toHaveBeenCalled(); }); }); diff --git a/tests/client-secret-surface.test.ts b/tests/client-secret-surface.test.ts new file mode 100644 index 000000000..4cd651cd0 --- /dev/null +++ b/tests/client-secret-surface.test.ts @@ -0,0 +1,167 @@ +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const SRC_ROOT = join(ROOT, "src"); +const SERVER_ENV = join(SRC_ROOT, "lib", "env.ts"); +const PUBLIC_ENV = join(SRC_ROOT, "lib", "client-env.ts"); +const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"]; +const PUBLIC_ENV_KEYS = new Set(["NODE_ENV", "NEXT_PUBLIC_LOCAL_NO_AUTH", "NEXT_PUBLIC_PUBLIC_UPLOADS_ENABLED"]); +const importCache = new Map(); + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { recursive: true }) + .map(String) + .filter((name) => SOURCE_EXTENSIONS.includes(extname(name))) + .map((name) => join(dir, name)); +} + +function isClientModule(text: string) { + return /^\s*["']use client["'];/m.test(text); +} + +function resolveLocalImport(importer: string, specifier: string) { + const base = specifier.startsWith("@/") + ? join(SRC_ROOT, specifier.slice(2)) + : specifier.startsWith(".") + ? resolve(dirname(importer), specifier) + : null; + if (!base) return null; + for (const candidate of [ + base, + ...SOURCE_EXTENSIONS.map((extension) => `${base}${extension}`), + ...SOURCE_EXTENSIONS.map((extension) => join(base, `index${extension}`)), + ]) { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } + return null; +} + +function localImports(file: string) { + const cached = importCache.get(file); + if (cached) return cached; + const text = readFileSync(file, "utf8"); + const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true); + const specifiers: string[] = []; + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + const clause = statement.importClause; + const namedBindings = clause?.namedBindings; + const isTypeOnly = + clause?.isTypeOnly || + (namedBindings && + ts.isNamedImports(namedBindings) && + namedBindings.elements.every((element) => element.isTypeOnly)); + if (!isTypeOnly) specifiers.push(statement.moduleSpecifier.text); + } + if ( + ts.isExportDeclaration(statement) && + !statement.isTypeOnly && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + specifiers.push(statement.moduleSpecifier.text); + } + } + const imports = specifiers.flatMap((specifier) => { + const resolved = resolveLocalImport(file, specifier); + return resolved ? [resolved] : []; + }); + importCache.set(file, imports); + return imports; +} + +function serverEnvImportChains() { + const clientRoots = sourceFiles(SRC_ROOT).filter((file) => isClientModule(readFileSync(file, "utf8"))); + const memo = new Map(); + + const pathToServerEnv = (file: string, visiting: Set): string[] | null => { + if (file === SERVER_ENV) return [file]; + if (memo.has(file)) return memo.get(file) ?? null; + if (visiting.has(file)) return null; + + visiting.add(file); + for (const imported of localImports(file)) { + const childPath = pathToServerEnv(imported, visiting); + if (childPath) { + const path = [file, ...childPath]; + memo.set(file, path); + visiting.delete(file); + return path; + } + } + visiting.delete(file); + memo.set(file, null); + return null; + }; + + return clientRoots.flatMap((root) => { + const chain = pathToServerEnv(root, new Set()); + return chain ? [chain.map((file) => relative(ROOT, file).replaceAll("\\", "/")).join(" -> ")] : []; + }); +} + +describe("client environment isolation", () => { + it("marks the server environment contract as server-only", () => { + expect(readFileSync(SERVER_ENV, "utf8")).toMatch(/^import ["']server-only["'];/); + }); + + it("prevents client module graphs from reaching the server environment contract", { timeout: 30000 }, () => { + expect(serverEnvImportChains()).toEqual([]); + }); + + it("keeps the public environment module limited to allowlisted public flags", () => { + const text = readFileSync(PUBLIC_ENV, "utf8"); + const referencedKeys = [...text.matchAll(/process\.env\.([A-Z0-9_]+)/g)].map((match) => match[1]!); + expect(new Set(referencedKeys)).toEqual(PUBLIC_ENV_KEYS); + expect(text).not.toMatch(/SUPABASE_SERVICE_ROLE_KEY|OPENAI_API_KEY|RAG_QUERY_HASH_SECRET/); + }); + + it("scans public assets and generated client chunks without printing surrounding content", () => { + const scannerPath = join(ROOT, "scripts", "check-client-bundle-secrets.mjs"); + const packageJson = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { + scripts: Record; + }; + expect(packageJson.scripts["check:client-bundle-secrets"]).toContain("check-client-bundle-secrets.mjs"); + expect(packageJson.scripts.build).toContain("check-client-bundle-secrets.mjs"); + + const fixtureRoot = mkdtempSync(join(tmpdir(), "clinical-kb-client-bundle-")); + try { + const staticRoot = join(fixtureRoot, ".next", "static"); + mkdirSync(staticRoot, { recursive: true }); + mkdirSync(join(fixtureRoot, "public"), { recursive: true }); + writeFileSync(join(staticRoot, "safe.js"), "console.log('safe client chunk');", "utf8"); + const safeResult = spawnSync(process.execPath, [scannerPath], { cwd: fixtureRoot, encoding: "utf8" }); + expect(safeResult.status).toBe(0); + + writeFileSync(join(staticRoot, "unsafe.js"), "const marker = 'OPENAI_API_KEY';", "utf8"); + const unsafeResult = spawnSync(process.execPath, [scannerPath], { cwd: fixtureRoot, encoding: "utf8" }); + expect(unsafeResult.status).toBe(1); + expect(unsafeResult.stderr).toContain(".next/static/unsafe.js"); + expect(unsafeResult.stderr).toContain("OPENAI_API_KEY"); + expect(unsafeResult.stderr).not.toContain("const marker"); + + rmSync(join(staticRoot, "unsafe.js")); + writeFileSync(join(fixtureRoot, "public", "unsafe.txt"), "SUPABASE_SERVICE_ROLE_KEY", "utf8"); + const publicResult = spawnSync(process.execPath, [scannerPath], { cwd: fixtureRoot, encoding: "utf8" }); + expect(publicResult.status).toBe(1); + expect(publicResult.stderr).toContain("public/unsafe.txt"); + expect(publicResult.stderr).toContain("SUPABASE_SERVICE_ROLE_KEY"); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/differentials-route.test.ts b/tests/differentials-route.test.ts index c77cb7380..508e69cda 100644 --- a/tests/differentials-route.test.ts +++ b/tests/differentials-route.test.ts @@ -70,11 +70,14 @@ class QueryBuilder implements PromiseLike { } function createSupabaseMock(resolve: QueryResolver = () => ok([])) { + const calls: QueryCall[] = []; const from = vi.fn((table: string) => { const call: QueryCall = { table, filters: [], inFilters: [], maybeSingle: false }; + calls.push(call); return new QueryBuilder(call, resolve); }); return { + calls, from, auth: { getUser: vi.fn(async (receivedToken?: string) => @@ -87,12 +90,28 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([])) { }; } -function mockRuntime(client: ReturnType, options: { demoMode?: boolean } = {}) { +function mockRuntime( + client: ReturnType, + options: { demoMode?: boolean; registryEmbeddingError?: Error } = {}, +) { vi.resetModules(); vi.doMock("@/lib/env", () => ({ isDemoMode: () => Boolean(options.demoMode), isLocalNoAuthMode: () => Boolean(options.demoMode), })); + vi.doMock("@/lib/registry-corpus", () => ({ + registryCorpusEmbeddingEnabled: () => Boolean(options.registryEmbeddingError), + bestEffortSyncDifferentialRows: vi.fn(async () => { + if (options.registryEmbeddingError) { + console.error("[differentials] registry corpus sync failed", { + name: options.registryEmbeddingError.name, + message: options.registryEmbeddingError.message, + }); + return { documentCount: 0, chunkCount: 0, skipped: true, reason: "failed" }; + } + return { documentCount: 0, chunkCount: 0 }; + }), + })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client, })); @@ -271,4 +290,63 @@ describe("differentials API routes", () => { expect(payload.presentations?.[0]?.id).toBe("acute-confusion-encephalopathy"); expect(payload.presentations?.length).toBeLessThanOrEqual(5); }); + + it("serves seeded differential records when registry corpus embedding fails after the row upsert", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let stored: Array> = []; + const client = createSupabaseMock((call) => { + if (call.table !== "differential_records") return ok([]); + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + return ok(stored.filter((row) => row.kind === "diagnosis")); + }); + mockRuntime(client, { registryEmbeddingError: new Error("embedding unavailable") }); + const { GET } = await import("../src/app/api/differentials/route"); + + const response = await GET(authenticatedRequest("/api/differentials?kind=diagnosis&limit=10")); + const payload = (await response.json()) as { records?: Array<{ slug: string }>; total?: number }; + + expect(response.status).toBe(200); + expect(payload.total ?? 0).toBeGreaterThan(0); + expect(payload.records?.length).toBeGreaterThan(0); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[differentials] registry corpus sync failed"), + expect.objectContaining({ name: "Error", message: "embedding unavailable" }), + ); + }); + + it.each([ + ["diagnosis", "/api/differentials/unknown-diagnosis?kind=diagnosis", "../src/app/api/differentials/[slug]/route"], + [ + "presentation", + "/api/differentials/presentations/unknown-presentation", + "../src/app/api/differentials/presentations/[slug]/route", + ], + ])("returns 404 for an unknown %s slug during a corpus embedding outage", async (kind, path, modulePath) => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let stored: Array> = []; + const slug = kind === "diagnosis" ? "unknown-diagnosis" : "unknown-presentation"; + const client = createSupabaseMock((call) => { + if (call.table !== "differential_records") return ok([]); + if (call.head) return { data: null, error: null, count: stored.length }; + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + if (call.maybeSingle) return ok(stored.find((row) => row.kind === kind && row.slug === slug) ?? null); + return ok(stored.filter((row) => row.kind === kind)); + }); + mockRuntime(client, { registryEmbeddingError: new Error("embedding unavailable") }); + const { GET } = await import(modulePath); + + const response = await GET(authenticatedRequest(path), { params: Promise.resolve({ slug }) }); + + expect(response.status).toBe(404); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[differentials] registry corpus sync failed"), + expect.objectContaining({ name: "Error", message: "embedding unavailable" }), + ); + }); }); diff --git a/tests/http-error-response.test.ts b/tests/http-error-response.test.ts new file mode 100644 index 000000000..5c544d600 --- /dev/null +++ b/tests/http-error-response.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; +import { jsonError, PublicApiError } from "../src/lib/http"; + +describe("jsonError public payload", () => { + it("keeps public error payloads stable without exposing stack or internal causes", async () => { + const error = new PublicApiError("Search failed safely.", 503, { + code: "search_unavailable", + requestId: "req_123", + causeName: "DatabaseError", + causeMessage: "select failed at /private/path", + sqlState: "PGRST500", + }); + error.stack = "PublicApiError: Search failed safely.\n at secret.ts:1:1"; + + const response = jsonError(error); + const body = await response.json(); + + expect(response.status).toBe(503); + expect(body).toEqual({ + error: "Search failed safely.", + message: "Search failed safely.", + code: "search_unavailable", + requestId: "req_123", + }); + expect(JSON.stringify(body)).not.toMatch(/stack|causeName|causeMessage|sqlState|secret\.ts|private\/path/i); + }); + + it("uses a generic message for unexpected server errors", async () => { + const error = new Error("database password leaked in thrown message"); + error.stack = "Error: database password leaked in thrown message\n at route.ts:1:1"; + + const response = jsonError(error, 500); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body).toEqual({ + error: "Request failed.", + message: "Request failed.", + code: "internal_error", + }); + }); + + it("only returns stable snake_case error codes and falls back to status-based codes for invalid codes", async () => { + // Valid stable code + const validError = new PublicApiError("Valid code error", 400, { code: "valid_error_code" }); + const validResponse = jsonError(validError); + const validBody = await validResponse.json(); + expect(validBody.code).toBe("valid_error_code"); + + // Invalid code: arbitrary class name like TypeError + const typeError = new PublicApiError("Type error", 500, { code: "TypeError" }); + const typeErrorResponse = jsonError(typeError); + const typeErrorBody = await typeErrorResponse.json(); + expect(typeErrorBody.code).toBe("internal_error"); + + // Invalid code: contains uppercase + const uppercaseError = new PublicApiError("Uppercase code", 400, { code: "InvalidCode" }); + const uppercaseResponse = jsonError(uppercaseError); + const uppercaseBody = await uppercaseResponse.json(); + expect(uppercaseBody.code).toBe("request_failed"); + + // Invalid code: contains spaces + const spaceError = new PublicApiError("Space code", 400, { code: "invalid code" }); + const spaceResponse = jsonError(spaceError); + const spaceBody = await spaceResponse.json(); + expect(spaceBody.code).toBe("request_failed"); + + // Invalid code: contains hyphen instead of underscore + const hyphenError = new PublicApiError("Hyphen code", 400, { code: "invalid-code" }); + const hyphenResponse = jsonError(hyphenError); + const hyphenBody = await hyphenResponse.json(); + expect(hyphenBody.code).toBe("request_failed"); + + // Invalid code: starts with number + const numberError = new PublicApiError("Number start", 400, { code: "5xx_error" }); + const numberResponse = jsonError(numberError); + const numberBody = await numberResponse.json(); + expect(numberBody.code).toBe("request_failed"); + + // Invalid code: empty string + const emptyError = new PublicApiError("Empty code", 400, { code: "" }); + const emptyResponse = jsonError(emptyError); + const emptyBody = await emptyResponse.json(); + expect(emptyBody.code).toBe("request_failed"); + }); +}); diff --git a/tests/medications-route.test.ts b/tests/medications-route.test.ts index 7d923bbd4..14ee3b5bd 100644 --- a/tests/medications-route.test.ts +++ b/tests/medications-route.test.ts @@ -5,7 +5,7 @@ const token = "valid-token"; const recordId = "11111111-1111-4111-8111-111111111111"; type QueryError = { message: string }; -type QueryResult = { data: unknown; error: QueryError | null }; +type QueryResult = { data: unknown; error: QueryError | null; count?: number | null }; type QueryFilter = { column: string; value: unknown }; type QueryCall = { table: string; @@ -14,6 +14,8 @@ type QueryCall = { maybeSingle: boolean; head?: boolean; count?: string; + upsert?: boolean; + upsertRows?: unknown[]; }; type QueryResolver = (call: QueryCall) => QueryResult; @@ -74,6 +76,12 @@ class QueryBuilder implements PromiseLike { return this; } + upsert(rows: unknown) { + this.call.upsert = true; + this.call.upsertRows = Array.isArray(rows) ? rows : [rows]; + return this; + } + maybeSingle() { this.call.maybeSingle = true; return Promise.resolve(this.resolver(this.call)); @@ -122,7 +130,10 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([]), options: { li }; } -function mockRuntime(client: ReturnType, options: { demoMode?: boolean } = {}) { +function mockRuntime( + client: ReturnType, + options: { demoMode?: boolean; registryEmbeddingError?: Error } = {}, +) { vi.resetModules(); vi.doUnmock("@/lib/supabase/auth"); vi.doUnmock("@/lib/supabase/admin"); @@ -133,6 +144,19 @@ function mockRuntime(client: ReturnType, options: { d requireOpenAIEnv: () => undefined, requireServerEnv: () => undefined, })); + vi.doMock("@/lib/registry-corpus", () => ({ + registryCorpusEmbeddingEnabled: () => Boolean(options.registryEmbeddingError), + bestEffortSyncMedicationRows: vi.fn(async () => { + if (options.registryEmbeddingError) { + console.error("[medications] registry corpus sync failed", { + name: options.registryEmbeddingError.name, + message: options.registryEmbeddingError.message, + }); + return { documentCount: 0, chunkCount: 0, skipped: true, reason: "failed" }; + } + return { documentCount: 0, chunkCount: 0 }; + }), + })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client, })); @@ -244,4 +268,63 @@ describe("medications API", () => { expect(payload.record.name).toBe("Acamprosate"); expect(client.from).not.toHaveBeenCalled(); }); + + it("serves a seeded medication detail when registry corpus embedding fails after the row upsert", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let stored: Array> = []; + const client = createSupabaseMock((call) => { + if (call.table !== "medication_records") return ok([]); + if (call.head) return { data: null, error: null, count: stored.length }; + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + if (call.maybeSingle) { + return ok(stored.find((row) => row.slug === "acamprosate") ?? null); + } + return ok(stored); + }); + mockRuntime(client, { registryEmbeddingError: new Error("embedding unavailable") }); + const { GET } = await import("../src/app/api/medications/[slug]/route"); + + const response = await GET(authedRequest("/api/medications/acamprosate"), { + params: Promise.resolve({ slug: "acamprosate" }), + }); + const payload = (await response.json()) as { record: { slug: string; name: string } }; + + expect(response.status).toBe(200); + expect(payload.record.slug).toBe("acamprosate"); + expect(payload.record.name).toBe("Acamprosate"); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[medications] registry corpus sync failed"), + expect.objectContaining({ name: "Error", message: "embedding unavailable" }), + ); + }); + + it("returns 404 for an unknown medication slug during a corpus embedding outage", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let stored: Array> = []; + const client = createSupabaseMock((call) => { + if (call.table !== "medication_records") return ok([]); + if (call.head) return { data: null, error: null, count: stored.length }; + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + if (call.maybeSingle) return ok(stored.find((row) => row.slug === "unknown-medication") ?? null); + return ok(stored); + }); + mockRuntime(client, { registryEmbeddingError: new Error("embedding unavailable") }); + const { GET } = await import("../src/app/api/medications/[slug]/route"); + + const response = await GET(authedRequest("/api/medications/unknown-medication"), { + params: Promise.resolve({ slug: "unknown-medication" }), + }); + + expect(response.status).toBe(404); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[medications] registry corpus sync failed"), + expect.objectContaining({ name: "Error", message: "embedding unavailable" }), + ); + }); }); diff --git a/tests/owner-scope.test.ts b/tests/owner-scope.test.ts index cf2025525..55da3e6d0 100644 --- a/tests/owner-scope.test.ts +++ b/tests/owner-scope.test.ts @@ -16,7 +16,7 @@ describe("requireOwnerScope (fail-closed owner scoping)", () => { vi.doMock("@/lib/env", () => ({ isDemoMode: () => true, isLocalNoAuthMode: () => false })); const { requireOwnerScope, PUBLIC_OWNER_FILTER_SENTINEL } = await import("../src/lib/owner-scope"); // Must not return undefined/null: that reaches the retrieval RPCs as a NULL - // owner_filter, which now fails closed (migration 20260708160000). The public + // owner_filter, which now fails closed (migration 20260708160001). The public // sentinel scopes these modes to the shared public corpus instead. expect(requireOwnerScope(undefined)).toBe(PUBLIC_OWNER_FILTER_SENTINEL); expect(requireOwnerScope(null)).toBe(PUBLIC_OWNER_FILTER_SENTINEL); diff --git a/tests/private-access-routes.test.ts b/tests/private-access-routes.test.ts index cee6d1230..c759934d5 100644 --- a/tests/private-access-routes.test.ts +++ b/tests/private-access-routes.test.ts @@ -438,7 +438,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(await payload(response)).toMatchObject({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -461,7 +461,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(await payload(response)).toMatchObject({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -654,7 +654,7 @@ describe("private document API access", () => { const response = await GET(authenticatedRequest("/api/documents")); expect(response.status).toBe(500); - expect(await payload(response)).toEqual({ error: "Request failed." }); + expect(await payload(response)).toMatchObject({ error: "Request failed." }); }); it("does not leak demo documents from real-mode listing failures", async () => { @@ -668,7 +668,7 @@ describe("private document API access", () => { const body = await payload(response); expect(response.status).toBe(500); - expect(body).toEqual({ error: "Request failed." }); + expect(body).toMatchObject({ error: "Request failed." }); expect(body.documents).toBeUndefined(); expect(body.demoMode).toBeUndefined(); }); @@ -706,7 +706,7 @@ describe("private document API access", () => { }); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(await payload(response)).toMatchObject({ error: "Document not found." }); expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); @@ -748,7 +748,7 @@ describe("private document API access", () => { }); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(await payload(response)).toMatchObject({ error: "Document not found." }); expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); @@ -857,7 +857,7 @@ describe("private document API access", () => { }); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Image not found." }); + expect(await payload(response)).toMatchObject({ error: "Image not found." }); expect(client.storageMocks.createSignedUrl).not.toHaveBeenCalled(); }); @@ -876,7 +876,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Public uploads are not configured for this workspace." }); + expect(await payload(response)).toMatchObject({ error: "Public uploads are not configured for this workspace." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.storageMocks.upload).not.toHaveBeenCalled(); }); @@ -933,7 +933,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Rate limit check is temporarily unavailable." }); + expect(await payload(response)).toMatchObject({ error: "Rate limit check is temporarily unavailable." }); expect(client.storageMocks.upload).not.toHaveBeenCalled(); }); @@ -1286,7 +1286,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(401); - expect(await payload(response)).toEqual({ error: "Authentication required." }); + expect(await payload(response)).toMatchObject({ error: "Authentication required." }); expect(client.from).not.toHaveBeenCalled(); }); @@ -1896,7 +1896,7 @@ describe("private document API access", () => { const documentUpdates = client.calls.filter((call) => call.table === "documents" && call.operation === "update"); expect(response.status).toBe(500); - expect(body).toEqual({ error: "Request failed." }); + expect(body).toMatchObject({ error: "Request failed." }); expect(documentUpdates).toHaveLength(2); expect(documentUpdates[0]?.updatePayload).toEqual({ status: "queued", @@ -1960,7 +1960,7 @@ describe("private document API access", () => { const documentUpdates = client.calls.filter((call) => call.table === "documents" && call.operation === "update"); expect(response.status).toBe(500); - expect(body).toEqual({ error: "Request failed." }); + expect(body).toMatchObject({ error: "Request failed." }); expect(documentUpdates).toHaveLength(1); expect(documentUpdates[0]?.updatePayload).toEqual({ status: "queued", @@ -2205,7 +2205,7 @@ describe("private document API access", () => { }); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(await payload(response)).toMatchObject({ error: "Document not found." }); expect(client.calls).toHaveLength(1); }); @@ -2378,7 +2378,7 @@ describe("private document API access", () => { }); expect(response.status).toBe(400); - expect(await payload(response)).toEqual({ error: "Invalid document id." }); + expect(await payload(response)).toMatchObject({ error: "Invalid document id." }); expect(client.auth.getUser).not.toHaveBeenCalled(); expect(client.from).not.toHaveBeenCalled(); }); @@ -2442,7 +2442,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(400); - expect(await payload(response)).toEqual({ error: "Enter a document title between 1 and 180 characters." }); + expect(await payload(response)).toMatchObject({ error: "Enter a document title between 1 and 180 characters." }); expect(client.from).not.toHaveBeenCalled(); }); @@ -2460,7 +2460,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(await payload(response)).toMatchObject({ error: "Document not found." }); expect(client.calls[0].filters).toContainEqual({ column: "owner_id", value: userId }); }); @@ -2558,7 +2558,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(400); - expect(await payload(response)).toEqual({ + expect(await payload(response)).toMatchObject({ error: "Enter a short, specific clinical tag. Generic document-control tags are not allowed.", }); expect(client.from).not.toHaveBeenCalled(); @@ -2681,7 +2681,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Tag not found." }); + expect(await payload(response)).toMatchObject({ error: "Tag not found." }); expect(selectExisting?.filters).toContainEqual({ column: "owner_id", value: userId }); expect(client.calls.some((call) => call.table === "document_labels" && call.operation === "update")).toBe(false); expect(invalidateRagCachesForDocumentMutation).not.toHaveBeenCalled(); @@ -2710,7 +2710,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Tag not found." }); + expect(await payload(response)).toMatchObject({ error: "Tag not found." }); expect(selectExisting?.filters).toEqual( expect.arrayContaining([ { column: "id", value: labelId }, @@ -2741,7 +2741,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Manual tag not found." }); + expect(await payload(response)).toMatchObject({ error: "Manual tag not found." }); expect(client.calls.some((call) => call.table === "document_labels" && call.operation === "update")).toBe(false); }); @@ -2924,7 +2924,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(500); - expect(await payload(response)).toEqual({ error: "Request failed." }); + expect(await payload(response)).toMatchObject({ error: "Request failed." }); expect(cleanupUpdate?.updatePayload).toMatchObject({ status: "failed", last_error: "Index trace cleanup failed: query log delete failed", @@ -2955,7 +2955,7 @@ describe("private document API access", () => { }); expect(response.status).toBe(409); - expect(await payload(response)).toEqual({ + expect(await payload(response)).toMatchObject({ error: "Document has pending or processing indexing work. Stop or wait for the worker before deleting.", }); expect(client.calls.some((call) => call.table === "documents" && call.operation === "delete")).toBe(false); @@ -3237,7 +3237,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(503); - expect(await payload(response)).toEqual({ error: "Rate limit check is temporarily unavailable." }); + expect(await payload(response)).toMatchObject({ error: "Rate limit check is temporarily unavailable." }); expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); }); @@ -3291,7 +3291,11 @@ describe("private document API access", () => { const response = await POST( request("/api/search", { method: "POST", - body: JSON.stringify({ query: "clozapine monitoring", includeRelatedDocuments: false }), + body: JSON.stringify({ + query: "clozapine monitoring", + includeRelatedDocuments: false, + filters: { sourceStatuses: ["current"] }, + }), }), ); const body = await payload(response); @@ -3323,13 +3327,17 @@ describe("private document API access", () => { const response = await POST( request("/api/search", { method: "POST", - body: JSON.stringify({ query: "clozapine monitoring", includeRelatedDocuments: false }), + body: JSON.stringify({ + query: "clozapine monitoring", + includeRelatedDocuments: false, + filters: { sourceStatuses: ["current"] }, + }), }), ); expect(response.status).toBe(500); expect(response.headers.get("X-Clinical-KB-Fallback")).toBeNull(); - expect(await payload(response)).toEqual({ error: "Search failed. Retry with a narrower question." }); + expect(await payload(response)).toMatchObject({ error: "Search failed. Retry with a narrower question." }); expect(searchChunksWithTelemetry).not.toHaveBeenCalled(); }); @@ -3350,7 +3358,7 @@ describe("private document API access", () => { const response = await POST( request("/api/answer", { method: "POST", - body: JSON.stringify({ query: "clozapine monitoring" }), + body: JSON.stringify({ query: "clozapine monitoring", filters: { sourceStatuses: ["current"] } }), }), ); const body = await payload(response); @@ -3384,7 +3392,7 @@ describe("private document API access", () => { const response = await POST( request("/api/answer/stream", { method: "POST", - body: JSON.stringify({ query: "clozapine monitoring" }), + body: JSON.stringify({ query: "clozapine monitoring", filters: { sourceStatuses: ["current"] } }), }), ); const body = await response.text(); @@ -3420,7 +3428,7 @@ describe("private document API access", () => { const response = await POST( request("/api/answer/stream", { method: "POST", - body: JSON.stringify({ query: "clozapine monitoring" }), + body: JSON.stringify({ query: "clozapine monitoring", filters: { sourceStatuses: ["current"] } }), }), ); const body = await response.text(); @@ -3435,6 +3443,9 @@ describe("private document API access", () => { // diagnosable from the client network tab (confirmed live 2026-07-06). details: { code: "supabase_api_key_configuration" }, }); + expect(JSON.stringify(errorPayload)).not.toMatch( + /stack|causeName|causeMessage|sqlState|private\/path|[A-Za-z]:\\\\/i, + ); expect(answerQuestionWithScope).not.toHaveBeenCalled(); }); @@ -3879,7 +3890,7 @@ describe("private document API access", () => { ); expect(response.status).toBe(404); - expect(await payload(response)).toEqual({ error: "Document not found." }); + expect(await payload(response)).toMatchObject({ error: "Document not found." }); expect(summarizeDocument).toHaveBeenCalledWith(otherDocumentId, userId); }); diff --git a/tests/public-api-access.test.ts b/tests/public-api-access.test.ts new file mode 100644 index 000000000..edaec20df --- /dev/null +++ b/tests/public-api-access.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { anonymousApiSubjectKey } from "@/lib/public-api-access"; + +function anonymousRequest(ip: string, userAgent: string) { + return new Request("http://localhost/api/answer", { + headers: { + "x-real-ip": ip, + "user-agent": userAgent, + }, + }); +} + +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")); + const second = anonymousApiSubjectKey(anonymousRequest("198.51.100.10", "client-b")); + + expect(second).toBe(first); + }); + + it("keeps distinct network identities separate", () => { + const first = anonymousApiSubjectKey(anonymousRequest("198.51.100.10", "client")); + const second = anonymousApiSubjectKey(anonymousRequest("198.51.100.11", "client")); + + expect(second).not.toBe(first); + }); +}); diff --git a/tests/registry-corpus.test.ts b/tests/registry-corpus.test.ts index d0505077b..6026c3274 100644 --- a/tests/registry-corpus.test.ts +++ b/tests/registry-corpus.test.ts @@ -6,6 +6,10 @@ import { registryCorpusDetailHref } from "../src/lib/registry-corpus-links"; import type { MedicationRecordRow } from "../src/lib/medication-records"; import type { RegistryRecordRow } from "../src/lib/registry-records"; +const { embedTextsMock } = vi.hoisted(() => ({ embedTextsMock: vi.fn() })); + +vi.mock("@/lib/openai", () => ({ embedTexts: embedTextsMock })); + function registryRow(overrides: Partial = {}): RegistryRecordRow { return { id: "11111111-1111-4111-8111-111111111111", @@ -43,7 +47,109 @@ function registryRow(overrides: Partial = {}): RegistryRecord }; } +function corpusHarness() { + const documents = new Map>(); + const chunks = new Map>(); + const tableState = { documents, document_chunks: chunks }; + const supabase = { + from: vi.fn((table: keyof typeof tableState) => { + let selectedIds: string[] = []; + const query = { + select: vi.fn(() => query), + in: vi.fn((_column: string, ids: string[]) => { + selectedIds = ids; + return query; + }), + upsert: vi.fn(async (rows: Array>) => { + for (const row of rows) tableState[table].set(String(row.id), row); + return { data: rows, error: null }; + }), + delete: vi.fn(() => query), + then: ( + resolve: (value: { data: Array>; error: null }) => unknown, + reject?: (reason: unknown) => unknown, + ) => + Promise.resolve({ + data: selectedIds.flatMap((id) => { + const row = tableState[table].get(id); + return row ? [row] : []; + }), + error: null, + }).then(resolve, reject), + }; + return query; + }), + }; + return { supabase, documents, chunks }; +} + describe("registry corpus", () => { + it("retries a failed embed and stops calling OpenAI once corpus hashes are current", async () => { + const { supabase, documents, chunks } = corpusHarness(); + embedTextsMock + .mockReset() + .mockRejectedValueOnce(new Error("embedding unavailable")) + .mockResolvedValue([[0.1]]); + + await expect( + (async () => { + const { embedClinicalRegistryRows } = await import("../src/lib/registry-corpus"); + return embedClinicalRegistryRows(supabase as never, [registryRow()]); + })(), + ).rejects.toThrow("embedding unavailable"); + expect(documents.size).toBe(0); + expect(chunks.size).toBe(0); + + const { embedClinicalRegistryRows } = await import("../src/lib/registry-corpus"); + await expect(embedClinicalRegistryRows(supabase as never, [registryRow()])).resolves.toEqual({ + documentCount: 1, + chunkCount: 1, + }); + await expect(embedClinicalRegistryRows(supabase as never, [registryRow()])).resolves.toEqual({ + documentCount: 0, + chunkCount: 0, + }); + expect(embedTextsMock).toHaveBeenCalledTimes(2); + }); + + it("refreshes stored rows without re-embedding when derived metadata drifts", async () => { + const { supabase, documents, chunks } = corpusHarness(); + embedTextsMock.mockReset().mockResolvedValue([[0.1]]); + const { embedClinicalRegistryRows } = await import("../src/lib/registry-corpus"); + + await expect(embedClinicalRegistryRows(supabase as never, [registryRow()])).resolves.toEqual({ + documentCount: 1, + chunkCount: 1, + }); + expect(embedTextsMock).toHaveBeenCalledTimes(1); + + // Simulate a row written by an older derivation: same content hash, stale + // derived metadata that content_hash cannot see. + const [documentId] = [...documents.keys()]; + const stored = documents.get(documentId!)!; + documents.set(documentId!, { + ...stored, + metadata: { ...(stored.metadata as Record), registry_detail_href: "/legacy/crisis-service" }, + }); + + await expect(embedClinicalRegistryRows(supabase as never, [registryRow()])).resolves.toEqual({ + documentCount: 1, + chunkCount: 1, + }); + // The refresh rewrites the rows but reuses the stored embedding. + expect(embedTextsMock).toHaveBeenCalledTimes(1); + const refreshed = documents.get(documentId!) as { metadata: Record }; + expect(refreshed.metadata.registry_detail_href).toBe("/services/crisis-service"); + const [chunk] = [...chunks.values()]; + expect(chunk?.embedding).toEqual([0.1]); + + await expect(embedClinicalRegistryRows(supabase as never, [registryRow()])).resolves.toEqual({ + documentCount: 0, + chunkCount: 0, + }); + expect(embedTextsMock).toHaveBeenCalledTimes(1); + }); + it("converts registry rows into source-governed corpus entries", () => { const [entry] = clinicalRegistryRowsToCorpusEntries([registryRow()]); diff --git a/tests/registry-records-route.test.ts b/tests/registry-records-route.test.ts index 5571c270c..ab95c3680 100644 --- a/tests/registry-records-route.test.ts +++ b/tests/registry-records-route.test.ts @@ -139,7 +139,10 @@ function createSupabaseMock(resolve: QueryResolver = () => ok([]), options: { li }; } -function mockRuntime(client: ReturnType, options: { demoMode?: boolean } = {}) { +function mockRuntime( + client: ReturnType, + options: { demoMode?: boolean; registryEmbeddingError?: Error } = {}, +) { vi.resetModules(); vi.doMock("@/lib/env", () => ({ env: {}, @@ -148,6 +151,19 @@ function mockRuntime(client: ReturnType, options: { d requireOpenAIEnv: () => undefined, requireServerEnv: () => undefined, })); + vi.doMock("@/lib/registry-corpus", () => ({ + registryCorpusEmbeddingEnabled: () => Boolean(options.registryEmbeddingError), + bestEffortSyncClinicalRegistryRows: vi.fn(async () => { + if (options.registryEmbeddingError) { + console.error("[registry] registry corpus sync failed", { + name: options.registryEmbeddingError.name, + message: options.registryEmbeddingError.message, + }); + return { documentCount: 0, chunkCount: 0, skipped: true, reason: "failed" }; + } + return { documentCount: 0, chunkCount: 0 }; + }), + })); vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client, })); @@ -372,6 +388,59 @@ describe("registry records API", () => { expect(payload.total).toBe(serviceRecords.length); }); + it("serves seeded records when registry corpus embedding fails after the row upsert", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let stored: Array> = []; + const client = createSupabaseMock((call) => { + if (call.table !== "clinical_registry_records") return ok([]); + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + return ok(stored); + }); + mockRuntime(client, { registryEmbeddingError: new Error("embedding unavailable") }); + const { GET } = await import("../src/app/api/registry/records/route"); + const { serviceRecords } = await import("../src/lib/services"); + + const response = await GET(authedRequest("/api/registry/records?kind=service")); + const payload = (await response.json()) as { records: Array<{ slug: string }>; total: number }; + + expect(response.status).toBe(200); + expect(payload.records).toHaveLength(serviceRecords.length); + expect(payload.total).toBe(serviceRecords.length); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[registry] registry corpus sync failed"), + expect.objectContaining({ name: "Error", message: "embedding unavailable" }), + ); + }); + + it("returns 404 for an unknown registry slug during a corpus embedding outage", async () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let stored: Array> = []; + const client = createSupabaseMock((call) => { + if (call.table !== "clinical_registry_records") return ok([]); + if (call.upsert) { + stored = (call.upsertRows ?? []) as Array>; + return ok(stored); + } + if (call.maybeSingle) return ok(stored.find((row) => row.slug === "unknown-service") ?? null); + return ok(stored); + }); + mockRuntime(client, { registryEmbeddingError: new Error("embedding unavailable") }); + const { GET } = await import("../src/app/api/registry/records/[slug]/route"); + + const response = await GET(authedRequest("/api/registry/records/unknown-service?kind=service"), { + params: Promise.resolve({ slug: "unknown-service" }), + }); + + expect(response.status).toBe(404); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining("[registry] registry corpus sync failed"), + expect.objectContaining({ name: "Error", message: "embedding unavailable" }), + ); + }); + it("does not seed when the owner already has registry records", async () => { const client = createSupabaseMock((call) => call.table === "clinical_registry_records" ? ok([registryRow()]) : ok([]), diff --git a/tests/retrieval-owner-filter-guard.test.ts b/tests/retrieval-owner-filter-guard.test.ts index e282b4da9..222e8f7d1 100644 --- a/tests/retrieval-owner-filter-guard.test.ts +++ b/tests/retrieval-owner-filter-guard.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; // Guard for the retrieval owner-scope boundary (48h-review finding #3). // // The SQL `retrieval_owner_matches(owner_filter, row_owner_id)` now fails CLOSED when -// `owner_filter IS NULL` (migration 20260708160000_retrieval_owner_matches_fail_closed), and +// `owner_filter IS NULL` (migration 20260708160001_retrieval_owner_matches_fail_closed), and // src/lib/owner-scope.ts no longer emits null — so the database has a real tenant floor. This // test remains as defense-in-depth on the app side: no `.rpc(...)` call in `src/` may pass a // *literal* null/undefined `owner_filter`, and every owner_filter value must come from the diff --git a/tests/safe-buffer.test.ts b/tests/safe-buffer.test.ts new file mode 100644 index 000000000..382526f05 --- /dev/null +++ b/tests/safe-buffer.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { safeBufferFrom } from "../src/lib/safe-buffer"; + +describe("safeBufferFrom", () => { + it("copies binary inputs", () => { + const original = Buffer.from("clinical"); + const copied = safeBufferFrom(original); + + expect(copied?.toString("utf8")).toBe("clinical"); + expect(copied).not.toBe(original); + }); + + it("decodes canonical base64 payloads", () => { + expect(safeBufferFrom("Y2xpbmljYWw=", "base64")?.toString("utf8")).toBe("clinical"); + }); + + it("returns null for malformed base64 payloads", () => { + expect(safeBufferFrom("%%%not-base64%%%", "base64")).toBeNull(); + expect(safeBufferFrom("abcde", "base64")).toBeNull(); + }); + + it("returns null for unsupported uncertain inputs", () => { + expect(safeBufferFrom({ data: "YQ==" }, "base64")).toBeNull(); + }); +}); diff --git a/tests/search-interaction-route.test.ts b/tests/search-interaction-route.test.ts index 957846412..2b411a71e 100644 --- a/tests/search-interaction-route.test.ts +++ b/tests/search-interaction-route.test.ts @@ -63,7 +63,7 @@ describe("/api/search/interaction", () => { const response = await POST(request({ query: "", documentId: "not-a-document-id" })); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: "Invalid interaction request." }); + await expect(response.json()).resolves.toMatchObject({ error: "Invalid interaction request." }); }); it("returns the shared server-error envelope when persistence fails", async () => { @@ -92,7 +92,7 @@ describe("/api/search/interaction", () => { ); expect(response.status).toBe(500); - await expect(response.json()).resolves.toEqual({ error: "Request failed." }); + await expect(response.json()).resolves.toMatchObject({ error: "Request failed." }); }); it("stores owned clicked document and chunk ids with sanitized labels", async () => { @@ -191,7 +191,7 @@ describe("/api/search/interaction", () => { const response = await POST(request({ query: "clozapine monitoring" })); expect(response.status).toBe(400); - await expect(response.json()).resolves.toEqual({ error: "Invalid interaction request." }); + await expect(response.json()).resolves.toMatchObject({ error: "Invalid interaction request." }); }); it("does not persist PHI-capable query text in source-open miss telemetry", async () => { diff --git a/tests/search-scope.test.ts b/tests/search-scope.test.ts index 8d3331e4c..53c94545b 100644 --- a/tests/search-scope.test.ts +++ b/tests/search-scope.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { activeScopeFilterCount, searchScopeFiltersSchema } from "@/lib/search-scope"; +import { activeScopeFilterCount, resolveSearchScope, searchScopeFiltersSchema } from "@/lib/search-scope"; describe("search scope filters", () => { it("accepts smart document label filter groups", () => { @@ -37,4 +37,23 @@ describe("search scope filters", () => { it("rejects unknown label types in labelTypesAny", () => { expect(() => searchScopeFiltersSchema.parse({ labelTypesAny: ["not-a-label-type"] })).toThrow(); }); + + it("does not enumerate every public document when no filters are requested", async () => { + const from = () => { + throw new Error("public all-document scope should be enforced by the retrieval owner sentinel"); + }; + + await expect( + resolveSearchScope({ + supabase: { from } as never, + ownerId: undefined, + publicOnly: true, + }), + ).resolves.toMatchObject({ + documentIds: undefined, + activeFilterCount: 0, + matchedDocumentCount: null, + summary: "All public documents", + }); + }); }); diff --git a/tests/stubs/server-only.ts b/tests/stubs/server-only.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/tests/stubs/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/tests/tsx-server-only-runner.test.ts b/tests/tsx-server-only-runner.test.ts new file mode 100644 index 000000000..78b164f07 --- /dev/null +++ b/tests/tsx-server-only-runner.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +describe("standalone TSX server-only compatibility", () => { + it("routes package TSX commands through the server-only-aware runner", () => { + const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + scripts: Record; + }; + const directTsx = Object.entries(packageJson.scripts).filter(([, command]) => command.startsWith("tsx ")); + expect(directTsx).toEqual([]); + const bareTsxTargets = Object.entries(packageJson.scripts).filter( + ([, command]) => /(^|&&\s*)tsx\s/.test(command) || command.includes("npx tsx"), + ); + expect(bareTsxTargets).toEqual([]); + expect(packageJson.scripts["check:production-readiness:ci"]).toContain("scripts/run-tsx.mjs"); + expect(packageJson.scripts["check:supabase-project"]).toContain("scripts/run-tsx.mjs"); + }); + + it("keeps the Next server-only marker while stubbing it only for standalone runners", () => { + expect(readFileSync(new URL("../src/lib/env.ts", import.meta.url), "utf8")).toMatch(/^import ["']server-only["'];/); + expect(readFileSync(new URL("../scripts/register-server-only.mjs", import.meta.url), "utf8")).toContain( + 'specifier === "server-only"', + ); + }); + + it("bounds Vitest workers and scopes stale-process cleanup to this checkout", () => { + const runner = readFileSync(new URL("../scripts/run-vitest.mjs", import.meta.url), "utf8"); + const config = readFileSync(new URL("../vitest.config.mts", import.meta.url), "utf8"); + expect(runner).toContain("const vitestNeedle = vitestBin.toLowerCase()"); + expect(runner).not.toContain("repoNeedle"); + expect(config).toContain("maxWorkers: 2"); + expect(config).toContain("testTimeout: 30_000"); + }); +}); diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 7896fc43f..ca799adac 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -205,7 +205,22 @@ type MockDemoApiOptions = { onAnswerRequest?: (query: string) => void; }; +async function blockExternalRequests(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + !["localhost", "127.0.0.1", "::1"].includes(url.hostname) + ) { + await route.abort("blockedbyclient"); + return; + } + await route.fallback(); + }); +} + async function mockDemoApi(page: Page, options: MockDemoApiOptions = {}) { + await blockExternalRequests(page); await mockLocalProjectIdentity(page); await page.route("**/api/setup-status**", async (route) => { await route.fulfill({ @@ -2017,7 +2032,7 @@ test.describe("Clinical KB UI smoke coverage", () => { const globalSearchInput = page.getByTestId("global-search-input"); await expect(page.getByRole("button", { name: "Mode Medication" })).toBeVisible({ timeout: 30_000 }); - await expect(globalSearchInput).toHaveAttribute("placeholder", "Search medications..."); + await expect(globalSearchInput).toHaveAttribute("placeholder", "Search medication dosing or safety..."); await expect(globalSearchInput).toHaveValue("acamprosate renal dose"); const acamprosateResult = page.getByTestId("medication-result-acamprosate-desktop"); @@ -2025,6 +2040,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await acamprosateResult.click(); await expect(page).toHaveURL(/\/medications\/acamprosate$/, { timeout: 30_000 }); await expectSingleMedicationPage(page); + await expect(page.getByRole("link", { name: "Back to medication search" })).toBeVisible(); await gotoCriticalApp(page, "/mockups/medication-prescribing"); await expect(page).toHaveURL(/\/medications\/acamprosate$/); @@ -2056,9 +2072,17 @@ test.describe("Clinical KB UI smoke coverage", () => { expect(actionOverflow.found).toBe(true); expect(actionOverflow.overflows).toBe(false); expect(actionOverflow.textOverflow).not.toBe("ellipsis"); + + await acamprosateCard.click(); + await expect(page).toHaveURL(/\/medications\/acamprosate$/, { timeout: 30_000 }); + const backLink = page.getByRole("link", { name: "Back", exact: true }); + await expect(backLink).toBeVisible(); + await expectMinTouchTarget(backLink); + await backLink.click(); + await expect(page).toHaveURL(/[?&]mode=prescribing/); }); - test("document search mode lists matching documents and scope actions", async ({ page }) => { + test("document search mode lists matching documents and scope actions @critical", async ({ page }) => { await page.setViewportSize({ width: 390, height: 820 }); await mockDemoApi(page); await gotoCriticalApp(page, "/"); @@ -2385,7 +2409,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expectNoPageHorizontalOverflow(page); }); - test("document viewer failed preview exposes retry recovery", async ({ page }) => { + test("document viewer failed preview exposes retry recovery @critical", async ({ page }) => { await page.route("**/api/setup-status**", async (route) => { await route.fulfill({ json: { demoMode: true, checks: readySetupChecks } }); }); diff --git a/tests/ui-tools.spec.ts b/tests/ui-tools.spec.ts index d1e1d3e86..526e3c4bc 100644 --- a/tests/ui-tools.spec.ts +++ b/tests/ui-tools.spec.ts @@ -1,7 +1,9 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Locator, type Page } from "playwright/test"; import type { Route } from "playwright-core"; import { acuteConfusionPresentationWorkflow } from "../src/lib/differentials"; import { demoAnswer, demoDocuments } from "../src/lib/demo-data"; +import { loadMedicationSnapshot } from "../src/lib/medication-snapshot"; +import { medicationToSearchResult, rankMedicationRecords } from "../src/lib/medications"; const readySetupChecks = [ { id: "env", label: ".env.local configured", status: "ready", detail: "Test environment ready." }, @@ -31,7 +33,22 @@ async function fulfillAnswerResponse(route: Route, payload: unknown) { await route.fulfill({ json: payload }); } +async function blockExternalRequests(page: Page) { + await page.route("**/*", async (route) => { + const url = new URL(route.request().url()); + if ( + (url.protocol === "http:" || url.protocol === "https:") && + !["localhost", "127.0.0.1", "::1"].includes(url.hostname) + ) { + await route.abort("blockedbyclient"); + return; + } + await route.fallback(); + }); +} + async function mockAnswerDashboardApi(page: Page) { + await blockExternalRequests(page); await page.route(/\/api\/local-project-id$/, async (route) => { await route.fulfill({ json: { @@ -69,6 +86,27 @@ async function mockAnswerDashboardApi(page: Page) { }, }); }); + await page.route(/\/api\/medications(?:\?.*)?$/, async (route) => { + const url = new URL(route.request().url()); + const query = url.searchParams.get("q")?.trim() || undefined; + const limit = Number(url.searchParams.get("limit") ?? "50"); + const records = loadMedicationSnapshot(); + const matches = query ? rankMedicationRecords(records, query, limit) : undefined; + await route.fulfill({ + json: { + records, + matches: matches?.map((match) => ({ + medication: match.medication, + result: medicationToSearchResult(match), + score: match.score, + reasons: match.reasons, + })), + total: records.length, + governance: {}, + demoMode: true, + }, + }); + }); await page.route(/\/api\/answer(?:\/stream)?(?:\?.*)?$/, async (route) => { const body = route.request().postDataJSON() as { query?: string; documentId?: string; documentIds?: string[] }; const answer = demoAnswer(body.query ?? "What monitoring is required?", body.documentId, body.documentIds); @@ -158,6 +196,14 @@ async function expectNoPageHorizontalOverflow(page: Page) { expect(overflow).toBeLessThanOrEqual(2); } +async function expectMinTouchTarget(locator: Locator, minSize = 44) { + const box = await locator.boundingBox(); + expect(box).not.toBeNull(); + const measurementTolerance = 2; + expect(box!.height + measurementTolerance).toBeGreaterThanOrEqual(minSize); + expect(box!.width + measurementTolerance).toBeGreaterThanOrEqual(minSize); +} + function visibleGlobalSearchInput(page: Page) { return page.locator('[data-testid="global-search-input"]:visible'); } @@ -1220,6 +1266,7 @@ test.describe("Clinical KB tools launcher", () => { await page.setViewportSize({ width: 390, height: 844 }); await gotoLauncher(page, "/differentials/presentations"); + await expect(page.getByTestId("differential-presentation-page")).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("link", { name: "Back to differentials" })).toBeVisible(); await expect(page.getByRole("link", { name: "Compare", exact: true })).toHaveAttribute("aria-current", "page"); await expect(page.getByRole("heading", { level: 1, name: workflow.title })).toBeVisible(); @@ -1397,4 +1444,81 @@ test.describe("Responsive layout guards", () => { const balance = Math.abs((tablet?.topGap ?? 0) - (tablet?.bottomGap ?? 0)); expect(balance).toBeLessThan(Math.max(tablet?.topGap ?? 0, tablet?.bottomGap ?? 0) * 1.45); }); + + test("prescribing mobile shortcuts and checks are distinct, actionable, and scrollable", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 760 }); + await mockAnswerDashboardApi(page); + await gotoLauncher(page, "/?mode=prescribing"); + + const home = page.getByTestId("medication-home"); + await expect(home).toBeVisible(); + await expect(home).toContainText("Check renal dosing and contraindications."); + await expect(home).toContainText("Review opioid-use precautions before prescribing."); + await expect(home).toContainText("Check maximum dose and titration guidance."); + + const checksRegion = home.getByRole("region", { name: "Medication checks" }); + const checkButtons = checksRegion.getByRole("button"); + await expect(checkButtons).toHaveCount(4); + for (const button of await checkButtons.all()) await expectMinTouchTarget(button); + + const rowMetrics = await checksRegion.locator(".answer-suggestion-row-scroll").evaluate((row) => { + const style = getComputedStyle(row); + return { + overflows: row.scrollWidth > row.clientWidth + 1, + maskImage: style.maskImage || style.webkitMaskImage, + }; + }); + expect(rowMetrics.overflows).toBe(true); + expect(rowMetrics.maskImage).not.toBe("none"); + await expectNoPageHorizontalOverflow(page); + + const capabilitySearches = [ + ["Dose", "medication dose adjustment"], + ["Safety", "medication contraindications and cautions"], + ["Monitoring", "medication baseline and follow-up monitoring"], + ["Access", "medication PBS access and brand availability"], + ] as const; + + for (const [label, query] of capabilitySearches) { + await gotoLauncher(page, "/?mode=prescribing"); + await page.getByTestId("medication-home").getByRole("button", { name: label, exact: true }).click(); + await expect(visibleGlobalSearchInput(page).first()).toHaveValue(query); + await expect(page.getByTestId("medication-home")).toHaveCount(0); + } + + await gotoLauncher(page, "/?mode=prescribing&q=acamprosate%20renal%20dose&run=1"); + const resultCard = page.getByTestId("medication-result-acamprosate-phone"); + const bottomDock = page.locator("form.answer-footer-search-dock"); + await expect(resultCard).toBeVisible(); + await expect(bottomDock).toBeVisible(); + await page.locator("main#main-content").evaluate((main) => main.scrollTo({ top: main.scrollHeight })); + const resultBox = await resultCard.boundingBox(); + const dockBox = await bottomDock.boundingBox(); + expect(resultBox).not.toBeNull(); + expect(dockBox).not.toBeNull(); + expect(resultBox!.y + resultBox!.height).toBeLessThanOrEqual(dockBox!.y + 2); + }); + + test("differentials recent work remains touch-sized inside its mobile scroll row", async ({ page }) => { + await page.setViewportSize({ width: 320, height: 760 }); + await mockAnswerDashboardApi(page); + await gotoLauncher(page, "/?mode=differentials"); + + const recentWork = page.getByTestId("differentials-home-template").getByRole("region", { name: "Recent work" }); + await expect(recentWork).toBeVisible(); + const recentButtons = recentWork.locator(".answer-suggestion-row-scroll").getByRole("button"); + expect(await recentButtons.count()).toBeGreaterThan(1); + for (const button of await recentButtons.all()) await expectMinTouchTarget(button); + + const rowMetrics = await recentWork.locator(".answer-suggestion-row-scroll").evaluate((row) => { + const style = getComputedStyle(row); + return { + overflows: row.scrollWidth > row.clientWidth + 1, + maskImage: style.maskImage || style.webkitMaskImage, + }; + }); + expect(rowMetrics.overflows).toBe(true); + expect(rowMetrics.maskImage).not.toBe("none"); + await expectNoPageHorizontalOverflow(page); + }); }); diff --git a/tests/worker-safe-logging.test.ts b/tests/worker-safe-logging.test.ts index 939db9787..b11e70f34 100644 --- a/tests/worker-safe-logging.test.ts +++ b/tests/worker-safe-logging.test.ts @@ -3,6 +3,17 @@ import { describe, expect, it } from "vitest"; const workerMain = readFileSync(new URL("../worker/main.ts", import.meta.url), "utf8"); const workerIndex = readFileSync(new URL("../worker/index.ts", import.meta.url), "utf8"); +const answerStreamRoute = readFileSync(new URL("../src/app/api/answer/stream/route.ts", import.meta.url), "utf8"); +const httpLib = readFileSync(new URL("../src/lib/http.ts", import.meta.url), "utf8"); +const seedFallbackFiles = [ + "../src/lib/registry-seed.ts", + "../src/lib/medication-seed.ts", + "../src/lib/differential-seed.ts", + "../src/app/api/registry/records/[slug]/route.ts", + "../src/app/api/medications/[slug]/route.ts", + "../src/app/api/differentials/[slug]/route.ts", + "../src/app/api/differentials/presentations/[slug]/route.ts", +].map((file) => readFileSync(new URL(file, import.meta.url), "utf8")); describe("worker safe logging", () => { it("does not log raw ingestion job errors", () => { @@ -12,4 +23,23 @@ describe("worker safe logging", () => { it("sanitizes worker bootstrap fatal errors", () => { expect(workerIndex).toContain('console.error("Worker bootstrap failed", safeErrorLogDetails(error))'); }); + + it("sanitizes streaming answer route errors before logging", () => { + expect(answerStreamRoute).toContain('logger.error("Search stream failed", safeErrorLogDetails(error))'); + expect(answerStreamRoute).not.toContain("stack: error instanceof Error ? error.stack"); + }); + + it("sanitizes shared JSON API errors before logging", () => { + expect(httpLib).toContain("...safeErrorLogDetails(error)"); + expect(httpLib).not.toContain("causeMessage: details?.causeMessage"); + expect(httpLib).not.toContain("stack: error instanceof Error ? error.stack"); + }); + + it("sanitizes registry seed fallback errors before logging", () => { + for (const file of seedFallbackFiles) { + expect(file).toContain("safeErrorLogDetails(error)"); + expect(file).not.toContain("auto-seed failed for owner"); + expect(file).not.toMatch(/console\.error\([^)]*,\s*error\)/); + } + }); }); diff --git a/vitest.config.mts b/vitest.config.mts index 62d8f5f80..58d7ee1c4 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,6 +4,7 @@ const config = { // body. Give those transforms headroom on slower worktree filesystems while // retaining a finite timeout that still catches genuine hangs. testTimeout: 30_000, + maxWorkers: 2, coverage: { provider: "v8", reporter: ["text", "lcov"], @@ -30,6 +31,7 @@ const config = { resolve: { alias: { "@": new URL("./src", import.meta.url).pathname, + "server-only": new URL("./tests/stubs/server-only.ts", import.meta.url).pathname, }, }, };