Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ RAG_AWAIT_QUERY_LOGS=false
# Design-exploration mockup routes (/mockups/*) 404 in production builds unless
# explicitly opted in. Always reachable in dev/test.
#NEXT_PUBLIC_MOCKUPS_ENABLED=false
# Passwordless developer-area access. Set this to open the Development hub,
# Care Plan, Caring Contacts and Ward Flow from a bookmarked link instead of a
# Supabase sign-in: visit any of those paths once as
# /mockups/development?devkey=<this value> and the proxy swaps it for a signed
# cookie that is renewed on every later visit. Minimum 32 characters; generate
# with `openssl rand -base64 32`. Leave unset to require administrator sign-in.
# Rotating this value is the revocation: it invalidates every device at once.
#DEVELOPER_AREA_ACCESS_KEY=
# Content browsing is public. Uploads and corpus-management actions require a
# signed-in user whose Supabase app_metadata.site_role is "administrator".
# Assign that claim only through the approval-gated auth:set-administrator script.
Expand Down
63 changes: 62 additions & 1 deletion data/repo-awareness-snapshot.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": "repo-awareness-snapshot-v3",
"captured_revision": {
"committed_at": "2026-09-07"
"committed_at": "2026-09-08"
},
"routes": {
"modes": [
Expand Down Expand Up @@ -1630,6 +1630,11 @@
"section": "ward-flow-phase-3-workspace",
"catalogued": true
},
{
"path": "docs/developer-area-access.md",
"section": "root",
"catalogued": true
},
{
"path": "docs/ward-flow-phase-3-rulings.md",
"section": "root",
Expand Down Expand Up @@ -7389,6 +7394,14 @@
"outcome": "exact merged PR #1491 head; un-checked-out local branch archived in verified batch3 bundle",
"checks": "GitHub merged exact head, branch not checked out, no open PR claim, batch3 bundle verify ok SHA256 48AF30DD07A04703D02F4A2FFBDE65F1E573D081E4660D4C7E240D8DB1079B8C"
},
{
"date": "2026-09-07",
"ref": "PR-2628",
"head": "0c061ba66d610d6a0ae2ca38cf467cb96191fd54",
"scope": "PR merge readiness",
"outcome": "Child rollback defect reproduced and corrected locally; RAG behavior classification and live-canary baseline remain merge blockers.",
"checks": "44 earlier focused tests and 221 Caring Contacts database tests passed. Revised rollback, both row ceilings, deleted-owner SQL fixtures and transactional migrations passed; regenerated schema manifest."
},
{
"date": "2026-08-15",
"ref": "claude/test-source-contract-hardening",
Expand Down Expand Up @@ -9525,6 +9538,14 @@
"outcome": "Before: fixed docks always consumed the measured visual-viewport height, double-lifting them when `interactive-widget=resizes-content` already resized the layout viewport. After: the provider subtracts layout shrink and exposes only residual overlay height to CSS while retaining keyboard-open state.",
"checks": "Focused Vitest 18/18 pass; `npm run ensure` verified http://localhost:3264; Prettier and diff checks pass; no provider-backed checks run."
},
{
"date": "2026-09-07",
"ref": "PR-2628",
"head": "2897742597ee22017bf54d25fe6474f435d546b0",
"scope": "PR CI and review repair",
"outcome": "Reviewed migration ordering and rollback semantics, synced current main, and fixed deterministic forms sorting CI expectation.",
"checks": "vitest corpus-access and migration-history suites (31); focused offline safety suites (43 total); installed-lock parity; merge-tree clean"
},
{
"date": "2026-07-24",
"ref": "PR #1176 / `cursor/pdf-crop-malformed-repro-9b3e`",
Expand Down Expand Up @@ -10525,6 +10546,14 @@
"outcome": "Queued one P1 add request: check:drift reported missing_live 21 (2026-08-09) then 20 (2026-08-14) despite two indexes being restored between, so the expected figure was 19; the gap is consistent with an ongoing drop mechanism. Recorded as inference not fact. Carries a stop rule against beginning the restoration window until resolved. Filed as add rather than a #316 update to avoid colliding with the #316 update already queued in PR #1970.",
"checks": "verify:pr-local (11 completed, 0 failed)"
},
{
"date": "2026-09-07",
"ref": "PR-2702",
"head": "3687785ed0b3898d4f179933aab8dc7dab791bb3",
"scope": "PR merge readiness",
"outcome": "Local repair findings corrected; hosted verification pending.",
"checks": "154 focused tests, typecheck, SQL regression fixture and schema replay passed; final publication pending."
},
{
"date": "2026-07-30",
"ref": "codex/review-pr1423",
Expand Down Expand Up @@ -11053,6 +11082,14 @@
"outcome": "fixed",
"checks": "git diff --check; static ledger guards; focused tests blocked without node_modules"
},
{
"date": "2026-09-07",
"ref": "claude/audit-fix-p16",
"head": "3c8c48223e38e4c58580df96fb0f3ec2f351303f",
"scope": "PR2628 integration of merged PR2702 and schema mirror correction",
"outcome": "Integrated main 0177bed184; restored legacy generation function mirror to canonical migration hash; canary baseline still blocks merge.",
"checks": "169 focused tests passed; combined Docker schema replay and three SQL fixtures passed; canonical generation function hash 594b1f715fbbfa1df1b1f1183a7fef5a restored; previous head hosted CI passed; new-head CI required; provider canary not dispatched."
},
{
"date": "2026-08-18",
"ref": "claude/patient-factsheets-search-regression-8iyvnd",
Expand Down Expand Up @@ -18429,6 +18466,14 @@
"outcome": "FIXED. Hosted Build failed when a runner reported 7.8 GiB via `os.totalmem` and hit the local Docker RAM floor in `guard-next-build.mjs`. Gate now skips under `CI`/`GITHUB_ACTIONS` (local protection retained). Prior tip motion/Bugbot fixes unchanged.",
"checks": "CI=true guard exit 0; awaiting exact-head hosted Build; no provider checks."
},
{
"date": "2026-09-07",
"ref": "codex/canary-fallback-repair-20260907",
"head": "9181d1a78b0e1b02eb34572c83a93aeb18152d61",
"scope": "fallback prose recovery",
"outcome": "No actionable findings in targeted review; live baseline pending",
"checks": "50 focused tests and 630 offline RAG tests passed; typecheck passed; production readiness blocked by six existing governance approvals"
},
{
"date": "2026-08-07",
"ref": "cursor/site-testing-speed-08c1",
Expand Down Expand Up @@ -21685,6 +21730,14 @@
"outcome": "SUPERSEDES the 2026-07-30 db8209be record, which asserted a root cause now REFUTED. That record claimed dragScrollBy clamping made the ui-phone-scroll red; main's #127 carries trace evidence (PR #1404 run 30521269873) that the drag delivered in full (scrollTop 1272 = 552+720) with ~1300px runway spare and a 10s non-flip is a latched state. The change is a diagnostic and guard, NOT a fix, and is now labelled so in the docstring, commit, PR body and #127. Remaining candidates: scrollHidden false vs sharedChromePinned latched; they are indistinguishable from the DOM because only the composite data-scroll-hidden is exposed. Prime suspect in source: composerFocusPinsChrome has a still-the-active-owner guard, headerFocusPinsChrome has none (master-search-header.tsx:397-398). ALSO: this PR ran zero pull_request workflows for ~2h (no CI/Gitleaks/Semgrep, only pull_request_target) because a real conflict blocked refs/pull/1427/merge - issue #116, caught by main's new PR mergeability check. Merging main fixed it and CI ran green first try.",
"checks": "CI run 30530618838 SUCCESS (13m39). MEASURED shard result, correcting the ~7min prediction: Production UI (1) 121 tests 9m36, (2) 111 tests 6m54, (3) 110 tests 6m20 - per-test cost is NOT uniform, shard 1 holds the slow specs, so the largest shard is 9m36 not the predicted 6.8min. ui-critical-fast 3m14. PR required SUCCESS. verify:cheap on merged tree PASS (434 files / 4563 passed, 4 skipped); prettier --check . PASS; ui-phone-scroll ran locally 1x via PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: 56 passed (5.3m) - three-run protocol NOT completed and not applicable, since this is not a flake fix."
},
{
"date": "2026-09-05",
"ref": "claude/audit-fix-p16 (PR #2628)",
"head": "ba04794640bd9edf9484d07a530bc5d109fc4537",
"scope": "Run PR sweep: CI fix + threads + drift",
"outcome": "before: PR required FAILURE (drift-detection test: schema.sql changed without regenerating drift-manifest.json), auto-merge ARMED on a PR carrying a live migration (align_corpus_flip_retrieval_scoped_child_owners). after: regenerated supabase/drift-manifest.json via local disposable Docker Postgres (never touches live project); guard-push correctly refused to push while auto-merge was armed on a migration PR, flagged to the user, user disabled auto-merge, then pushed. Merged origin/main (clean, twice). PR now green and waiting for the user's manual merge inside an approved window — never auto-merged.",
"checks": "npm run drift:manifest (local Docker Postgres replay); npx vitest run tests/drift-detection.test.ts (21 passed, twice, before and after the second main merge). No provider-backed / live-Supabase checks run."
},
{
"date": "2026-08-18",
"ref": "claude/services-navigation-removal-7bnknn",
Expand Down Expand Up @@ -26061,6 +26114,14 @@
"outcome": "redundant exact head merged in PR 1438; removal deferred by primary-dirty lease",
"checks": "clean status; GitHub merged exact head; zero cherry-pick-unique commits"
},
{
"date": "2026-09-07",
"ref": "codex/canary-fallback-repair-20260907",
"head": "f13dbc27efd9174c0de5ffcb39a73c791dd0f148",
"scope": "fallback prose recovery",
"outcome": "Targeted review found no actionable defects; all three original fallbacks pass live; full baseline retains two timing-only failures",
"checks": "109 focused tests, 630 offline RAG tests and typecheck passed; live 44 cases: 30/30 substantive, citation/numeric failures zero; two latency rechecks pending; production readiness blocked by six existing approvals"
},
{
"date": "2026-07-11",
"ref": "PR #469 / claude/response-formatting-cleanup-b57a9c",
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ npm run docs:check-links
- [wiring-conventions.md](wiring-conventions.md) — page/button wiring conventions and the dead-button / orphan-route gates
- [search-chrome-behaviour.md](search-chrome-behaviour.md) — shared search-chrome contract: composer ownership, phone edge-to-edge dock, hide/reveal reserves
- [mockup-retirement-policy.md](mockup-retirement-policy.md) — when a mockup may be deleted, who decides, what evidence is required, and the three tiers that keep developer-gated prototypes out of cleanup scope
- [developer-area-access.md](developer-area-access.md) — how the four developer-gated `/mockups` subtrees are protected, the passwordless `?devkey` link and its setup, what the link deliberately does not grant, and how to revoke it
- [search-results-bar-decisions.md](search-results-bar-decisions.md) — shared results-bar anatomy, why the filter shelf is scoped to two modes, and what is deliberately not done
- [deployment-architecture.md](deployment-architecture.md) — app/worker/Supabase deployment topology
- [ingestion-state-machine.md](ingestion-state-machine.md) — ingestion job lifecycle and states (dated 2026-07-07 race analysis; the lease is heartbeated and fenced since 2026-07-08 — see its status banner)
Expand Down
102 changes: 102 additions & 0 deletions docs/developer-area-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Developer area access

How the four developer-gated subtrees under `/mockups` are protected, and how to
open them without signing in.

The subtrees are listed once, in `src/lib/developer-area/headers.ts`
(`DEVELOPER_GATED_PATH_PREFIXES`): the Development hub, Care Plan, Caring
Contacts and Ward Flow. Every other `/mockups/**` path 404s in production and is
not covered here.

## Two credentials, either of which opens the area

`DeveloperAreaGate` (`src/components/developer-area/developer-area-gate.tsx`)
admits a request holding **either**:

1. **A signed-in administrator** — a Supabase session whose
`app_metadata.site_role` is `administrator`, the same claim that gates
document and corpus management. Signing in is a magic link sent to an email
address, or Apple/Google/Microsoft. There has never been a password.
2. **A valid passwordless access cookie** — the `?devkey=` link described below.

The cookie is checked first, because it is the expected case on the owner's own
devices and it needs no provider round trip to answer.

Outside production the gate is a no-op, matching every other `/mockups/**`
route. In production it bypasses only under the exact double-flag pairing the
isolated Playwright production build uses; `NEXT_PUBLIC_MOCKUPS_ENABLED=true`
alone must never open it, which was incident `#L30`.

## The passwordless link

**Setup, once per deployment.** Generate a secret and set it as the server-only
Railway variable `DEVELOPER_AREA_ACCESS_KEY` on the `Database` service:

```bash
openssl rand -base64 32
```

Minimum 32 characters, enforced in `src/lib/env.ts` and again in
`resolveDeveloperAccessKey`. A shorter value is treated as unconfigured rather
than accepted, because this secret travels in a URL where it is visible in
browser history and in any screen share. Never name it `NEXT_PUBLIC_…`: Next.js
inlines those into the client bundle, and `check:production-readiness` fails the
release if it finds that name.

**Setup, once per device.** Visit any gated path with the secret attached:

```
https://psychiatry.tools/mockups/development?devkey=<the secret>
```

`src/proxy.ts` verifies it, sets a signed cookie, and redirects to the same URL
without the parameter — so the secret does not stay in the address bar, in the
history entry that gets shared, or in a `Referer` header sent onward. From then
on that browser opens the developer area with no sign-in at all.

**It does not expire in practice.** The cookie is stamped for one year —
deliberately inside the ~400-day ceiling browsers clamp `Set-Cookie` lifetimes
to, so the stated expiry is the real one — and `src/proxy.ts` re-issues it on
every verified visit. A device used at least once a year never needs the link
again.

## What the link does not grant

Reaching the page is all it grants. The panels that read live data check the
administrator claim themselves and degrade to "unavailable" for a link holder:
`resolveHubEnvironmentFacts` (`environment-facts.ts`) and the corpus-health
reader both call `isAdministratorUser` independently of the gate.

That separation is deliberate. The link is a convenience credential that can be
forwarded in a message or copied off a screen; the corpus is the clinical
library. Do not "simplify" those panels by having them trust the cookie.

What a link holder _can_ read is the prototype content and the repository-derived
panels: the task ledger, the hazard notes, review state, routes, documentation
inventory, and the Ward Flow / Care Plan / Caring Contact prototypes. Treat the
link accordingly — it is roughly as sensitive as the internal notes themselves.

## Revoking access

Rotate `DEVELOPER_AREA_ACCESS_KEY` in Railway. Every existing cookie was signed
under the old key, so all of them stop verifying at once, on every device. There
is no per-device revocation, and none is planned for a single-operator
deployment.

To turn the passwordless route off entirely, unset the variable. The
administrator sign-in is then the only way in, exactly as before this existed.

## Why the cookie is not the key

The cookie carries `v1.<issuedAt>.<HMAC-SHA256 over both, keyed by the secret>`,
not the secret. A stolen cookie therefore cannot be turned back into the key, it
cannot be re-dated to extend itself (the signature covers the issue time), and
the server enforces the expiry rather than trusting the browser to drop it.
Every unset, under-strength, malformed, or wrongly-keyed case resolves to "not
granted" and falls through to the sign-in screen.

Implementation: `src/lib/developer-area/link-access.ts`. Tests:
`tests/developer-area-link-access.test.ts` (the credential),
`tests/proxy.test.ts` (the exchange and the renewal),
`tests/developer-area-access.test.ts` and
`tests/developer-area-gate.dom.test.tsx` (the gate).
40 changes: 40 additions & 0 deletions scripts/production-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,45 @@ function recordMockupsGateProductionCheck() {
}
}

/**
* The passwordless developer-area link (`DEVELOPER_AREA_ACCESS_KEY`, exchanged
* for a signed cookie by `src/proxy.ts`) is a second credential for the same
* subtrees the administrator claim gates. Two things about it are worth
* catching at release time rather than in a browser.
*
* A `NEXT_PUBLIC_`-prefixed copy is a hard failure: Next.js inlines those into
* the client bundle, so the secret would ship to every visitor and the
* developer area would be open to anyone who reads the JavaScript. That is #L30
* with a longer string, and there is no legitimate reason for the name to exist.
*
* A correctly-named key in production is not a failure — it is the feature
* working — but it IS a fact a release should state out loud, because it means
* the area is reachable without a sign-in by anyone holding the link.
*/
export function developerAccessKeyProductionRisk(
environment: Record<string, string | undefined> = process.env,
): "none" | "enabled" | "public-name" {
if (environment.NEXT_PUBLIC_DEVELOPER_AREA_ACCESS_KEY?.trim()) return "public-name";
const productionLike = environment.NODE_ENV === "production" || environment.VERCEL_ENV === "production";
if (!productionLike || !environment.DEVELOPER_AREA_ACCESS_KEY?.trim()) return "none";
return "enabled";
}

function recordDeveloperAccessKeyCheck() {
const risk = developerAccessKeyProductionRisk();
if (risk === "public-name") {
result.failures.push(
"NEXT_PUBLIC_DEVELOPER_AREA_ACCESS_KEY is set. Next.js inlines NEXT_PUBLIC_ values into the client bundle, " +
"so this would publish the developer-area secret to every visitor — rename it to DEVELOPER_AREA_ACCESS_KEY (server-only).",
);
} else if (risk === "enabled") {
result.warnings.push(
"DEVELOPER_AREA_ACCESS_KEY is set: the developer area also opens for anyone holding the ?devkey link, without signing in. " +
"Rotate the value to revoke every device.",
);
}
}

async function checkFileForServiceRoleExposure() {
const envFiles = [".env", ".env.production", ".env.development"];
for (const fileName of envFiles) {
Expand Down Expand Up @@ -357,6 +396,7 @@ async function main() {
recordNoAuthProductionCheck();
recordDemoModeProductionCheck();
recordMockupsGateProductionCheck();
recordDeveloperAccessKeyCheck();
recordRawQueryPersistenceProductionCheck();
recordAnswerPersistenceProductionCheck();
await checkFileForServiceRoleExposure();
Expand Down
Loading
Loading