diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index f0ca275cc..2508df9c2 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -809,6 +809,9 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | `cursor/sidebar-six-item-land-cfa0` / PR #1174 content-proof `d97c11e6` | `d97c11e6` | #066 land/proof of six-item sidebar from `cd54e68f` onto main | PROVE — feature content landed; focused 20/20; verify:pr-local PASS; ensure spot-check PASS; verify:ui 267/267. Not merged to main. | vitest favourites+therapy; verify:pr-local; ensure+computerUse spot-check; verify:ui | | 2026-07-24 | PR #1175 / `cursor/ledger-009-010-032-041-063-519b` | f3986abc39468e077643611ec2d95e374c2e901f | PR review + CI fix (ledger docs #009/#010/#032/#041/#063) | FINDINGS FIXED. P1: Static PR checks failed on Prettier (5 docs). P1: leftover `PR_POLICY_BODY.md` from merged #1134 caused Sync PR policy body to overwrite this docs PR description with search-performance text ??? deleted the stale template. No P0. Product scope remains docs-only; gated brief follow-ups (wire coming-soon, Current Clinical Work storage, Factsheets second mode, governance ranking) correctly not implemented. Residual: human approving review once CI green. | Local: prettier --check on touched docs; ledger open/resolved/queue integrity for five IDs; no client fetch(`/api/jobs`); `verify:cheap` earlier on tip 95d68c6b. No OpenAI/Supabase writes. | | 2026-07-25 | cursor/search-performance-review-4ee9 (PR #1134) | 692834a86e612cc8b311dc6895e007f182f5c5b8 | Open-PR maintenance: superseded docs-link thread and clean main sync | Before: branch was behind current main with one outdated docs-link thread; its product tree already matched main. After: merged current main cleanly and verified the route-group-aware docs-link fix now covers legacy route references. RAG impact: no retrieval behaviour change — history sync and docs tooling verification only. | `node scripts/check-docs-links.mjs` pass (1154 references); clean merge-tree; no live RAG canary or provider-backed check run. | + +| 2026-07-24 | PR #1178 / `cursor/local-presence-054-7cf3` | `c1b462a0d2db74b4a9e2f7136c33bf075ab52c7e` | Local presence #054 + Tools canonical #007 + CI remediation | FINDINGS FIXED. P1 Prettier on presence files formatted. P1 Safety audit postcss<=8.5.17 cleared by bumping to ^8.5.18/$postcss (8.5.23). P1 stale PR_POLICY_BODY.md removed so Sync PR policy no longer overwrites this PR with unrelated search-performance text. #007 implemented as `/tools` canonical (PT-11); `/?mode=tools` alias retained. No P0. Residual: hosted #054 secrets remain operator-owned. | Focused Vitest 70/70; verify:cheap 374/3326; npm audit high clean; check:local-presence ok. No hosted secret/provider mutations. | +| 2026-07-24 | PR #1178 / `cursor/local-presence-054-7cf3` | `162f64cfdb475262d5f8534313397c3e895457d4` | Local presence #054 + Tools canonical #007 + CI remediation (supersedes prior tip review) | Same findings fixed on tip including PR_POLICY_BODY.md deletion commit. No new high-confidence defects. Residual: hosted #054. | verify:cheap green on prior tip; tip adds leftover PR_POLICY_BODY removal + this ledger row. | | 2026-07-24 | cursor/search-correctness-030-075-6273 (PR #1177) | 54ab9f8498751ef7e96815dd2496b8137f29dad7 | Review + follow-up hardening of #030/#075 search-correctness fixes | Findings fixed: (P2) one combo-titled source could still make multi-slot allHit true via substring alias hits — `expectedFileCoverage` now assigns each retrieved top-file to at most one expected slot; (P2) label pagination could loop forever on a stuck full-page API — fail-closed page budget added; (P2 process) stale `PR_POLICY_BODY.md` from search-performance leftover was overwriting this PR body via Sync PR policy body — corrected then deleted. No remaining high-confidence P0–P1 in product scope. Residual: human approving review; Unit coverage CI still finishing on later heads. RAG impact: no retrieval behaviour change — eval matching / label pagination only. | Focused Vitest 32/32; `verify:cheap` green; `verify:pr-local` green (lint/typecheck/3326 unit/build/client-bundle/offline RAG fixtures 36/36). No OpenAI/live Supabase/provider-backed canary. | | 2026-07-24 | cursor/search-correctness-030-075-6273 (PR #1177) | 96ba6152c1f8e5e0000000000000000000000000 | Supersedes prior #1177 review row with post-sync tip | Same product outcome as prior row; tip includes correct PR_POLICY_BODY sync + template deletion so Sync PR policy body cannot reintroduce the stale search-performance description. | `npm run check:branch-review-ledger` pass; no provider-backed checks run. | | 2026-07-24 | PR #1175 / `cursor/ledger-009-010-032-041-063-519b` | f3986abc39468e077643611ec2d95e374c2e901f | PR review + CI fix (ledger docs #009/#010/#032/#041/#063) | FINDINGS FIXED. P1: Static PR checks failed on Prettier (5 docs). P1: leftover `PR_POLICY_BODY.md` from merged #1134 caused Sync PR policy body to overwrite this docs PR description with search-performance text — deleted the stale template. No P0. Product scope remains docs-only; gated brief follow-ups (wire coming-soon, Current Clinical Work storage, Factsheets second mode, governance ranking) correctly not implemented. Residual: human approving review once CI green. | Local: prettier --check on touched docs; ledger open/resolved/queue integrity for five IDs; no client fetch(`/api/jobs`); `verify:cheap` earlier on tip 95d68c6b. No OpenAI/Supabase writes. | @@ -822,6 +825,7 @@ This file is append-only. Never rewrite or delete an existing review record; app | 2026-07-24 | open-PR conflict sync (22 PRs) | multi-head | Conflict resolution pass | Before: all 22 open PRs behind/dirty vs main (several CONFLICTING/DIRTY). After: merged origin/main into every open head; all pushes OK; merge-tree classified 22/22 clean. | merge origin/main per branch; check:branch-review-ledger on #1172; no provider-backed checks run | | 2026-07-24 | cursor/pr-queue-hygiene-72ec | pending-push | PR queue hygiene | Add pr-branch-sync workflow + sync:pr-branches helper; bump postcss to clear npm audit high; document anti-churn guidance in AGENTS/process-hardening/pr-babysit/run-pr. | check:github-actions PASS; docs:check-scripts/index PASS; vitest sync-open-pr-branches 3/3; npm audit high clean; no provider-backed checks run | | 2026-07-24 | codex/apply-phone-layout-to-all-home-pages (PR #1124) | pending | Babysit: ledger dedupe + merge readiness | Before: Static PR failed on exact duplicate ledger rows after main sync. After: removed duplicate rows; squash auto-merge armed. | check:branch-review-ledger PASS; no provider-backed checks run | +| 2026-07-25 | cursor/local-presence-054-7cf3 (PR #1178) | b438cd872286c831c6d9c8db49b017745f98abcc | Run PR sweep: CI fix + threads + drift | Before: GitHub reported DIRTY; Static PR checks found three stale `npm run check:local-presence` references because the implemented script was not registered; Production UI had one focus-restoration failure after 266 passes; 0 unresolved threads. After: merged current `origin/main` cleanly and registered the missing local script, so all 348 docs script references resolve. | `node scripts/check-docs-script-refs.mjs` pass; Prettier and `git diff --check` pass; focused Vitest/UI rerun deferred while another worktree owns the heavyweight lock; environment-reading presence mode and provider-backed checks not run. | | 2026-07-25 | cursor/sidebar-six-item-land-cfa0 (PR #1174) | c44a5c536e43f300feb1b88892d0b2a8ca2c4feb | Run PR sweep: CI fix + threads + drift | Before: GitHub reported DIRTY and Static PR checks failed formatting `docs/outstanding-issues.md`; 0 unresolved threads. After: merged current `origin/main` cleanly and formatted the failing document. | Prettier check pass; `git diff --check` pass; no provider-backed checks run. | | 2026-07-25 | cursor/pdf-crop-malformed-repro-9b3e (PR #1176) | c31543f5b5862dbe6911079029f28500a6af2a59 | Run PR sweep: CI fix + threads + drift | Before: GitHub reported DIRTY and Static PR checks failed formatting `docs/outstanding-issues.md` and `tests/pdf-extractor.test.ts`; 0 unresolved threads. After: merged current `origin/main` cleanly and formatted both failing files. | Prettier check pass; `git diff --check` pass; no provider-backed checks run. | | 2026-07-25 | codex/search-results-filters-20260725 | 88131e7267efd33059766dec80355a9246fbb2bf | Search result filters and document Sources merge-readiness review | APPROVE. No P0-P2 finding after current-main sync. Documents open Sources as an on-screen filtering surface with source-type controls; the shared results ribbon is applied across search pages. Highest residual risk: unusual real-content combinations may alter perceived density, while responsive, forced-colors, focus, and overflow paths are browser-covered. RAG impact: no retrieval behaviour change - UI controls and source browsing only. | `npm run verify:ui` pass 268/268; `npm run verify:cheap` pass (377 files, 3340 passed, 1 skipped); post-sync `npm run verify:pr-local` pass (378 files, 3349 passed, 1 skipped, production build, bundle-secret scan, offline RAG fixtures); `npm run check:production-readiness` pass with OPENAI_SAFETY_IDENTIFIER_SECRET warning; no live/provider-backed app checks run. | diff --git a/docs/codebase-index.md b/docs/codebase-index.md index d016e669f..e6ac8cce0 100644 --- a/docs/codebase-index.md +++ b/docs/codebase-index.md @@ -303,15 +303,8 @@ sequenceDiagram One shared composer (`master-search-header.tsx`) serves every mode. Placement: -<<<<<<< HEAD - -- **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/formulation`, `/tools`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. (`/applications` is a redirect to `/tools`, not a composer surface.) -- **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer, therapy-compass CSS workspace, differentials presentation workflow. - \======= - **Mode homes** (`/services`, `/forms`, `/favourites`, `/differentials`, `/dsm`, `/specifiers`, `/formulation`, `/factsheets`, `/therapy-compass`, `/tools`, and dashboard homes): inline in the hero via the `mode-home-composer-slot` portal, on phone and tablet+ alike. (`/applications` is a redirect to `/tools`, not a composer surface.) - -> > > > > > > origin/main - +- **Information (detail) pages**: catalogue/record routes under each mode (`/services/[slug]`, `/forms/[slug]`, `/medications/[slug]`, `/specifiers/[slug]`, `/formulation/[slug]`, `/factsheets/[slug]`, `/dsm/diagnoses/[slug]`, …). Route detection: `src/lib/information-pages.ts` (`isInformationPage`). Shared outer chrome: `src/components/information-page-shell.tsx` (`InformationPageShell`, breadcrumbs, optional footer). Specifier/formulation mode shells re-export that primitive. Intentional opt-outs: document viewer, therapy-compass CSS workspace, differentials presentation workflow. - **Result and detail views**: fixed bottom dock on phone (compact variant on submitted searches), sticky top from `sm` up. - **Results routing**: standalone routes own their submitted searches via `?q=…&run=1` (`/services` → `ServicesNavigatorPage`, `/forms` → `FormsSearchResultsPage`, `/differentials` → `DifferentialsHome` results view, `/formulation` → local mechanism results, `/favourites` filters the command library in place). Answer, Documents, and Prescribing submitted searches render inside `ClinicalDashboard` — intentional, since they need retrieval/answer state. `/?mode=favourites` redirects to `/favourites`; `/?mode=differentials` redirects to `/differentials`; `/?mode=dsm` redirects to `/dsm`; `/?mode=specifiers` redirects to `/specifiers`; `/?mode=formulation` redirects to `/formulation`. - **Intentionally composer-free routes**: `/differentials/presentations/*` (comparison workflow owns its chrome), `/documents/[id]` viewer (has its own in-document ask composer), `/documents/source/*` (document flow owns mobile chrome). Do not re-flag these in search-consistency audits. diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md index 31d9ec96a..ff7618411 100644 --- a/docs/operator-backlog.md +++ b/docs/operator-backlog.md @@ -44,6 +44,7 @@ dashboard/CLI action, never committed. | Secret / config | Status | Where | Notes | | ------------------------------------------ | ---------- | ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Local fillable HMAC/probe secrets | ✅ local | `.env.local` (gitignored) | Use `npm run check:local-presence` / `--fill` for distinct local `OPENAI_SAFETY_IDENTIFIER_SECRET`, `RAG_QUERY_HASH_SECRET`, and `HEALTH_DEEP_PROBE_SECRET`. Never commit values. Hosted stores are separate. | | `RAG_QUERY_HASH_SECRET` (prod) | 🔎 verify | Railway runtime secret | GitHub repo secret present since 2026-07-10 (CI green); confirm the SAME value is set in Railway runtime. PIA-2 fail-closed guard requires it at boot (min 16 chars) | | `HEALTH_DEEP_PROBE_SECRET` (prod + GitHub) | ⚠️ partial | Railway runtime + GitHub repo secret | Railway production was set and the authorized deep probe returned healthy on 2026-07-19. GitHub remains pending: set the same value as a repo secret, set `PROD_HEALTH_URL`, then enable the ops-digest schedule. | | `SUPABASE_SERVICE_ROLE_KEY` (per env) | ⏳ pending | Railway runtime secret | accepts the `sb_secret_…` key | diff --git a/docs/outstanding-issues.md b/docs/outstanding-issues.md index 297064b74..1d424f49e 100644 --- a/docs/outstanding-issues.md +++ b/docs/outstanding-issues.md @@ -107,13 +107,12 @@ removed after current-main verification; it is not missing recommended work. | #001 | P2 | task | Semantic reranking still gated off | `RAG_SEMANTIC_RERANK_ENABLED=false` from PR #901. Do not enable until the provider-backed 36/36 retrieval-quality gate **and** an ambiguity-focused canary are explicitly approved and recorded. | `docs/process-hardening.md` (Semantic reranking rollout debt); PR #901 | 2026-07-21 | | #069 | P3 | task | Live-profile table-facts plpgsql+EXECUTE latency | Migration `20260724120000_table_facts_plpgsql_execute.sql` plus P3 follow-ups (`20260724130000_*`, `20260724130100_*`) are in PR #1133. Hosted apply is blocked in this environment (no `SUPABASE_DB_URL`; Supabase MCP `needsAuth`). A live `profile:retrieval --rpc match_document_table_facts_text --analyze` attempt returned `Unregistered API key` against the injected service-role secret. **Next:** operator applies the three pending migrations on the live target project, then re-runs approval-gated `profile:retrieval` / `explain_retrieval_rpc` and confirms ~70ms-class plans with no ranking change. **Stop** without mutating ranking. | session 2026-07-24 Database interface audit; PR #1133 | 2026-07-24 | | #053 | P1 | task | Execute cross-border privacy/legal package | Execute OpenAI and Railway DPAs; decide ZDR and Australian data residency; obtain prompt-cache behavior in writing; review subprocessors; obtain APP 8 and APP 5/1 counsel sign-off. Do not represent the release as privacy-approved or alter final public privacy wording before sign-off. | `docs/openai-cross-border-basis.md`; `docs/privacy-impact-assessment.md` | 2026-07-24 | -| #054 | P2 | task | Reconcile local and hosted secrets/config | Presence-check safety-identifier, query-hash, deep-probe, Supabase service-role, OpenAI, project-identity, and schedule settings; set only confirmed gaps with distinct per-environment values. Never record secret values. Provider reads/writes require approval. | `.env.example`; production-readiness warning; `docs/operator-backlog.md` | 2026-07-24 | +| #054 | P2 | task | Reconcile local and hosted secrets/config | **Local (2026-07-24):** `npm run check:local-presence` (+ optional `--fill`) presence-checks safety-identifier / query-hash / deep-probe (names + length buckets only), fails closed on ambiguous project identity, and fills only those three local HMAC/probe gaps into gitignored `.env.local`. Production-readiness now warns on the same local gaps. Provider keys and hosted Railway/GitHub stores were not touched. **Next (hosted/operator):** presence-check Railway runtime + GitHub repo secrets for query-hash, deep-probe (+ `PROD_HEALTH_URL` / ops-digest), service-role, OpenAI, and schedule/Vault settings; set only confirmed gaps with distinct per-environment values. Never record secret values. Provider reads/writes require approval. | `.env.example`; `scripts/check-local-presence.mjs`; production-readiness; `docs/operator-backlog.md` | 2026-07-24 | | #055 | P2 | task | Run one exact-SHA full release and PR gate | Before the next full-confidence release/handoff, record the candidate/PR SHA and run the local/provider release gates, Firefox/WebKit, required hosted CI, and actionable GitHub review-thread closure once. Stop at the first actionable failure and rerun only the repaired smallest gate. | `docs/launch-operator-runbook.md`; `docs/codex-review-protocol.md` | 2026-07-24 | | #056 | P2 | task | Provision isolated staging environment | After explicit cost/ownership approval, provision `Clinical KB Staging` Supabase and Railway tiers with distinct secrets and synthetic/non-clinical data. Verify identity, schema, indexing, health, and the production-data boundary. | `docs/staging-setup.md`; `docs/operator-backlog.md` | 2026-07-24 | | #057 | P2 | task | Complete staging soak and rollback rehearsal | After #056, run the documented soak and rollback against an exact candidate; retain latency/error/rollback evidence. Stop on unsafe data, identity mismatch, or an unowned rollback decision. | `docs/launch-operator-runbook.md`; `docs/capacity-review.md` | 2026-07-24 | | #058 | P2 | task | Verify production content before any seed write | Against `the live target project`, verify registry, differentials, and medications surfaces are non-empty before writing. Seed only confirmed gaps idempotently with approved owner/project identity and confirmation flags. | `docs/launch-operator-runbook.md`; `docs/operator-backlog.md` | 2026-07-24 | | #005 | P3 | rec | `finalScore` saturates at clamp ceiling | Base + ~40 stacked boosts routinely exceed 1.0, so strong matches tie at 1.0 and order by an arbitrary `document_id` tiebreak. If ranking is ever revisited, break ties by the **pre-clamp** score rather than raising the `[0,1]` ceiling (downstream gates assume `[0,1]`). Ordering already sorts by the unbounded pre-clamp `rankScore` (`clinical-search.ts:1735,1927,1950-1955`), so the clamp confines only the reported confidence value, not result order. Not a defect on the current golden set; any change here is a protected RAG surface (canary required). | `docs/rag-hybrid-findings-and-todo.md` P1 item 4; `src/lib/clinical-search.ts:1735` | 2026-07-21 | -| #007 | P3 | rec | `/tools` vs `/?mode=tools` parallel Tools entry points | `/tools` (standalone `ApplicationsLauncherPage`) has no inbound in-app link; the sidebar Tools item uses `/?mode=tools`. Decide the canonical entry point and wire nav consistently, or drop the standalone `/tools` page + `/applications` redirect. Currently allowlisted in `tests/route-reachability.test.ts`. | `src/app/tools/page.tsx`; `src/app/applications/route.ts` | 2026-07-21 | | #011 | P3 | task | Auth DB-connection allocation is operator-only | Supabase Auth (GoTrue) is capped at ~10 absolute DB connections (Supabase perf advisor). Switch to **percentage-based** allocation in the Supabase **dashboard** before the first compute scale-up ΓÇö **not settable via SQL/MCP** (operator-owned). Verify via a staging soak + an approval-gated read-only advisor re-check. | `docs/auth-connection-cap-runbook.md`; `docs/process-hardening.md` (Known follow-up debts) | 2026-07-21 | | #012 | P3 | rec | Slim the lazy cross-mode differentials chunk | `cross-mode-differentials.ts` is dynamically imported (correctly code-split **out** of the initial/dashboard bundle ΓÇö verified), but it pulls the full ~860 KB differentials snapshot (~125 KB gzip lazy chunk) just to build a tiny `{slug,title,clinicalHinge}` + presentations + aliases catalog. A precomputed lightweight index (generator + drift check, like the `specifiers-content` split / medications `fields=index`) would cut that lazy chunk ~5ΓÇô10├ù. Not a bundle leak ΓÇö an M-effort slim. | `src/lib/cross-mode-differentials.ts`; `src/components/clinical-dashboard/cross-mode-links.tsx:150`; session 2026-07-21 (build:analyze) | 2026-07-21 | | #013 | P3 | rec | Route-chunk + mockup catalogue JSON weight | `build:analyze`: `/specifiers` ships `specifiers-search-index.json` (~180 KB parsed), `/forms` ships `forms-catalog.json` (~132 KB), `/formulation` ships `formulation-content.json` (~52 KB, client-side local search ΓÇö needs index/full split or a search endpoint, architectural). All route-scoped (not initial bundle). Also `*-mockups.tsx` (~100 KB across chunks) build though `/mockups` 404s in prod ΓÇö exclude from the prod artifact. | session 2026-07-21 (build:analyze) | 2026-07-21 | @@ -146,6 +145,7 @@ Move resolved rows here with the resolution date and a one-line outcome. Keep th | ID | Type | Summary | Outcome | Resolved | | ---- | ----- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| #007 | rec | `/tools` vs `/?mode=tools` parallel Tools entry points | Resolved as `/tools` canonical (PT-11 already documented on `/applications` redirect). Sidebar, appModeHomeHref, universal-search, prefetch, sitemap, and reachability now use `/tools`; `/?mode=tools` remains a dashboard-mode alias. Reachability allowlist entry removed. | 2026-07-24 | | #030 | issue | Wide-tier alias lets one doc satisfy both comparison slots | Fixed on `cursor/search-correctness-030-075-6273`: removed dual-listed Admission-to-Discharge titles from AdmissionCommunityPts so one retrieved source cannot make allHit true for both comparison slots; fail-closed contracts in `tests/eval-document-matching.test.ts`. RAG impact: no retrieval behaviour change ΓÇö eval matching only. | 2026-07-24 | | #075 | issue | Search-scope label enumeration can truncate after 1,000 rows | Fixed on `cursor/search-correctness-030-075-6273`: `loadScopeLabels` pages document_labels with deterministic order/batching past the Supabase 1k cap; multi-page >1000 contracts in `tests/search-scope.test.ts`. Isolated from mixed PR #1132. RAG impact: no retrieval behaviour change ΓÇö label pagination only. | 2026-07-24 | | #009 | rec | Confirm `/api/jobs` is intentionally server/ops-only | Kept as deliberate administrator/ops listing: no client `fetch("/api/jobs")` (UI uses `/api/ingestion/jobs`); documented in `docs/api-jobs-ops-surface.md` plus wiring/codebase-index/site-map notes. Not abandoned ΓÇö do not remove without updating API contract tests. | 2026-07-24 | diff --git a/docs/production-readiness-checklist.md b/docs/production-readiness-checklist.md index c802c15bb..4dbd05c07 100644 --- a/docs/production-readiness-checklist.md +++ b/docs/production-readiness-checklist.md @@ -26,6 +26,9 @@ Last reviewed: 2026-07-10. Applies to any feature branch or release candidate. - [x] Added one-command production preflight: - `npm run check:production-readiness` - runs env validation, Supabase target checks, lockfile/env-file presence checks, and placeholder checks. +- [x] Added local/dev secret presence helper: + - `npm run check:local-presence` (optional `--fill`) + - names + length buckets only; fills only safety-identifier / query-hash / deep-probe into gitignored `.env.local`; never prints values or invents provider credentials. - [x] Added deployment startup readiness gate: - `npm run check:deployment-readiness` - verifies `next start` boot behavior and local project identity guard on a managed local port. diff --git a/docs/site-map.md b/docs/site-map.md index 5219ce63d..48e5ad878 100644 --- a/docs/site-map.md +++ b/docs/site-map.md @@ -53,7 +53,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` - `/specifiers` - Specifiers mode. Search kind: `specifiers`. Query example: `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1`. - `/formulation` - Formulation mode. Search kind: `formulation`. Query example: `/formulation?q=I+keep+going+over+it&focus=1&run=1`. - `/?mode=prescribing` - Medication mode. Search kind: `documents`. Query example: `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1`. -- `/?mode=tools` - Tools mode. Search kind: `tools`. Query example: `/?mode=tools&q=medications&focus=1&run=1`. +- `/tools` - Tools mode. Search kind: `tools`. Query example: `/tools?q=medications&focus=1&run=1`. - `/therapy-compass` - Therapy mode. Search kind: `tools`. Query example: `/therapy-compass?q=behavioural+activation&focus=1&run=1`. - `/factsheets` - Factsheets mode. Search kind: `tools`. Query example: `/factsheets/search?q=sertraline&focus=1&run=1`. @@ -71,7 +71,7 @@ This file is generated by `npm run sitemap:update`. Run `npm run sitemap:check` | Specifiers | `/specifiers` | `/specifiers?q=depressed+but+racing+thoughts&focus=1&run=1` | `/specifiers/[slug]`, `/specifiers/builder`, `/specifiers/compare`, and `/specifiers/map`. | | Formulation | `/formulation` | `/formulation?q=I+keep+going+over+it&focus=1&run=1` | `/formulation/[slug]`, `/formulation/builder`, `/formulation/compare`, and `/formulation/map`. | | Medication | `/?mode=prescribing` | `/?mode=prescribing&q=acamprosate+renal+dose&focus=1&run=1` | `/medications/[slug]`; `/medications` redirects to medication mode. | -| Tools | `/?mode=tools` | `/?mode=tools&q=medications&focus=1&run=1` | Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`). | +| Tools | `/tools` | `/tools?q=medications&focus=1&run=1` | Canonical Tools launcher at `/tools` (PT-11). `/?mode=tools` remains a dashboard-mode alias. | ## Documents flow index diff --git a/docs/wiring-conventions.md b/docs/wiring-conventions.md index 241919252..04da2e1d1 100644 --- a/docs/wiring-conventions.md +++ b/docs/wiring-conventions.md @@ -95,8 +95,9 @@ Intentional exceptions are documented, not silenced: ## Known wiring debts -Tracked in `docs/outstanding-issues.md` (`/issues`): the `/tools` vs `/?mode=tools` duplicate Tools -entry point remains open (`#007`). +Tracked in `docs/outstanding-issues.md` (`/issues`): the unused `document-flow-routes.ts` href +builders remain open. `#007` (`/tools` vs `/?mode=tools`) is resolved: `/tools` is the canonical entry +(PT-11); `/?mode=tools` remains a dashboard-mode alias. ### Closed wiring audits (2026-07-24) diff --git a/package.json b/package.json index 1f2d763cb..f44e49405 100644 --- a/package.json +++ b/package.json @@ -183,6 +183,7 @@ "skills": "node scripts/list-database-skills.mjs", "skills:sync": "node scripts/sync-skills.mjs", "check:skills": "node scripts/list-database-skills.mjs --check", + "check:local-presence": "node scripts/check-local-presence.mjs", "check:drift": "node scripts/run-tsx.mjs scripts/check-drift.ts", "check:migration-history": "node scripts/run-tsx.mjs scripts/check-migration-history-alignment.ts", "drift:manifest": "node scripts/run-tsx.mjs scripts/generate-drift-manifest.ts", diff --git a/scripts/check-local-presence.mjs b/scripts/check-local-presence.mjs new file mode 100644 index 000000000..9f847b37c --- /dev/null +++ b/scripts/check-local-presence.mjs @@ -0,0 +1,349 @@ +#!/usr/bin/env node +/** + * check-local-presence — local/dev secret and project-identity presence checks. + * + * Names + length buckets only. Never prints secret values. + * Optional `--fill` writes distinct local-only HMAC/probe secrets into `.env.local` + * for confirmed fillable gaps. Never invents provider credentials or touches + * hosted Railway/GitHub secret stores. + * + * npm run check:local-presence + * npm run check:local-presence -- --fill + */ +import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +/** Local-only secrets this tool may generate into `.env.local`. */ +export const FILLABLE_LOCAL_SECRETS = [ + { + name: "OPENAI_SAFETY_IDENTIFIER_SECRET", + minLength: 32, + bytes: 32, + reason: "pseudonymous OpenAI safety_identifier HMAC (local/dev distinct value)", + }, + { + name: "RAG_QUERY_HASH_SECRET", + minLength: 16, + bytes: 32, + reason: "PIA-2 query-hash HMAC (local/dev distinct value)", + }, + { + name: "HEALTH_DEEP_PROBE_SECRET", + minLength: 16, + bytes: 32, + reason: "authorized /api/health?deep=1 probe token (local/dev distinct value)", + }, +]; + +/** Report-only; never auto-filled (need real provider/project values). */ +export const REPORT_ONLY_KEYS = [ + "NEXT_PUBLIC_SUPABASE_URL", + "SUPABASE_PROJECT_REF", + "SUPABASE_PROJECT_NAME", + "SUPABASE_STAGING_PROJECT_REF", + "SUPABASE_STAGING_PROJECT_NAME", + "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY", + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", + "RAG_PROVIDER_MODE", +]; + +const EXPECTED_PROD_REF = "sjrfecxgysukkwxsowpy"; +const EXPECTED_PROD_NAME = "Clinical KB Database"; +const STALE_REF = "qjgitjyhxrwxsrydablr"; + +const ENV_FILES = [".env", ".env.local", ".env.development.local"]; + +/** Length bucket for presence reports — never the raw value. */ +export function lengthBucket(value) { + if (value == null || String(value).trim() === "") return "missing"; + const len = String(value).length; + if (len < 8) return "too-short(<8)"; + if (len < 16) return "short(<16)"; + if (len < 32) return "ok(16-31)"; + if (len < 64) return "ok(32-63)"; + return "ok(64+)"; +} + +export function isPresent(value) { + return Boolean(value && String(value).trim()); +} + +export function meetsMinLength(value, minLength) { + return isPresent(value) && String(value).trim().length >= minLength; +} + +/** Parse KEY=VALUE lines without shell expansion; strip simple quotes. */ +export function parseEnvFile(text) { + /** @type {Record} */ + const out = {}; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Z][A-Z0-9_]*$/.test(key)) continue; + let value = line.slice(eq + 1); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + out[key] = value; + } + return out; +} + +export function loadLocalEnv({ cwd = root, processEnv = process.env } = {}) { + const fromFiles = {}; + const filesPresent = []; + for (const name of ENV_FILES) { + const filePath = path.join(cwd, name); + if (!existsSync(filePath)) continue; + filesPresent.push(name); + Object.assign(fromFiles, parseEnvFile(readFileSync(filePath, "utf8"))); + } + const merged = { ...fromFiles }; + for (const key of [...FILLABLE_LOCAL_SECRETS.map((s) => s.name), ...REPORT_ONLY_KEYS]) { + const fromProc = processEnv[key]; + if (fromProc != null && String(fromProc).length > 0) { + merged[key] = fromProc; + } + } + return { merged, filesPresent, fromFiles }; +} + +export function extractUrlRef(url) { + if (!isPresent(url)) return null; + try { + const hostname = new URL(String(url).trim()).hostname.toLowerCase(); + const suffix = ".supabase.co"; + if (!hostname.endsWith(suffix)) return null; + const ref = hostname.slice(0, -suffix.length); + return /^[a-z0-9]{20}$/.test(ref) ? ref : null; + } catch { + return null; + } +} + +/** + * Project-identity classification for local presence. + * - unset: no Supabase URL → demo-mode local is fine + * - ready: URL + ref/name align with production or an explicit staging pair + * - ambiguous: partial/mismatched/stale identity → fail closed + */ +export function classifyProjectIdentity(env) { + const url = env.NEXT_PUBLIC_SUPABASE_URL; + const ref = env.SUPABASE_PROJECT_REF?.trim() || null; + const name = env.SUPABASE_PROJECT_NAME?.trim() || null; + const urlRef = extractUrlRef(url); + + if (!isPresent(url) && !ref && !name) { + return { + status: "unset", + message: "No Supabase project identity configured (local demo mode is OK).", + }; + } + + if (!isPresent(url)) { + return { + status: "ambiguous", + message: "SUPABASE_PROJECT_REF/NAME set without NEXT_PUBLIC_SUPABASE_URL.", + }; + } + + if (!urlRef) { + return { + status: "ambiguous", + message: "NEXT_PUBLIC_SUPABASE_URL is not a recognizable *.supabase.co project URL.", + }; + } + + if (urlRef === STALE_REF || ref === STALE_REF) { + return { + status: "ambiguous", + message: `Stale Supabase project ref ${STALE_REF} detected; refuse to continue.`, + }; + } + + if (urlRef === EXPECTED_PROD_REF) { + const problems = []; + if (ref && ref !== EXPECTED_PROD_REF) { + problems.push(`SUPABASE_PROJECT_REF (${ref}) does not match URL ref.`); + } + if (name && name !== EXPECTED_PROD_NAME) { + problems.push(`SUPABASE_PROJECT_NAME does not match expected "${EXPECTED_PROD_NAME}".`); + } + if (!ref || !name) { + problems.push("Production URL present but SUPABASE_PROJECT_REF and/or SUPABASE_PROJECT_NAME missing."); + } + if (problems.length) { + return { status: "ambiguous", message: problems.join(" ") }; + } + return { + status: "ready", + message: `Project identity points at ${EXPECTED_PROD_NAME} (${EXPECTED_PROD_REF}).`, + }; + } + + // Non-production URL: require explicit staging declaration pair matching the URL ref. + const stagingRef = env.SUPABASE_STAGING_PROJECT_REF?.trim() || null; + const stagingName = env.SUPABASE_STAGING_PROJECT_NAME?.trim() || null; + if (stagingRef === urlRef && stagingName && ref === urlRef && name === stagingName) { + return { + status: "ready", + message: `Project identity points at declared staging "${stagingName}" (${urlRef}).`, + }; + } + + return { + status: "ambiguous", + message: + "Supabase URL is neither the expected production project nor a fully declared staging identity. Stop rather than guessing.", + }; +} + +export function assessLocalPresence(env) { + const fillable = FILLABLE_LOCAL_SECRETS.map((spec) => { + const value = env[spec.name]; + const present = meetsMinLength(value, spec.minLength); + return { + name: spec.name, + present, + lengthBucket: lengthBucket(value), + minLength: spec.minLength, + fillable: true, + reason: spec.reason, + status: present ? "ok" : "gap", + }; + }); + + const reportOnly = REPORT_ONLY_KEYS.map((name) => { + const value = env[name]; + const present = isPresent(value); + return { + name, + present, + lengthBucket: lengthBucket(value), + fillable: false, + status: present ? "ok" : "report-only-gap", + }; + }); + + const identity = classifyProjectIdentity(env); + return { fillable, reportOnly, identity }; +} + +export function generateLocalSecret(bytes = 32) { + return randomBytes(bytes).toString("hex"); +} + +/** + * Merge fillable gaps into `.env.local` text. Skips keys that already meet + * minLength. For present-but-too-short keys, removes stale KEY=value lines + * before appending the regenerated assignment. Returns filled key names only. + */ +export function mergeFillIntoEnvLocal(existingText, gaps, { generate = generateLocalSecret } = {}) { + const existing = parseEnvFile(existingText || ""); + const filled = []; + const additions = []; + + for (const gap of gaps) { + if (meetsMinLength(existing[gap.name], gap.minLength)) continue; + const value = generate(gap.bytes); + if (!meetsMinLength(value, gap.minLength)) { + throw new Error(`Generated ${gap.name} did not meet minLength ${gap.minLength}`); + } + filled.push(gap.name); + additions.push(`# local/dev presence fill — ${gap.reason}`); + additions.push(`${gap.name}=${value}`); + } + + if (filled.length === 0) { + return { text: existingText || "", filled }; + } + + const filledSet = new Set(filled); + const stripped = (existingText || "") + .split(/\r?\n/) + .filter((rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) return true; + const eq = line.indexOf("="); + if (eq <= 0) return true; + const key = line.slice(0, eq).trim(); + return !filledSet.has(key); + }) + .join("\n") + .replace(/\s*$/, ""); + const next = `${stripped ? `${stripped}\n\n` : ""}# --- check:local-presence --fill (${new Date().toISOString().slice(0, 10)}) ---\n${additions.join("\n")}\n`; + return { text: next, filled }; +} + +function printPresenceReport({ filesPresent, assessment }) { + console.log("[Local presence]"); + console.log(`Env files: ${filesPresent.length ? filesPresent.join(", ") : "(none)"}`); + console.log(""); + console.log("Fillable local secrets (names + length buckets only):"); + for (const row of assessment.fillable) { + const mark = row.status === "ok" ? "ok" : "GAP"; + console.log(` - ${row.name}: ${mark} [${row.lengthBucket}] (min ${row.minLength})`); + } + console.log(""); + console.log("Report-only (never auto-filled):"); + for (const row of assessment.reportOnly) { + const mark = row.present ? "present" : "absent"; + console.log(` - ${row.name}: ${mark} [${row.lengthBucket}]`); + } + console.log(""); + console.log(`Project identity: ${assessment.identity.status} — ${assessment.identity.message}`); +} + +function main() { + const fill = process.argv.includes("--fill"); + const { merged, filesPresent } = loadLocalEnv(); + const assessment = assessLocalPresence(merged); + + printPresenceReport({ filesPresent, assessment }); + + if (assessment.identity.status === "ambiguous") { + console.error("\nSTOP: ambiguous project identity. Resolve SUPABASE_* identity before filling or continuing."); + process.exit(1); + } + + const gaps = FILLABLE_LOCAL_SECRETS.filter((spec) => { + const row = assessment.fillable.find((r) => r.name === spec.name); + return row && row.status === "gap"; + }); + + if (!fill) { + if (gaps.length) { + console.log( + `\n${gaps.length} fillable local gap(s). Re-run with --fill to write distinct local values into .env.local (gitignored).`, + ); + process.exitCode = 2; + } else { + console.log("\nNo fillable local gaps."); + } + return; + } + + if (gaps.length === 0) { + console.log("\n--fill: nothing to write; fillable local secrets already present."); + return; + } + + const envLocalPath = path.join(root, ".env.local"); + const existingText = existsSync(envLocalPath) ? readFileSync(envLocalPath, "utf8") : ""; + const { text, filled } = mergeFillIntoEnvLocal(existingText, gaps); + writeFileSync(envLocalPath, text, { encoding: "utf8", mode: 0o600 }); + console.log(`\n--fill: wrote ${filled.length} distinct local secret(s) into .env.local:`); + for (const name of filled) console.log(` - ${name}`); + console.log("Values were not printed. .env.local is gitignored — do not commit it."); +} + +const invokedDirectly = process.argv[1]?.endsWith("check-local-presence.mjs"); +if (invokedDirectly) main(); diff --git a/scripts/generate-site-map.ts b/scripts/generate-site-map.ts index 1b65b65dc..bc80a1341 100644 --- a/scripts/generate-site-map.ts +++ b/scripts/generate-site-map.ts @@ -372,7 +372,7 @@ function renderModePageIndex() { mode: "Tools", home: appModeHomeHref("tools"), search: appModeHomeHref("tools", { query: "medications", focus: true, run: true }), - detail: "Tool launcher and detail panels inside dashboard tools mode (`/?mode=tools`).", + detail: "Canonical Tools launcher at `/tools` (PT-11). `/?mode=tools` remains a dashboard-mode alias.", }, ]); } diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index bdd446ce4..8b99263d0 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -242,10 +242,14 @@ async function main() { if (envModule.env.OPENAI_API_KEY && !envModule.env.OPENAI_SAFETY_IDENTIFIER_SECRET) { result.warnings.push( - "OPENAI_SAFETY_IDENTIFIER_SECRET is not set; authenticated Responses requests omit the privacy-preserving safety identifier.", + "OPENAI_SAFETY_IDENTIFIER_SECRET is not set; authenticated Responses requests omit the privacy-preserving safety identifier. For local/dev, run npm run check:local-presence -- --fill.", ); } else if (envModule.env.OPENAI_SAFETY_IDENTIFIER_SECRET) { result.passes.push("OpenAI safety identifiers use a deployment-secret HMAC; raw owner IDs are not sent."); + } else if (!isCiMode) { + result.warnings.push( + "OPENAI_SAFETY_IDENTIFIER_SECRET is not set (optional until OpenAI is enabled). Local fill: npm run check:local-presence -- --fill.", + ); } // Exercise the real boot guard so this check tracks its behaviour instead of @@ -262,9 +266,21 @@ async function main() { const productionLike = process.env.NODE_ENV === "production" || process.env.VERCEL_ENV === "production"; if (productionLike) { result.failures.push(`Query-hash secret issue: ${message}`); + } else if (!isCiMode) { + result.warnings.push( + `RAG_QUERY_HASH_SECRET is not set for local/dev (${message}). Fill a distinct local value with npm run check:local-presence -- --fill.`, + ); } } + if (envModule.env.HEALTH_DEEP_PROBE_SECRET) { + result.passes.push("HEALTH_DEEP_PROBE_SECRET is set for authorized deep health probes."); + } else if (!isCiMode) { + result.warnings.push( + "HEALTH_DEEP_PROBE_SECRET is not set; /api/health?deep=1 stays shallow. Local fill: npm run check:local-presence -- --fill.", + ); + } + if (placeholderLooksLikeExample(envModule.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ?? "")) { result.warnings.push("NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY looks like a placeholder."); } diff --git a/src/components/clinical-dashboard/ClinicalSidebar.tsx b/src/components/clinical-dashboard/ClinicalSidebar.tsx index 4af45b67e..b8b162c0f 100644 --- a/src/components/clinical-dashboard/ClinicalSidebar.tsx +++ b/src/components/clinical-dashboard/ClinicalSidebar.tsx @@ -60,7 +60,8 @@ const sidebarToolItems = [ { id: "services", label: "Services", icon: appModeIcons.services, href: "/services" }, { id: "prescribing", label: "Medications", icon: Pill, href: "/?mode=prescribing" }, { id: "factsheets", label: "Factsheets", icon: appModeIcons.factsheets, href: "/factsheets" }, - { id: "tools", label: "Tools", icon: Wrench, href: "/?mode=tools" }, + // PT-11: standalone /tools is the canonical entry; /?mode=tools remains a dashboard-mode alias. + { id: "tools", label: "Tools", icon: Wrench, href: "/tools" }, ] as const; const sidebarAccountLibraryItems = [ diff --git a/src/components/clinical-dashboard/global-search-shell.tsx b/src/components/clinical-dashboard/global-search-shell.tsx index 80d1230bd..991c06172 100644 --- a/src/components/clinical-dashboard/global-search-shell.tsx +++ b/src/components/clinical-dashboard/global-search-shell.tsx @@ -344,6 +344,10 @@ function GlobalStandaloneSearchShellClient({ // Guard both: open menu DOM (activeElement is often mid-transition) and // any intentional focus already moved off the composer. if (document.getElementById("app-mode-menu")) return; + // Do not reclaim composer focus while a modal Sheet is open (Sources / + // Guide / filters). The focus=1 hydration retry otherwise races sheet + // autofocus and can leave the Find field unfocused in UI smoke. + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; const active = document.activeElement; if (active instanceof HTMLElement && active !== document.body && active !== inputRef.current) { return; @@ -386,7 +390,7 @@ function GlobalStandaloneSearchShellClient({ }, [recentQueriesOwnerId]); function prefetchApplications() { - router.prefetch("/?mode=tools"); + router.prefetch("/tools"); if (favouritesAccessible) router.prefetch("/favourites"); router.prefetch("/differentials"); router.prefetch("/dsm"); diff --git a/src/components/clinical-dashboard/use-dashboard-shell-actions.ts b/src/components/clinical-dashboard/use-dashboard-shell-actions.ts index d456a6e6d..ea891ae68 100644 --- a/src/components/clinical-dashboard/use-dashboard-shell-actions.ts +++ b/src/components/clinical-dashboard/use-dashboard-shell-actions.ts @@ -92,7 +92,7 @@ export function useDashboardShellActions(options: { }, [closeTransientSurfaces, openAccountSetup, setSettingsOpen, signedIn]); const prefetchApplications = useCallback(() => { - prefetch("/?mode=tools"); + prefetch("/tools"); if (favouritesAccessible) prefetch("/favourites"); prefetch("/differentials"); }, [favouritesAccessible, prefetch]); diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index c4a0ea333..aa8c4c6c1 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -228,13 +228,14 @@ export function Sheet({ }; focusIfNeeded(); // Retries cover sibling-sheet teardown and late-mounted autofocus inputs - // (UtilityDrawer media sync / deferred drawer children). + // (UtilityDrawer media sync / deferred drawer children). Keep retrying long + // enough to outlast focus=1 hydration (rAF + ~300ms) and composer reclaim. let attempts = 0; const retryTimer = window.setInterval(() => { attempts += 1; const focusTarget = focusIfNeeded(); if ( - attempts >= 8 || + attempts >= 40 || !isTopmostSheet(sheetId) || (focusTarget != null && document.activeElement === focusTarget) ) { diff --git a/src/lib/app-modes.ts b/src/lib/app-modes.ts index 5bd72ee75..174fce8a8 100644 --- a/src/lib/app-modes.ts +++ b/src/lib/app-modes.ts @@ -291,7 +291,9 @@ export const appModeDefinitions = [ id: "tools", label: "Tools", description: "Clinical tools and applications", - href: "/?mode=tools", + // PT-11: standalone /tools is the canonical entry; /?mode=tools remains a + // dashboard-mode alias for bookmarks and deep links. + href: "/tools", search: { kind: "tools", placeholder: "Search tools...", @@ -396,6 +398,7 @@ const namespaceIsolatedModes = new Set([ "formulation", "therapy-compass", "factsheets", + "tools", ]); export function appModeHomeHref(modeId: AppModeId, options: SearchNavigationOptions = {}) { diff --git a/src/lib/universal-search.ts b/src/lib/universal-search.ts index 4ad0af773..7b1b98bb4 100644 --- a/src/lib/universal-search.ts +++ b/src/lib/universal-search.ts @@ -739,6 +739,6 @@ export function universalSearchViewAllHref(domain: UniversalSearchDomain, query: case "therapies": return `/therapy-compass/search?q=${encodeURIComponent(query)}&run=1`; case "tools": - return `/?mode=tools&q=${encodeURIComponent(query)}&run=1`; + return `/tools?q=${encodeURIComponent(query)}&run=1`; } } diff --git a/tests/app-modes.test.ts b/tests/app-modes.test.ts index 22cacfa5a..c7acaf64f 100644 --- a/tests/app-modes.test.ts +++ b/tests/app-modes.test.ts @@ -229,7 +229,7 @@ describe("app mode search contract", () => { "/?mode=prescribing&q=acamprosate+renal+dose", ); expect(appModeHomeHref("tools", { query: " medications ", run: true, focus: true })).toBe( - "/?mode=tools&q=medications&focus=1&run=1", + "/tools?q=medications&focus=1&run=1", ); }); diff --git a/tests/favourites-auth-gate.dom.test.tsx b/tests/favourites-auth-gate.dom.test.tsx index abc7b2b88..566817b3c 100644 --- a/tests/favourites-auth-gate.dom.test.tsx +++ b/tests/favourites-auth-gate.dom.test.tsx @@ -99,7 +99,7 @@ describe("favourites auth gate DOM", () => { { name: "Services", href: "/services" }, { name: "Medications", href: "/?mode=prescribing" }, { name: "Factsheets", href: "/factsheets" }, - { name: "Tools", href: "/?mode=tools" }, + { name: "Tools", href: "/tools" }, ]); expect(screen.queryByRole("link", { name: "Favourites" })).toBeNull(); expect(screen.queryByRole("button", { name: /^Guide & help$/ })).toBeNull(); diff --git a/tests/local-presence.test.ts b/tests/local-presence.test.ts new file mode 100644 index 000000000..dc11f8fb9 --- /dev/null +++ b/tests/local-presence.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; + +import { + assessLocalPresence, + classifyProjectIdentity, + FILLABLE_LOCAL_SECRETS, + lengthBucket, + mergeFillIntoEnvLocal, + parseEnvFile, + REPORT_ONLY_KEYS, +} from "../scripts/check-local-presence.mjs"; + +describe("check-local-presence", () => { + it("parses env assignments without exposing values in helpers", () => { + const parsed = parseEnvFile( + ["# comment", 'RAG_QUERY_HASH_SECRET="abc"', "OPENAI_API_KEY=xyz", "not valid", ""].join("\n"), + ); + expect(parsed.RAG_QUERY_HASH_SECRET).toBe("abc"); + expect(parsed.OPENAI_API_KEY).toBe("xyz"); + expect(Object.keys(parsed)).not.toContain("not"); + }); + + it("buckets lengths without needing the raw secret in assertions beyond fixtures", () => { + expect(lengthBucket("")).toBe("missing"); + expect(lengthBucket("short")).toBe("too-short(<8)"); + expect(lengthBucket("0123456789abcdef")).toBe("ok(16-31)"); + expect(lengthBucket("a".repeat(40))).toBe("ok(32-63)"); + }); + + it("treats unset Supabase identity as demo-mode OK", () => { + expect(classifyProjectIdentity({}).status).toBe("unset"); + }); + + it("fails closed on stale or mismatched project identity", () => { + expect( + classifyProjectIdentity({ + NEXT_PUBLIC_SUPABASE_URL: "https://qjgitjyhxrwxsrydablr.supabase.co", + SUPABASE_PROJECT_REF: "qjgitjyhxrwxsrydablr", + SUPABASE_PROJECT_NAME: "Database", + }).status, + ).toBe("ambiguous"); + + expect( + classifyProjectIdentity({ + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_PROJECT_REF: "sjrfecxgysukkwxsowpy", + }).status, + ).toBe("ambiguous"); + }); + + it("accepts complete production identity", () => { + const result = classifyProjectIdentity({ + NEXT_PUBLIC_SUPABASE_URL: "https://sjrfecxgysukkwxsowpy.supabase.co", + SUPABASE_PROJECT_REF: "sjrfecxgysukkwxsowpy", + SUPABASE_PROJECT_NAME: "Clinical KB Database", + }); + expect(result.status).toBe("ready"); + }); + + it("flags fillable HMAC/probe gaps and never marks provider keys fillable", () => { + const assessment = assessLocalPresence({}); + expect(assessment.fillable.every((row) => row.status === "gap")).toBe(true); + expect(FILLABLE_LOCAL_SECRETS.map((s) => s.name)).toEqual( + expect.arrayContaining(["OPENAI_SAFETY_IDENTIFIER_SECRET", "RAG_QUERY_HASH_SECRET", "HEALTH_DEEP_PROBE_SECRET"]), + ); + expect(REPORT_ONLY_KEYS).toEqual(expect.arrayContaining(["SUPABASE_SERVICE_ROLE_KEY", "OPENAI_API_KEY"])); + expect(assessment.reportOnly.every((row) => row.fillable === false)).toBe(true); + }); + + it("merges only missing fillable secrets and never prints them via return shape", () => { + const existing = "RAG_PROVIDER_MODE=offline\n"; + let n = 0; + const { text, filled } = mergeFillIntoEnvLocal(existing, FILLABLE_LOCAL_SECRETS, { + generate: () => { + n += 1; + return `local-dev-secret-${n}-`.padEnd(64, "x"); + }, + }); + expect(filled).toEqual(["OPENAI_SAFETY_IDENTIFIER_SECRET", "RAG_QUERY_HASH_SECRET", "HEALTH_DEEP_PROBE_SECRET"]); + expect(text).toContain("RAG_PROVIDER_MODE=offline"); + expect(text).toContain("OPENAI_SAFETY_IDENTIFIER_SECRET="); + expect(text).toContain("RAG_QUERY_HASH_SECRET="); + expect(text).toContain("HEALTH_DEEP_PROBE_SECRET="); + + const again = mergeFillIntoEnvLocal(text, FILLABLE_LOCAL_SECRETS, { + generate: () => "should-not-be-used-because-already-present-pad".padEnd(64, "y"), + }); + expect(again.filled).toEqual([]); + }); + + it("replaces present-but-too-short assignments instead of duplicating keys", () => { + const existing = [ + "RAG_PROVIDER_MODE=offline", + "OPENAI_SAFETY_IDENTIFIER_SECRET=short", + "RAG_QUERY_HASH_SECRET=also-short", + ].join("\n"); + const { text, filled } = mergeFillIntoEnvLocal(existing, FILLABLE_LOCAL_SECRETS, { + generate: () => "regenerated-local-secret-value-".padEnd(64, "z"), + }); + expect(filled).toEqual(["OPENAI_SAFETY_IDENTIFIER_SECRET", "RAG_QUERY_HASH_SECRET", "HEALTH_DEEP_PROBE_SECRET"]); + expect(text).toContain("RAG_PROVIDER_MODE=offline"); + expect(text).not.toMatch(/OPENAI_SAFETY_IDENTIFIER_SECRET=short/); + expect(text).not.toMatch(/RAG_QUERY_HASH_SECRET=also-short/); + expect([...text.matchAll(/^OPENAI_SAFETY_IDENTIFIER_SECRET=/gm)]).toHaveLength(1); + expect([...text.matchAll(/^RAG_QUERY_HASH_SECRET=/gm)]).toHaveLength(1); + expect(parseEnvFile(text).OPENAI_SAFETY_IDENTIFIER_SECRET).toMatch(/^regenerated-local-secret-value-/); + }); +}); diff --git a/tests/production-readiness-offline.test.ts b/tests/production-readiness-offline.test.ts index adb67ef8f..fd61b23f0 100644 --- a/tests/production-readiness-offline.test.ts +++ b/tests/production-readiness-offline.test.ts @@ -19,4 +19,11 @@ describe("production readiness provider policy", () => { it("allows a missing OpenAI key only for explicit offline mode", () => { expect(openAIReadinessPolicy("offline")).toEqual({ required: false, ready: true }); }); + + it("documents local presence fill guidance for safety/query-hash/deep-probe gaps", () => { + const source = readFileSync(new URL("../scripts/production-readiness.ts", import.meta.url), "utf8"); + expect(source).toContain("check:local-presence"); + expect(source).toContain("HEALTH_DEEP_PROBE_SECRET is not set"); + expect(source).toContain("OPENAI_SAFETY_IDENTIFIER_SECRET is not set"); + }); }); diff --git a/tests/route-reachability.test.ts b/tests/route-reachability.test.ts index f9ff1fea5..27ef8f372 100644 --- a/tests/route-reachability.test.ts +++ b/tests/route-reachability.test.ts @@ -29,10 +29,6 @@ const srcRoot = path.join(repoRoot, "src"); /** Intentionally-unlinked static page routes, each with the reason it is exempt. */ const REACHABILITY_ALLOWLIST = new Map([ - [ - "/tools", - "Orphan parallel to /?mode=tools; nothing links to the standalone page. Tracked as issue #007 (decide the canonical Tools entry point). Reachable via URL and the /applications redirect.", - ], [ "/documents/source", "Legacy compatibility redirect target reached by external/legacy deep links, not in-app navigation (frontend-architecture.md).", diff --git a/tests/ui-smoke.spec.ts b/tests/ui-smoke.spec.ts index 207738351..9e554a2cb 100644 --- a/tests/ui-smoke.spec.ts +++ b/tests/ui-smoke.spec.ts @@ -764,7 +764,7 @@ async function openMobileClinicalGuideMenu(page: Page) { { name: "Services", href: "/services" }, { name: "Medications", href: "/?mode=prescribing" }, { name: "Factsheets", href: "/factsheets" }, - { name: "Tools", href: "/?mode=tools" }, + { name: "Tools", href: "/tools" }, ]); await expect(menu.getByRole("button", { name: "Guide & help", exact: true })).toHaveCount(0); await expect(menu.getByRole("button", { name: /^(Switch to )?(dark|light) mode$/i })).toHaveCount(0); @@ -1086,7 +1086,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expandSidebar.click(); await expect(sidebar).toBeVisible(); await expect(sidebar.getByRole("link", { name: "View tools" })).toHaveCount(0); - await expect(sidebar.getByRole("link", { name: "Tools", exact: true })).toHaveAttribute("href", "/?mode=tools"); + await expect(sidebar.getByRole("link", { name: "Tools", exact: true })).toHaveAttribute("href", "/tools"); await expect(sidebar.getByTestId("sidebar-account-settings")).toHaveAccessibleName( /G Guest Not signed in\. Set up workspace/, ); @@ -1139,7 +1139,7 @@ test.describe("Clinical KB UI smoke coverage", () => { { name: "Services", href: "/services" }, { name: "Medications", href: "/?mode=prescribing" }, { name: "Factsheets", href: "/factsheets" }, - { name: "Tools", href: "/?mode=tools" }, + { name: "Tools", href: "/tools" }, ]); expect( await library @@ -1403,6 +1403,7 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(appModeMenu.getByRole("menuitemradio", { name: /^Medication\b/ })).toBeAttached(); // Scroll the sheet body so a lower mode is interactable, then select it. + // Tools is canonical at /tools (PT-11); selecting it navigates off the dashboard. const toolsMode = appModeMenu.getByRole("menuitemradio", { name: /^Tools\b/ }); await toolsMode.scrollIntoViewIfNeeded(); await expect(toolsMode).toBeVisible(); @@ -1410,8 +1411,8 @@ test.describe("Clinical KB UI smoke coverage", () => { await expect(modeSheet).toHaveCount(0); await expect(appModeMenu).toHaveCount(0); - await expect(page.getByRole("button", { name: "Mode Tools" })).toBeVisible(); - await expect(page.getByRole("button", { name: "Mode Tools" })).toBeFocused(); + await expect(page).toHaveURL(/\/tools(?:\?|$)/); + await expect(page.getByRole("heading", { name: /tools/i }).first()).toBeVisible(); await expectNoPageHorizontalOverflow(page); });