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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,14 @@ RAG_AWAIT_QUERY_LOGS=false
# Design-exploration mockup routes (/mockups/*) 404 in production builds unless
# explicitly opted in. Always reachable in dev/test.
#NEXT_PUBLIC_MOCKUPS_ENABLED=false
# Passwordless developer-area access. Set this to open the Development hub,
# Care Plan, Caring Contacts and Ward Flow from a bookmarked link instead of a
# Supabase sign-in: visit any of those paths once as
# /mockups/development?devkey=<this value> and the proxy swaps it for a signed
# cookie that is renewed on every later visit. Minimum 32 characters; generate
# with `openssl rand -base64 32`. Leave unset to require administrator sign-in.
# Rotating this value is the revocation: it invalidates every device at once.
#DEVELOPER_AREA_ACCESS_KEY=
# Content browsing is public. Uploads and corpus-management actions require a
# signed-in user whose Supabase app_metadata.site_role is "administrator".
# Assign that claim only through the approval-gated auth:set-administrator script.
Expand Down
5 changes: 5 additions & 0 deletions data/repo-awareness-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -1643,6 +1643,11 @@
"section": "ward-flow-phase-3-workspace",
"catalogued": true
},
{
"path": "docs/developer-area-access.md",
"section": "root",
"catalogued": true
},
{
"path": "docs/ward-flow-phase-3-rulings.md",
"section": "root",
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ npm run docs:check-links
- [wiring-conventions.md](wiring-conventions.md) — page/button wiring conventions and the dead-button / orphan-route gates
- [search-chrome-behaviour.md](search-chrome-behaviour.md) — shared search-chrome contract: composer ownership, phone edge-to-edge dock, hide/reveal reserves
- [mockup-retirement-policy.md](mockup-retirement-policy.md) — when a mockup may be deleted, who decides, what evidence is required, and the three tiers that keep developer-gated prototypes out of cleanup scope
- [developer-area-access.md](developer-area-access.md) — how the four developer-gated `/mockups` subtrees are protected, the passwordless `?devkey` link and its setup, what the link deliberately does not grant, and how to revoke it
- [search-results-bar-decisions.md](search-results-bar-decisions.md) — shared results-bar anatomy, why the filter shelf is scoped to two modes, and what is deliberately not done
- [deployment-architecture.md](deployment-architecture.md) — app/worker/Supabase deployment topology
- [ingestion-state-machine.md](ingestion-state-machine.md) — ingestion job lifecycle and states (dated 2026-07-07 race analysis; the lease is heartbeated and fenced since 2026-07-08 — see its status banner)
Expand Down
102 changes: 102 additions & 0 deletions docs/developer-area-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Developer area access

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

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

## Two credentials, either of which opens the area

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

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

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

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

## The passwordless link

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

```bash
openssl rand -hex 32
```

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

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

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

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

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

## What the link does not grant

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

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

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

## Revoking access

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

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

## Why the cookie is not the key

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

Implementation: `src/lib/developer-area/link-access.ts`. Tests:
`tests/developer-area-link-access.test.ts` (the credential),
`tests/proxy.test.ts` (the exchange and the renewal),
`tests/developer-area-access.test.ts` and
`tests/developer-area-gate.dom.test.tsx` (the gate).
41 changes: 41 additions & 0 deletions scripts/production-readiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -376,6 +377,45 @@ function recordMockupsGateProductionCheck() {
}
}

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

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

async function checkFileForServiceRoleExposure() {
const envFiles = [".env", ".env.production", ".env.development"];
for (const fileName of envFiles) {
Expand Down Expand Up @@ -436,6 +476,7 @@ async function main() {
recordNoAuthProductionCheck();
recordDemoModeProductionCheck();
recordMockupsGateProductionCheck();
recordDeveloperAccessKeyCheck();
recordRawQueryPersistenceProductionCheck();
recordAnswerPersistenceProductionCheck();
await checkFileForServiceRoleExposure();
Expand Down
23 changes: 22 additions & 1 deletion src/components/developer-area/developer-area-gate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
Expand All @@ -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 <DeveloperAreaRouteGuard>{children}</DeveloperAreaRouteGuard>;
}

const { state, email } = await resolveDeveloperAccessState();
if (state === "authorized") {
return <DeveloperAreaRouteGuard>{children}</DeveloperAreaRouteGuard>;
Expand Down
23 changes: 23 additions & 0 deletions src/lib/developer-area/access.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -48,3 +51,23 @@ export async function resolveDeveloperAccessState(): Promise<DeveloperAccessResu
email: user.email ?? null,
};
}

/**
* Whether this request carries a valid passwordless access cookie — the secret
* link the owner bookmarks, exchanged for a signed cookie by `src/proxy.ts`.
*
* A second, independent credential for the same subtree, checked BEFORE the
* Supabase round trip so the common case (his own laptop, cookie held) renders
* without an auth call at all. It grants exactly what the administrator claim
* grants and nothing more: reaching the page. The panels that read live data
* still require the administrator claim themselves — `environment-facts.ts` and
* `corpus-health.ts` call `isAdministratorUser` on their own, and degrade to
* "unavailable" rather than serving a document count to a link holder. That
* separation is deliberate. Do not "simplify" it by having those panels trust
* this cookie: the link is a convenience credential that can be forwarded in a
* message, and the corpus is the clinical library.
*/
export async function developerLinkAccessGranted(): Promise<boolean> {
const cookieStore = await cookies();
return developerAccessTokenValid(cookieStore.get(DEVELOPER_ACCESS_COOKIE)?.value);
}
Loading
Loading