From 392513bf3b9fdd0a8f1ba4c27548da58f7c4380a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 13:36:45 +0000 Subject: [PATCH 1/3] Open the developer area with a bookmarked link instead of a sign-in The owner asked for a Developer Section that does not ask him to sign in. Deleting DeveloperAreaGate would have published the task ledger, the hazard notes and the Ward Flow / Care Plan / Caring Contact prototypes to anyone visiting psychiatry.tools -- #L30 reintroduced on purpose -- so this adds a third credential beside the administrator claim rather than removing it. DEVELOPER_AREA_ACCESS_KEY (server-only, 32-character floor) is presented once per device as `/mockups/development?devkey=...`. src/proxy.ts verifies it, sets a signed cookie and redirects the key out of the URL so it does not survive in the address bar, the shared history entry or a Referer header. The cookie carries an HMAC over its own issue time, not the key, so it cannot be re-dated or turned back into the secret; the proxy re-issues it on every verified visit, inside the ~400-day ceiling browsers clamp Set-Cookie to, so a device used once a year never sees the sign-in again. The credential is additive and fails closed everywhere: unset, under-strength, malformed, expired, or signed under a rotated key all fall through to the existing sign-in screen. Rotating the key in Railway is the revocation. Reaching the page is all it grants -- environment-facts and corpus-health still check the administrator claim themselves and degrade to "unavailable" for a link holder. check:production-readiness now fails a NEXT_PUBLIC_ copy of the key (Next.js would inline it into the client bundle) and states an enabled key as a release-time fact. docs/developer-area-access.md covers setup, per-device use, what the link does not grant, and revocation. Test fixtures are built from readable words with padEnd(32, "-") rather than written as 32-character hex literals. The first version of this branch failed Gitleaks with five generic-api-key findings on exactly those literals, which is a true positive for the rule: a random-looking high-entropy string assigned to a name like KEY is the shape it exists to catch. Nothing in the tests depended on the values looking random, so the fixtures changed rather than the allowlist. Squashed to one commit for the same reason -- a range scan reads every commit's diff, so the offending blob had to leave the branch's history, not just its tip. Verified: verify:cheap exit 0; lint and typecheck pass (7464 input files each); prettier --check . clean whole-tree; the five affected suites green (76 tests); pinned Gitleaks 8.24.3 reproduces the original failure (leaks found: 5) and reports none over this branch's range. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K8kRjars99uHE46MRH28rg --- .env.example | 8 + data/repo-awareness-snapshot.json | 63 +++++++- docs/README.md | 1 + docs/developer-area-access.md | 102 ++++++++++++ scripts/production-readiness.ts | 40 +++++ .../developer-area/developer-area-gate.tsx | 23 ++- src/lib/developer-area/access.ts | 23 +++ src/lib/developer-area/link-access.ts | 152 ++++++++++++++++++ src/lib/env.ts | 8 + src/proxy.ts | 69 +++++++- tests/developer-area-access.test.ts | 58 +++++++ tests/developer-area-gate.dom.test.tsx | 41 +++++ tests/developer-area-link-access.test.ts | 115 +++++++++++++ tests/production-readiness-offline.test.ts | 29 ++++ tests/proxy.test.ts | 111 +++++++++++++ 15 files changed, 840 insertions(+), 3 deletions(-) create mode 100644 docs/developer-area-access.md create mode 100644 src/lib/developer-area/link-access.ts create mode 100644 tests/developer-area-link-access.test.ts 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..8829476085 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,55 @@ 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. + // Built from readable words rather than written as a 32-character random-looking + // literal. A high-entropy string assigned to a name like KEY is exactly what the + // Gitleaks `generic-api-key` rule is for, and it fired on this file's first + // version (secret-scan, run 34232733033). The value only has to be a key of + // sufficient length -- nothing here depends on it looking random -- so the fix + // is to stop it resembling a credential, never to allowlist the finding. + const KEY = "developer-area-test-key".padEnd(32, "-"); + + 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: "developer-area-other-key".padEnd(32, "-"), + }) 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..a4dcc26dc7 --- /dev/null +++ b/tests/developer-area-link-access.test.ts @@ -0,0 +1,115 @@ +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. + +// Built from readable words rather than written as a 32-character random-looking +// literal. A high-entropy string assigned to a name like KEY is exactly what the +// Gitleaks `generic-api-key` rule is for, and it fired on this file's first +// version (secret-scan, run 34232733033). The value only has to be a key of +// sufficient length -- nothing here depends on it looking random -- so the fix +// is to stop it resembling a credential, never to allowlist the finding. +const KEY = "developer-area-test-key".padEnd(32, "-"); // exactly the 32-character floor +const OTHER_KEY = "developer-area-other-key".padEnd(32, "-"); +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..75d92bb4ae 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,102 @@ 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. + // Built from readable words rather than written as a 32-character random-looking + // literal. A high-entropy string assigned to a name like KEY is exactly what the + // Gitleaks `generic-api-key` rule is for, and it fired on this file's first + // version (secret-scan, run 34232733033). The value only has to be a key of + // sufficient length -- nothing here depends on it looking random -- so the fix + // is to stop it resembling a credential, never to allowlist the finding. + const KEY = "developer-area-test-key".padEnd(32, "-"); + + 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(); + }); + }); +}); From 57fa44915f93c7de9c4d78de798756d2be7b3c23 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 04:33:35 +0000 Subject: [PATCH 2/3] Rebuild the repo-awareness snapshot after resolving the main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of origin/main (217 commits) conflicted in exactly one file, `data/repo-awareness-snapshot.json`, which is generated. Resolved by taking main's version and regenerating with `npm run snapshot:repo-awareness` rather than hand-editing either side: a hand-merged generated file can parse cleanly while disagreeing with what its generator would produce, which is the failure mode `check:repo-awareness-snapshot` exists to catch. Every other file auto-merged, including the four this branch changes that main also touched (`.env.example`, `src/lib/env.ts`, `scripts/production-readiness.ts`, `tests/production-readiness-offline.test.ts`). The passwordless credential is verified still present and wired after the merge: nine references in `src/proxy.ts`, the gate's `developerLinkAccessGranted` branch, and `DEVELOPER_AREA_ACCESS_KEY` in the env schema, `.env.example` and the readiness check. Verified: the five affected suites pass, 86 tests (up from 76 — main added cases to the shared production-readiness file this branch also edits). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K8kRjars99uHE46MRH28rg --- data/repo-awareness-snapshot.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/data/repo-awareness-snapshot.json b/data/repo-awareness-snapshot.json index 71702f24ff..57bb6030c3 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-11" + "committed_at": "2026-09-12" }, "routes": { "modes": [ @@ -1634,6 +1634,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", From bfa7307d2e3517e0d052e4126acda6e6abf6510b Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:00:42 +0800 Subject: [PATCH 3/3] Fix passwordless developer link review findings --- docs/developer-area-access.md | 2 +- scripts/production-readiness.ts | 3 +- src/lib/developer-area/link-access.ts | 5 +-- src/lib/env.ts | 16 ++++++++- src/proxy.ts | 40 +++++++++------------- tests/env.test.ts | 22 ++++++++++++ tests/production-readiness-offline.test.ts | 5 +++ tests/proxy.test.ts | 24 +++++++++++++ 8 files changed, 89 insertions(+), 28 deletions(-) create mode 100644 tests/env.test.ts diff --git a/docs/developer-area-access.md b/docs/developer-area-access.md index 7b79cdde97..eb17d9875d 100644 --- a/docs/developer-area-access.md +++ b/docs/developer-area-access.md @@ -33,7 +33,7 @@ alone must never open it, which was incident `#L30`. Railway variable `DEVELOPER_AREA_ACCESS_KEY` on the `Database` service: ```bash -openssl rand -base64 32 +openssl rand -hex 32 ``` Minimum 32 characters, enforced in `src/lib/env.ts` and again in diff --git a/scripts/production-readiness.ts b/scripts/production-readiness.ts index b6bbcb0595..1d09b427f8 100644 --- a/scripts/production-readiness.ts +++ b/scripts/production-readiness.ts @@ -6,6 +6,7 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { loadEnvConfig } from "@next/env"; +import { resolveDeveloperAccessKey } from "../src/lib/developer-area/link-access"; import { checkSupabaseProjectConfig } from "@/lib/supabase/project"; import { checkNodeRuntime as checkStrictNodeRuntime } from "./check-runtime"; @@ -396,7 +397,7 @@ export function developerAccessKeyProductionRisk( ): "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"; + if (!productionLike || !resolveDeveloperAccessKey(environment)) return "none"; return "enabled"; } diff --git a/src/lib/developer-area/link-access.ts b/src/lib/developer-area/link-access.ts index 17444e09ec..43e25bfd5a 100644 --- a/src/lib/developer-area/link-access.ts +++ b/src/lib/developer-area/link-access.ts @@ -53,8 +53,9 @@ export const DEVELOPER_ACCESS_COOKIE_PATH = "/mockups"; 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. + * Minimum accepted key length. The setup docs generate a 64-character + * URL-safe hexadecimal value with `openssl rand -hex 32`; anything shorter is + * treated as unconfigured. */ export const MIN_DEVELOPER_ACCESS_KEY_LENGTH = 32; diff --git a/src/lib/env.ts b/src/lib/env.ts index 79646da6d5..0f3750cb7f 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,6 +1,7 @@ import "server-only"; import { z } from "zod"; +import { MIN_DEVELOPER_ACCESS_KEY_LENGTH } from "@/lib/developer-area/link-access"; import { resolvePythonBin } from "@/lib/python-bin"; import { assertExpectedSupabaseProjectConfig, checkSupabaseProjectConfig } from "@/lib/supabase/project"; import { MAX_UPLOAD_MB_CEILING } from "@/lib/upload-limits"; @@ -10,6 +11,16 @@ function coerceBlankEnv(value: unknown): unknown { return typeof value === "string" && value.trim() === "" ? undefined : value; } +/** + * The passwordless developer link is intentionally fail-closed when its key is + * unset or under-strength. Normalizing those values before schema validation + * keeps that runtime fallback reachable instead of preventing proxy startup. + */ +function coerceDeveloperAreaAccessKey(value: unknown): unknown { + if (typeof value !== "string") return value; + return value.trim().length < MIN_DEVELOPER_ACCESS_KEY_LENGTH ? undefined : value; +} + const clinicalAskDisabledModeIds = new Set([ "services", "forms", @@ -82,7 +93,10 @@ const envSchema = z.object({ // 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(), + DEVELOPER_AREA_ACCESS_KEY: z.preprocess( + coerceDeveloperAreaAccessKey, + z.string().min(MIN_DEVELOPER_ACCESS_KEY_LENGTH).optional(), + ), // Keep `z.` at the call site so `check-env-parity` parseEnvSchemaNames sees these names. NEXT_PUBLIC_SENTRY_DSN: z.preprocess(coerceBlankEnv, z.string().url().optional()), NEXT_PUBLIC_SENTRY_RELEASE: z.string().optional(), diff --git a/src/proxy.ts b/src/proxy.ts index 24f055eda6..236e990386 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -232,6 +232,23 @@ export async function proxy(request: NextRequest) { return response; }; + // `?devkey=…` exchanges the secret for the long-lived signed cookie and + // redirects with it removed, so the key never lingers in the address bar, in + // shared history, or in an onward Referer header. This must run before + // compatibility redirects, which otherwise preserve the query string and + // could forward the secret to their target. + 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 redirectTarget = staticRouteRedirects[pathname]; + if (redirectTarget) url.pathname = redirectTarget; + const response = withCsp(NextResponse.redirect(url)); + const token = developerAccessKeyMatches(presented) ? issueDeveloperAccessToken() : null; + if (token) setDeveloperAccessCookie(response, token, request); + return response; + } + const legacyHomeTarget = legacyHomeRedirectUrl(request.nextUrl, request.method); if (legacyHomeTarget) return withCsp(NextResponse.redirect(legacyHomeTarget)); @@ -306,29 +323,6 @@ 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-")); diff --git a/tests/env.test.ts b/tests/env.test.ts new file mode 100644 index 0000000000..2b5fca1588 --- /dev/null +++ b/tests/env.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("developer-area access-key environment parsing", () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it("treats blank and under-strength access keys as unconfigured at module load", async () => { + // Replacing the schema preprocessor with a direct min(32) validation should + // make this import reject. The actual app imports this module before proxy + // authorization can fall back to the administrator gate. + for (const accessKey of ["", "short", "x".repeat(31)]) { + vi.resetModules(); + vi.stubEnv("DEVELOPER_AREA_ACCESS_KEY", accessKey); + + await expect(import("@/lib/env")).resolves.toMatchObject({ + env: { DEVELOPER_AREA_ACCESS_KEY: undefined }, + }); + } + }); +}); diff --git a/tests/production-readiness-offline.test.ts b/tests/production-readiness-offline.test.ts index c321918886..4637bd0ed3 100644 --- a/tests/production-readiness-offline.test.ts +++ b/tests/production-readiness-offline.test.ts @@ -301,6 +301,11 @@ describe("production readiness provider policy", () => { expect(developerAccessKeyProductionRisk({ NODE_ENV: "production" })).toBe("none"); // Whitespace is not a configured key. expect(developerAccessKeyProductionRisk({ NODE_ENV: "production", DEVELOPER_AREA_ACCESS_KEY: " " })).toBe("none"); + // The runtime resolver rejects under-strength values, so readiness must not + // advertise the passwordless link as active for one. + expect(developerAccessKeyProductionRisk({ NODE_ENV: "production", DEVELOPER_AREA_ACCESS_KEY: "short" })).toBe( + "none", + ); }); it("reports no risk outside production or with the flag unset", () => { diff --git a/tests/proxy.test.ts b/tests/proxy.test.ts index 75d92bb4ae..db6d21eec5 100644 --- a/tests/proxy.test.ts +++ b/tests/proxy.test.ts @@ -272,6 +272,30 @@ describe("static compatibility redirects", () => { const location = new URL(response.headers.get("location")!); expect(location.pathname).toBe("/mockups/ward-flow/network"); }); + + it("exchanges a developer key before redirecting a retired gated path", async () => { + const key = "developer-area-test-key".padEnd(32, "-"); + const previous = process.env.DEVELOPER_AREA_ACCESS_KEY; + process.env.DEVELOPER_AREA_ACCESS_KEY = key; + try { + const response = await proxy( + requestFor(`/mockups/ward-flow/constellation?${DEVELOPER_ACCESS_QUERY_PARAM}=${key}&view=network`), + ); + + const location = response.headers.get("location"); + expect(location).toBeTruthy(); + expect(location).toContain("/mockups/ward-flow/network"); + expect(location).toContain("view=network"); + expect(location).not.toContain(DEVELOPER_ACCESS_QUERY_PARAM); + expect(location).not.toContain(key); + expect(response.headers.getSetCookie().some((cookie) => cookie.startsWith(`${DEVELOPER_ACCESS_COOKIE}=`))).toBe( + true, + ); + } finally { + if (previous === undefined) delete process.env.DEVELOPER_AREA_ACCESS_KEY; + else process.env.DEVELOPER_AREA_ACCESS_KEY = previous; + } + }); }); describe("document-source fallback redirects", () => {