diff --git a/.env.example b/.env.example index 90ef93746e..e5fd61b194 100644 --- a/.env.example +++ b/.env.example @@ -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= 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. diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index af85eabf95..b2d92113b6 100644 --- a/data/repo-awareness-snapshot.json +++ b/data/repo-awareness-snapshot.json @@ -1,7 +1,7 @@ { "version": "repo-awareness-snapshot-v3", "captured_revision": { - "committed_at": "2026-09-07" + "committed_at": "2026-09-08" }, "routes": { "modes": [ @@ -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", @@ -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", @@ -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`", @@ -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", @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/docs/README.md b/docs/README.md index b72e52cae2..08d43a3db2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) diff --git a/docs/developer-area-access.md b/docs/developer-area-access.md new file mode 100644 index 0000000000..7b79cdde97 --- /dev/null +++ b/docs/developer-area-access.md @@ -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= +``` + +`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..`, +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). diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index 49fe24fd1d..925199d781 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -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 = 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) { @@ -357,6 +396,7 @@ async function main() { recordNoAuthProductionCheck(); recordDemoModeProductionCheck(); recordMockupsGateProductionCheck(); + recordDeveloperAccessKeyCheck(); recordRawQueryPersistenceProductionCheck(); recordAnswerPersistenceProductionCheck(); await checkFileForServiceRoleExposure(); diff --git a/src/components/developer-area/developer-area-gate.tsx b/src/components/developer-area/developer-area-gate.tsx index 216cee98f6..dae666b43c 100644 --- a/src/components/developer-area/developer-area-gate.tsx +++ b/src/components/developer-area/developer-area-gate.tsx @@ -2,7 +2,11 @@ import { headers } from "next/headers"; import type { ReactNode } from "react"; import { DEVELOPER_AREA_PATH_HEADER } from "@/lib/developer-area/headers"; -import { developerGateBypassAllowed, resolveDeveloperAccessState } from "@/lib/developer-area/access"; +import { + developerGateBypassAllowed, + developerLinkAccessGranted, + resolveDeveloperAccessState, +} from "@/lib/developer-area/access"; import { DeveloperAreaRouteGuard } from "./developer-area-route-guard"; import { DeveloperGateScreen } from "./developer-gate-screen"; @@ -23,6 +27,14 @@ import { DeveloperGateScreen } from "./developer-gate-screen"; * together with `NEXT_PUBLIC_MOCKUPS_ENABLED=true`). The mockups flag alone * must never disable this gate on a real deployment (#L30). * + * A signed-in administrator is not the only way through. A visitor holding the + * passwordless access cookie — issued by `src/proxy.ts` in exchange for the + * `?devkey=…` secret, see `src/lib/developer-area/link-access.ts` — is admitted + * too, so the owner's bookmarked link opens this subtree with no sign-in at all. + * That credential is additive: it admits nobody the administrator claim would + * have admitted less of, and it is off entirely unless `DEVELOPER_AREA_ACCESS_KEY` + * is configured at sufficient strength. + * * The authorized branch wraps `children` in `DeveloperAreaRouteGuard`, which * re-runs this check on every client-side navigation between the subtree's * own sibling pages, because the App Router does not re-render this shared @@ -33,6 +45,15 @@ export async function DeveloperAreaGate({ children }: { children: ReactNode }) { return <>{children}; } + // The passwordless route. Checked before the Supabase call because it is the + // expected path on the owner's own devices, and because it needs no provider + // round trip to answer. Wrapped in the same route guard as the administrator + // branch so a revoked cookie (the key rotated in Railway) stops working on the + // next client-side navigation rather than at the next hard reload (#L31). + if (await developerLinkAccessGranted()) { + return {children}; + } + const { state, email } = await resolveDeveloperAccessState(); if (state === "authorized") { return {children}; diff --git a/src/lib/developer-area/access.ts b/src/lib/developer-area/access.ts index aa3ee7933e..aebd974225 100644 --- a/src/lib/developer-area/access.ts +++ b/src/lib/developer-area/access.ts @@ -1,7 +1,10 @@ import "server-only"; +import { cookies } from "next/headers"; + import { isAdministratorUser } from "@/lib/authorization"; import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { DEVELOPER_ACCESS_COOKIE, developerAccessTokenValid } from "@/lib/developer-area/link-access"; export type DeveloperAccessState = "authorized" | "unauthenticated" | "unauthorized"; @@ -48,3 +51,23 @@ export async function resolveDeveloperAccessState(): Promise { + const cookieStore = await cookies(); + return developerAccessTokenValid(cookieStore.get(DEVELOPER_ACCESS_COOKIE)?.value); +} diff --git a/src/lib/developer-area/link-access.ts b/src/lib/developer-area/link-access.ts new file mode 100644 index 0000000000..17444e09ec --- /dev/null +++ b/src/lib/developer-area/link-access.ts @@ -0,0 +1,152 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Passwordless, link-based access to the developer-gated `/mockups` subtrees. + * + * The owner asked for a Developer Section that does not ask him to sign in, and + * the two obvious answers were both wrong. Deleting `DeveloperAreaGate` would + * publish the task ledger, the hazard notes, and the Ward Flow / Care Plan / + * Caring Contact prototypes to anyone who visits `psychiatry.tools` — that is + * exactly #L30, reintroduced deliberately. Keeping only the Supabase sign-in + * leaves the magic-link round trip he asked to be rid of. + * + * So this is a third credential, alongside the administrator claim and never + * replacing it: a secret carried in the URL once, exchanged by `src/proxy.ts` + * for a long-lived signed cookie, after which the subtree opens with no + * interaction at all on that device. + * + * **The cookie is not the key.** It carries an HMAC-SHA256 signature over its + * own issue time, keyed by `DEVELOPER_AREA_ACCESS_KEY`, so a stolen cookie + * cannot be turned back into the key, and rotating the key in Railway revokes + * every device at once — the only revocation this design has, which is why it + * is written down here rather than left to be rediscovered. + * + * **It fails closed in every unset or under-strength case.** No key configured, + * a key below `MIN_DEVELOPER_ACCESS_KEY_LENGTH`, a malformed token, a token + * signed under a different key: all resolve to "not granted", and the visitor + * gets the ordinary sign-in screen. A short key is rejected rather than + * accepted-with-a-warning because this secret travels in a URL, where it is + * visible in a browser's history and in any screen share. + */ + +/** Cookie the proxy issues once the URL secret verifies. */ +export const DEVELOPER_ACCESS_COOKIE = "psychsift_developer_access"; + +/** Query parameter carrying the secret, e.g. `/mockups/development?devkey=…`. */ +export const DEVELOPER_ACCESS_QUERY_PARAM = "devkey"; + +/** + * Cookie path. Scoped to `/mockups` rather than `/` so this credential is never + * transmitted on a clinical request — it has no business being attached to a + * search, a document read, or an API call, and a cookie that is never sent + * cannot leak from those requests' logs or proxies. + */ +export const DEVELOPER_ACCESS_COOKIE_PATH = "/mockups"; + +/** + * One year. Deliberately inside the ~400-day ceiling Chrome and Safari clamp + * `Set-Cookie` lifetimes to, so the stated expiry is the real one rather than a + * ten-year value the browser silently truncates. `src/proxy.ts` re-issues the + * cookie on every verified request, so a device used at least once a year never + * sees the sign-in screen again — which is the whole point of the feature. + */ +export const DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; + +/** + * Minimum accepted key length. 32 characters of the `openssl rand -base64 32` + * output the setup docs hand out; anything shorter is treated as unconfigured. + */ +export const MIN_DEVELOPER_ACCESS_KEY_LENGTH = 32; + +/** Version tag inside the token, so a future format change is distinguishable rather than ambiguous. */ +const TOKEN_VERSION = "v1"; + +/** + * The configured secret, or null when the feature is off. + * + * Injectable rather than reading `process.env` at module scope so the guard can + * be tested without a live environment, matching `developerGateBypassAllowed`. + */ +export function resolveDeveloperAccessKey( + environment: Record = process.env, +): string | null { + const trimmed = environment.DEVELOPER_AREA_ACCESS_KEY?.trim(); + if (!trimmed || trimmed.length < MIN_DEVELOPER_ACCESS_KEY_LENGTH) return null; + return trimmed; +} + +/** The exact bytes the signature covers: version and issue time together, so neither can move. */ +function signedMessage(issuedAtSeconds: number) { + return `${TOKEN_VERSION}.${issuedAtSeconds}`; +} + +/** + * Mints the cookie value for a verified visitor. + * Format: `v1..`. + * Returns null when no key is configured (fail closed — never an unsigned token). + */ +export function issueDeveloperAccessToken( + environment: Record = process.env, + nowMs: number = Date.now(), +): string | null { + const key = resolveDeveloperAccessKey(environment); + if (!key) return null; + const issuedAtSeconds = Math.floor(nowMs / 1000); + const signature = createHmac("sha256", key).update(signedMessage(issuedAtSeconds)).digest("base64url"); + return `${TOKEN_VERSION}.${issuedAtSeconds}.${signature}`; +} + +/** Constant-time equality; never let comparison timing reveal how much of a forgery was right. */ +function signatureMatches(provided: string, expected: string): boolean { + try { + const a = Buffer.from(provided, "utf8"); + const b = Buffer.from(expected, "utf8"); + return a.length === b.length && timingSafeEqual(a, b); + } catch { + return false; + } +} + +/** + * Whether a cookie value is a token this deployment issued and still honours. + * + * A token older than `DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS` is rejected even + * if the browser kept sending it: the expiry must be enforced by the server that + * minted it, not left to the client that stores it. + */ +export function developerAccessTokenValid( + token: string | undefined | null, + environment: Record = process.env, + nowMs: number = Date.now(), +): boolean { + const key = resolveDeveloperAccessKey(environment); + if (!key || !token) return false; + + const parts = token.split("."); + if (parts.length !== 3) return false; + const [version, issuedAtRaw, signature] = parts; + if (version !== TOKEN_VERSION || !issuedAtRaw || !signature) return false; + if (!/^\d+$/.test(issuedAtRaw)) return false; + + const issuedAtSeconds = Number(issuedAtRaw); + if (!Number.isSafeInteger(issuedAtSeconds)) return false; + + const ageSeconds = Math.floor(nowMs / 1000) - issuedAtSeconds; + if (ageSeconds > DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS) return false; + // A token stamped in the future is a clock skew or a forgery attempt; a small + // tolerance covers the former without accepting an indefinitely post-dated one. + if (ageSeconds < -300) return false; + + const expected = createHmac("sha256", key).update(signedMessage(issuedAtSeconds)).digest("base64url"); + return signatureMatches(signature, expected); +} + +/** Whether a secret presented in a URL is the configured key. Constant-time, and false when unset. */ +export function developerAccessKeyMatches( + presented: string | undefined | null, + environment: Record = process.env, +): boolean { + const key = resolveDeveloperAccessKey(environment); + if (!key || !presented) return false; + return signatureMatches(presented, key); +} diff --git a/src/lib/env.ts b/src/lib/env.ts index 7f43c3b792..b0fa299f47 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -68,6 +68,14 @@ const envSchema = z.object({ LOCAL_NO_AUTH_OWNER_EMAIL: z.string().optional(), LOCAL_NO_AUTH_OWNER_ID: z.string().uuid().optional(), NEXT_PUBLIC_MOCKUPS_ENABLED: z.enum(["true", "false"]).optional(), + // Passwordless access to the developer-gated /mockups subtrees: the secret a + // bookmarked `?devkey=…` link presents once, which src/proxy.ts exchanges for a + // signed, long-lived cookie. Server-only and never NEXT_PUBLIC_ — a public + // build-time flag opening this area is precisely #L30. Optional: unset means + // the link route is off and the administrator sign-in is the only way in. The + // 32-character floor is enforced rather than advisory because this secret + // travels in a URL, where it is visible in browser history and screen shares. + DEVELOPER_AREA_ACCESS_KEY: z.string().min(32).optional(), // Keep `z.` at the call site so `check-env-parity` parseEnvSchemaNames sees these names. NEXT_PUBLIC_SENTRY_DSN: z.preprocess(coerceBlankUrlEnv, z.string().url().optional()), NEXT_PUBLIC_SENTRY_RELEASE: z.string().optional(), diff --git a/src/proxy.ts b/src/proxy.ts index a71c55e773..24f055eda6 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -12,6 +12,15 @@ import { DEVELOPER_AREA_PATH_HEADER, DEVELOPER_GATED_PATH_PREFIXES, } from "@/lib/developer-area/headers"; +import { + DEVELOPER_ACCESS_COOKIE, + DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS, + DEVELOPER_ACCESS_COOKIE_PATH, + DEVELOPER_ACCESS_QUERY_PARAM, + developerAccessKeyMatches, + developerAccessTokenValid, + issueDeveloperAccessToken, +} from "@/lib/developer-area/link-access"; import { readSearchNavigationContext } from "@/lib/search-navigation-context"; import { buildContentSecurityPolicy, resolveRuntimeFlags } from "@/lib/security-headers"; import { signProxyAuthPayload } from "@/lib/supabase/proxy-auth-crypto"; @@ -200,9 +209,26 @@ export async function proxy(request: NextRequest) { } return headers; }; - // Every response the browser sees must carry the enforced CSP header. + // Rolling renewal of the passwordless developer-area cookie. Browsers clamp + // `Set-Cookie` lifetimes to roughly 400 days, so a cookie issued once would + // quietly expire and put the sign-in screen back in front of the owner about a + // year later — the exact outcome this feature exists to prevent. Re-stamping it + // on every verified visit means a device used at least once a year never needs + // the link again. It renews only what already verifies: an absent, expired, or + // forged cookie yields null here and falls through to `DeveloperAreaGate`. + const developerAccessRenewal = + isDeveloperGatedPath(pathname) && developerAccessTokenValid(request.cookies.get(DEVELOPER_ACCESS_COOKIE)?.value) + ? issueDeveloperAccessToken() + : null; + + // Every response the browser sees must carry the enforced CSP header — and, on + // a gated path held open by a valid access cookie, the renewed cookie. Stamped + // here rather than on one early-returned response so the renewal cannot skip + // the Supabase session refresh below: an administrator who is ALSO using the + // link must keep having their session cookie rotated like everyone else. const withCsp = (response: NextResponse) => { response.headers.set("content-security-policy", csp); + if (developerAccessRenewal) setDeveloperAccessCookie(response, developerAccessRenewal, request); return response; }; @@ -280,6 +306,29 @@ export async function proxy(request: NextRequest) { return withCsp(new NextResponse(null, { status: 404 })); } + // Passwordless developer-area access, step one: a gated path carrying + // `?devkey=…` exchanges the secret for the long-lived signed cookie and is + // redirected to the same URL without it, so the key never lingers in the + // address bar, in the history entry that gets shared, or in a Referer header + // sent onward. The redirect happens whether or not the secret verifies — a + // wrong key must not survive into `DEVELOPER_AREA_PATH_HEADER` and reappear as + // the `next` value on the sign-in screen. + // + // This is a third credential beside the administrator claim, never a + // replacement: `DeveloperAreaGate` still admits a signed-in administrator, and + // still refuses a visitor holding neither. `NEXT_PUBLIC_MOCKUPS_ENABLED` is + // untouched by any of it (#L30). See `src/lib/developer-area/link-access.ts` + // for why the cookie carries a signature rather than the key itself. + if (isDeveloperGatedPath(pathname) && request.nextUrl.searchParams.has(DEVELOPER_ACCESS_QUERY_PARAM)) { + const presented = request.nextUrl.searchParams.get(DEVELOPER_ACCESS_QUERY_PARAM); + const url = request.nextUrl.clone(); + url.searchParams.delete(DEVELOPER_ACCESS_QUERY_PARAM); + const response = withCsp(NextResponse.redirect(url)); + const token = developerAccessKeyMatches(presented) ? issueDeveloperAccessToken() : null; + if (token) setDeveloperAccessCookie(response, token, request); + return response; + } + const url = env.NEXT_PUBLIC_SUPABASE_URL; const key = env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY; const hasAuthCookie = request.cookies.getAll().some((cookie) => cookie.name.startsWith("sb-")); @@ -334,6 +383,24 @@ export async function proxy(request: NextRequest) { return withCsp(response); } +/** + * Writes the developer-area access cookie onto a response. + * + * `secure` is derived from the request's own protocol rather than pinned true: + * a local `http://` dev server must be able to hold the cookie too, and a + * `Secure` cookie set over http is silently dropped by the browser. Every real + * deployment is https, so this is https in practice. + */ +function setDeveloperAccessCookie(response: NextResponse, token: string, request: NextRequest) { + response.cookies.set(DEVELOPER_ACCESS_COOKIE, token, { + httpOnly: true, + sameSite: "lax", + secure: request.nextUrl.protocol === "https:", + path: DEVELOPER_ACCESS_COOKIE_PATH, + maxAge: DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS, + }); +} + export function shouldBlockProductionMockups( pathname: string, environment: Record = process.env, diff --git a/tests/developer-area-access.test.ts b/tests/developer-area-access.test.ts index a17ffb07c8..b5234102ad 100644 --- a/tests/developer-area-access.test.ts +++ b/tests/developer-area-access.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { issueDeveloperAccessToken } from "../src/lib/developer-area/link-access"; + // resolveDeveloperAccessState() is the real authorization decision behind the // Development hub and Caring Contact routes in production (src/proxy.ts and // mockups/layout.tsx only decide which requests reach it). It must distinguish @@ -7,9 +9,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; // administrator session — because the middle case (someone else's ordinary // self-serve account) must NOT be treated the same as "please sign in". +const originalAccessKey = process.env.DEVELOPER_AREA_ACCESS_KEY; + afterEach(() => { vi.restoreAllMocks(); vi.resetModules(); + if (originalAccessKey === undefined) delete process.env.DEVELOPER_AREA_ACCESS_KEY; + else process.env.DEVELOPER_AREA_ACCESS_KEY = originalAccessKey; }); async function loadWithSupabaseUser( @@ -109,3 +115,49 @@ describe("developerGateBypassAllowed", () => { ).toBe(false); }); }); + +describe("developerLinkAccessGranted", () => { + // The passwordless credential the gate checks BEFORE Supabase. It must admit + // only a cookie this deployment signed -- an absent, forged, or + // differently-keyed value has to fall through to the sign-in screen, because + // failing open here publishes the developer area to the internet. + const KEY = "0123456789abcdef0123456789abcdef"; + + async function loadWithCookie(value: string | undefined) { + vi.doMock("server-only", () => ({})); + vi.doMock("next/headers", () => ({ + cookies: vi.fn(async () => ({ get: (name: string) => (value === undefined ? undefined : { name, value }) })), + })); + vi.doMock("@/lib/supabase/server", () => ({ createSupabaseServerClient: vi.fn(async () => null) })); + return import("../src/lib/developer-area/access"); + } + + it("grants access for a cookie signed by this deployment", async () => { + process.env.DEVELOPER_AREA_ACCESS_KEY = KEY; + const token = issueDeveloperAccessToken({ DEVELOPER_AREA_ACCESS_KEY: KEY }) as string; + const { developerLinkAccessGranted } = await loadWithCookie(token); + + await expect(developerLinkAccessGranted()).resolves.toBe(true); + }); + + it("refuses an absent cookie, a forged one, and one signed under a rotated key", async () => { + process.env.DEVELOPER_AREA_ACCESS_KEY = KEY; + const foreign = issueDeveloperAccessToken({ + DEVELOPER_AREA_ACCESS_KEY: "fedcba9876543210fedcba9876543210", + }) as string; + + for (const value of [undefined, "v1.1.forged", foreign]) { + vi.resetModules(); + const { developerLinkAccessGranted } = await loadWithCookie(value); + await expect(developerLinkAccessGranted()).resolves.toBe(false); + } + }); + + it("refuses every cookie when the deployment configures no key (fail closed)", async () => { + const token = issueDeveloperAccessToken({ DEVELOPER_AREA_ACCESS_KEY: KEY }) as string; + delete process.env.DEVELOPER_AREA_ACCESS_KEY; + const { developerLinkAccessGranted } = await loadWithCookie(token); + + await expect(developerLinkAccessGranted()).resolves.toBe(false); + }); +}); diff --git a/tests/developer-area-gate.dom.test.tsx b/tests/developer-area-gate.dom.test.tsx index 04cb588b4e..1ad5b98c23 100644 --- a/tests/developer-area-gate.dom.test.tsx +++ b/tests/developer-area-gate.dom.test.tsx @@ -10,6 +10,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; * `developerGateBypassAllowed()` predicate directly; this file proves the * gate component actually consults it — and falls through to * `resolveDeveloperAccessState()` otherwise — end to end. + * + * It also covers the passwordless link credential (`developerLinkAccessGranted`, + * the `?devkey=…` cookie): that it opens the subtree without a Supabase call, + * and that it is additive — an unauthorised visitor holding no cookie still + * meets the gate screen exactly as before. */ const mocks = vi.hoisted(() => ({ @@ -18,6 +23,7 @@ const mocks = vi.hoisted(() => ({ state: "authorized" as "authorized" | "unauthenticated" | "unauthorized", email: null as string | null, }, + linkAccessGranted: false, pathname: "/mockups/development", routerRefresh: vi.fn(), })); @@ -33,6 +39,7 @@ vi.mock("next/navigation", () => ({ vi.mock("@/lib/developer-area/access", () => ({ developerGateBypassAllowed: vi.fn(() => mocks.bypassAllowed), + developerLinkAccessGranted: vi.fn(async () => mocks.linkAccessGranted), resolveDeveloperAccessState: vi.fn(async () => mocks.accessResult), })); @@ -44,6 +51,7 @@ afterEach(() => { cleanup(); vi.restoreAllMocks(); mocks.bypassAllowed = false; + mocks.linkAccessGranted = false; mocks.accessResult = { state: "authorized", email: null }; mocks.pathname = "/mockups/development"; mocks.routerRefresh.mockClear(); @@ -108,3 +116,36 @@ describe("DeveloperAreaGate", () => { expect(screen.getByTestId("gate-screen")).toHaveTextContent("unauthorized"); }); }); + +describe("DeveloperAreaGate passwordless link access", () => { + it("renders children on a valid access cookie without consulting Supabase at all", async () => { + mocks.linkAccessGranted = true; + // Would render the gate screen if the link credential were ignored, so this + // proves the cookie is what admitted the visitor. + mocks.accessResult = { state: "unauthenticated", email: null }; + const { resolveDeveloperAccessState } = await import("@/lib/developer-area/access"); + const { DeveloperAreaGate } = await import("@/components/developer-area/developer-area-gate"); + // The module factory's mock is shared across this file, so its call count + // carries earlier tests' calls; clear it so the assertion below is about + // this render only. + vi.mocked(resolveDeveloperAccessState).mockClear(); + + render(await DeveloperAreaGate({ children:

secret

})); + + expect(screen.getByTestId("protected")).toBeInTheDocument(); + expect(screen.queryByTestId("gate-screen")).not.toBeInTheDocument(); + // Checked first and short-circuits: the common case costs no provider call. + expect(resolveDeveloperAccessState).not.toHaveBeenCalled(); + }); + + it("still shows the gate screen when no cookie is held, so the credential is additive only", async () => { + mocks.linkAccessGranted = false; + mocks.accessResult = { state: "unauthorized", email: "someone-else@example.com" }; + const { DeveloperAreaGate } = await import("@/components/developer-area/developer-area-gate"); + + render(await DeveloperAreaGate({ children:

secret

})); + + expect(screen.queryByTestId("protected")).not.toBeInTheDocument(); + expect(screen.getByTestId("gate-screen")).toHaveTextContent("unauthorized"); + }); +}); diff --git a/tests/developer-area-link-access.test.ts b/tests/developer-area-link-access.test.ts new file mode 100644 index 0000000000..7d75ca1c56 --- /dev/null +++ b/tests/developer-area-link-access.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { + DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS, + developerAccessKeyMatches, + developerAccessTokenValid, + issueDeveloperAccessToken, + resolveDeveloperAccessKey, +} from "../src/lib/developer-area/link-access"; + +// The passwordless route into the developer-gated /mockups subtrees. It is a +// real production credential on psychiatry.tools -- the thing standing between +// an unauthenticated visitor and the task ledger, the hazard notes, and the Ward +// Flow / Care Plan / Caring Contact prototypes -- so every way it could fail +// OPEN is asserted here, not just the happy path. + +const KEY = "0123456789abcdef0123456789abcdef"; // exactly the 32-character floor +const OTHER_KEY = "fedcba9876543210fedcba9876543210"; +const configured = { DEVELOPER_AREA_ACCESS_KEY: KEY }; + +describe("resolveDeveloperAccessKey", () => { + it("returns the trimmed key when configured at sufficient length", () => { + expect(resolveDeveloperAccessKey({ DEVELOPER_AREA_ACCESS_KEY: ` ${KEY} ` })).toBe(KEY); + }); + + it("treats an unset, blank, or under-strength key as unconfigured", () => { + expect(resolveDeveloperAccessKey({})).toBeNull(); + expect(resolveDeveloperAccessKey({ DEVELOPER_AREA_ACCESS_KEY: " " })).toBeNull(); + // One character short of the floor. A weak secret in a URL must be refused + // outright rather than accepted as "better than nothing". + expect(resolveDeveloperAccessKey({ DEVELOPER_AREA_ACCESS_KEY: KEY.slice(0, 31) })).toBeNull(); + }); +}); + +describe("issueDeveloperAccessToken", () => { + it("mints a verifiable token under the configured key", () => { + const token = issueDeveloperAccessToken(configured); + expect(token).toMatch(/^v1\.\d+\.[A-Za-z0-9_-]+$/); + expect(developerAccessTokenValid(token, configured)).toBe(true); + }); + + it("mints nothing when no key is configured, rather than an unsigned token", () => { + expect(issueDeveloperAccessToken({})).toBeNull(); + expect(issueDeveloperAccessToken({ DEVELOPER_AREA_ACCESS_KEY: "too-short" })).toBeNull(); + }); +}); + +describe("developerAccessTokenValid", () => { + it("rejects a token signed under a different key (rotation revokes every device)", () => { + const token = issueDeveloperAccessToken(configured); + expect(developerAccessTokenValid(token, { DEVELOPER_AREA_ACCESS_KEY: OTHER_KEY })).toBe(false); + }); + + it("rejects a tampered signature and a tampered issue time", () => { + const now = 1_760_000_000_000; + const token = issueDeveloperAccessToken(configured, now) as string; + const [version, issuedAt, signature] = token.split("."); + + expect(developerAccessTokenValid(`${version}.${issuedAt}.${signature}x`, configured, now)).toBe(false); + // Re-dating a genuine token is the attack the signature covers the issue + // time for: without it, an expired cookie could be renewed by its holder. + expect(developerAccessTokenValid(`${version}.${Number(issuedAt) + 10}.${signature}`, configured, now)).toBe(false); + }); + + it("rejects malformed, empty, and wrong-version values", () => { + for (const value of ["", "v1", "v1.123", "v2.123.sig", "...", "v1..sig", "v1.notanumber.sig"]) { + expect(developerAccessTokenValid(value, configured)).toBe(false); + } + expect(developerAccessTokenValid(undefined, configured)).toBe(false); + expect(developerAccessTokenValid(null, configured)).toBe(false); + }); + + it("rejects every token when no key is configured (fail closed)", () => { + const token = issueDeveloperAccessToken(configured); + expect(developerAccessTokenValid(token, {})).toBe(false); + }); + + it("enforces the expiry server-side, not by trusting the browser to drop the cookie", () => { + const issuedAt = 1_760_000_000_000; + const token = issueDeveloperAccessToken(configured, issuedAt) as string; + const justInside = issuedAt + (DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS - 60) * 1000; + const justOutside = issuedAt + (DEVELOPER_ACCESS_COOKIE_MAX_AGE_SECONDS + 60) * 1000; + + expect(developerAccessTokenValid(token, configured, justInside)).toBe(true); + expect(developerAccessTokenValid(token, configured, justOutside)).toBe(false); + }); + + it("rejects a token stamped far in the future", () => { + const now = 1_760_000_000_000; + const postDated = issueDeveloperAccessToken(configured, now + 86_400_000) as string; + expect(developerAccessTokenValid(postDated, configured, now)).toBe(false); + }); +}); + +describe("developerAccessKeyMatches", () => { + it("accepts the exact configured key and nothing else", () => { + expect(developerAccessKeyMatches(KEY, configured)).toBe(true); + expect(developerAccessKeyMatches(OTHER_KEY, configured)).toBe(false); + // A prefix must not pass: the comparison is length-checked before the + // constant-time compare, so a truncated guess cannot match. + expect(developerAccessKeyMatches(KEY.slice(0, 20), configured)).toBe(false); + }); + + it("matches nothing when unconfigured, including the empty string", () => { + expect(developerAccessKeyMatches(KEY, {})).toBe(false); + expect(developerAccessKeyMatches("", {})).toBe(false); + expect(developerAccessKeyMatches(undefined, configured)).toBe(false); + }); +}); diff --git a/tests/production-readiness-offline.test.ts b/tests/production-readiness-offline.test.ts index 1eb579554a..87cee569e2 100644 --- a/tests/production-readiness-offline.test.ts +++ b/tests/production-readiness-offline.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { clinicalAskReadinessFindings, isProviderFreeCodexCloud, + developerAccessKeyProductionRisk, mockupsGateProductionRisk, openAIReadinessPolicy, validClinicalAskEvidenceArtifact, @@ -191,6 +192,34 @@ describe("production readiness provider policy", () => { ).toBe("playwright-exception"); }); + it("fails a NEXT_PUBLIC_ copy of the developer-area key, which would ship the secret to every visitor", () => { + // Next.js inlines NEXT_PUBLIC_ values into the client bundle. The name is + // rejected everywhere, not only in production, because a build made with it + // anywhere carries the secret into whatever it is deployed as. + expect(developerAccessKeyProductionRisk({ NEXT_PUBLIC_DEVELOPER_AREA_ACCESS_KEY: "anything" })).toBe("public-name"); + expect( + developerAccessKeyProductionRisk({ + NODE_ENV: "development", + NEXT_PUBLIC_DEVELOPER_AREA_ACCESS_KEY: "anything", + }), + ).toBe("public-name"); + }); + + it("states the passwordless developer link as an enabled production fact, not a failure", () => { + expect( + developerAccessKeyProductionRisk({ NODE_ENV: "production", DEVELOPER_AREA_ACCESS_KEY: "k".repeat(32) }), + ).toBe("enabled"); + expect( + developerAccessKeyProductionRisk({ VERCEL_ENV: "production", DEVELOPER_AREA_ACCESS_KEY: "k".repeat(32) }), + ).toBe("enabled"); + expect( + developerAccessKeyProductionRisk({ NODE_ENV: "development", DEVELOPER_AREA_ACCESS_KEY: "k".repeat(32) }), + ).toBe("none"); + expect(developerAccessKeyProductionRisk({ NODE_ENV: "production" })).toBe("none"); + // Whitespace is not a configured key. + expect(developerAccessKeyProductionRisk({ NODE_ENV: "production", DEVELOPER_AREA_ACCESS_KEY: " " })).toBe("none"); + }); + it("reports no risk outside production or with the flag unset", () => { expect(mockupsGateProductionRisk({ NODE_ENV: "development", NEXT_PUBLIC_MOCKUPS_ENABLED: "true" })).toBe("none"); expect(mockupsGateProductionRisk({ NODE_ENV: "production" })).toBe("none"); diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 001c4367d6..9016927bc6 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -5,6 +5,12 @@ import { NextRequest } from "next/server"; import { proxy, shouldBlockProductionMockups } from "../src/proxy"; import { env } from "@/lib/env"; import { DEVELOPER_GATED_PATH_PREFIXES } from "@/lib/developer-area/headers"; +import { + DEVELOPER_ACCESS_COOKIE, + DEVELOPER_ACCESS_QUERY_PARAM, + developerAccessTokenValid, + issueDeveloperAccessToken, +} from "@/lib/developer-area/link-access"; import * as ssr from "@supabase/ssr"; import { vi } from "vitest"; @@ -32,6 +38,12 @@ function requestFor(path = "/"): NextRequest { return new NextRequest(new URL(`http://localhost${path}`)); } +function requestWithCookie(path: string, name: string, value: string): NextRequest { + return new NextRequest(new URL(`http://localhost${path}`), { + headers: { cookie: `${name}=${value}` }, + }); +} + function scriptSrcOf(csp: string): string { const directive = csp.split(";").find((d) => d.trim().startsWith("script-src")); if (!directive) throw new Error(`no script-src in CSP: ${csp}`); @@ -407,3 +419,96 @@ describe("developer-gated area comments name the constant instead of counting (L } }); }); + +describe("passwordless developer-area access (?devkey)", () => { + // src/proxy.ts owns the exchange: the URL secret goes in, a signed cookie + // comes back, and the key is stripped from the address bar by a redirect. + // This is a production credential on psychiatry.tools, so the assertions below + // are written around the ways it could fail OPEN or leak the key onward. + const KEY = "0123456789abcdef0123456789abcdef"; + + function withKey(run: () => Promise): Promise { + const previous = process.env.DEVELOPER_AREA_ACCESS_KEY; + process.env.DEVELOPER_AREA_ACCESS_KEY = KEY; + return run().finally(() => { + if (previous === undefined) delete process.env.DEVELOPER_AREA_ACCESS_KEY; + else process.env.DEVELOPER_AREA_ACCESS_KEY = previous; + }); + } + + function accessCookie(response: Response) { + return response.headers.getSetCookie().find((cookie) => cookie.startsWith(`${DEVELOPER_ACCESS_COOKIE}=`)); + } + + it("exchanges a correct key for a signed cookie and redirects the key out of the URL", async () => { + await withKey(async () => { + const response = await proxy(requestFor(`/mockups/development?${DEVELOPER_ACCESS_QUERY_PARAM}=${KEY}`)); + + const location = response.headers.get("location"); + expect(location).toBeTruthy(); + // The whole point of the redirect: the secret must not survive into the + // address bar, the shared history entry, or an onward Referer header. + expect(location).not.toContain(KEY); + expect(location).not.toContain(DEVELOPER_ACCESS_QUERY_PARAM); + + const cookie = accessCookie(response); + expect(cookie).toBeTruthy(); + expect(cookie).toContain("HttpOnly"); + expect(cookie).toContain("SameSite=lax"); + // The cookie is never sent on a clinical request. + expect(cookie).toContain("Path=/mockups"); + // And it carries a signature, not the key. + expect(cookie).not.toContain(KEY); + + const token = cookie!.slice(cookie!.indexOf("=") + 1).split(";")[0]; + expect(developerAccessTokenValid(token, { DEVELOPER_AREA_ACCESS_KEY: KEY })).toBe(true); + }); + }); + + it("issues no cookie for a wrong key, but still strips it from the URL", async () => { + await withKey(async () => { + const response = await proxy(requestFor(`/mockups/development?${DEVELOPER_ACCESS_QUERY_PARAM}=wrong-guess`)); + + expect(accessCookie(response)).toBeUndefined(); + // Stripped anyway, so a failed guess cannot ride the DEVELOPER_AREA_PATH + // header into the sign-in screen's `next` value. + expect(response.headers.get("location")).not.toContain("wrong-guess"); + }); + }); + + it("issues no cookie when the deployment has no key configured", async () => { + const previous = process.env.DEVELOPER_AREA_ACCESS_KEY; + delete process.env.DEVELOPER_AREA_ACCESS_KEY; + try { + const response = await proxy(requestFor(`/mockups/development?${DEVELOPER_ACCESS_QUERY_PARAM}=${KEY}`)); + expect(accessCookie(response)).toBeUndefined(); + } finally { + if (previous !== undefined) process.env.DEVELOPER_AREA_ACCESS_KEY = previous; + } + }); + + it("ignores the parameter outside the developer-gated prefixes", async () => { + await withKey(async () => { + // A look-alike path and an ordinary clinical path must not be able to mint + // this cookie -- only the subtrees the gate actually covers. + for (const path of ["/mockups/care-plan-archive", "/documents"]) { + const response = await proxy(requestFor(`${path}?${DEVELOPER_ACCESS_QUERY_PARAM}=${KEY}`)); + expect(accessCookie(response)).toBeUndefined(); + } + }); + }); + + it("renews a valid cookie on an ordinary gated visit, and renews nothing for a forged one", async () => { + await withKey(async () => { + const token = issueDeveloperAccessToken({ DEVELOPER_AREA_ACCESS_KEY: KEY }) as string; + + const renewed = await proxy(requestWithCookie("/mockups/development", DEVELOPER_ACCESS_COOKIE, token)); + expect(accessCookie(renewed)).toBeTruthy(); + + // Rolling renewal must extend only what already verifies; a forged value + // is left to be refused by DeveloperAreaGate, never re-stamped as valid. + const forged = await proxy(requestWithCookie("/mockups/development", DEVELOPER_ACCESS_COOKIE, "v1.1.forged")); + expect(accessCookie(forged)).toBeUndefined(); + }); + }); +});