diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 0f7a6af0d..7853d5751 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -5,11 +5,16 @@ import { join, sep } from "node:path"; /** * Demo-mode mutation allowlist. On a `DEMO_MODE=true` deploy the proxy - * 403s every non-GET `/api/*` request except a narrow allowlist. Login - * is the historical baseline; the two dashboard display-pref writes - * (chart-overlay toggles + comparison-baseline selector) were added so - * the above-chart toggles work in the demo. They touch nothing but the - * caller's own `User.dashboardWidgetsJson` blob. + * 403s every non-GET `/api/*` request except a narrow allowlist. Login is + * the historical baseline; the rest are the writes the demo has to make to + * walk its own flows — the two dashboard display-pref blobs, the setup + * flow's closed-enum answers and checkpoints, and the baseline profile + * fields. None carries health data or free text. + * + * The demo is one published account every visitor signs into, so what a + * visitor writes is what the next visitor finds. The refusals below are as + * much of the contract as the admissions: free text that reaches the Coach + * prompt, and anything that creates a second record, stay out. * * This guard locks the allowlist shape in place so a refactor can't * silently widen it (admitting a data-bearing mutation) or narrow it @@ -106,23 +111,36 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(missing, "onboarding writes the demo cannot make").toEqual([]); }); - it("permits the baseline writes the confirm screen makes (M-m)", () => { - // v1.39 (Wave C, C6) — a demo visitor who typed a height got a 403 and a - // generic toast, and "someone I look after" could only take "Finish - // without the profile": the three writes the confirm screen makes were - // not on the list. None of them is health data — the account's own - // profile fields, the encrypted self-context the anamnesis card writes, - // and the managed record the flow's guardian arm creates. - for (const [path, method] of [ - ["/api/auth/profile", "PUT"], - ["/api/coach/about-me", "PUT"], - ["/api/managed-profiles", "POST"], - ] as const) { - expect( - proxy(makeRequest(path, method)).status, - `${method} ${path} is blocked in the demo`, - ).not.toBe(403); - } + it("permits the profile write the baseline step makes", () => { + // v1.39 — a demo visitor who typed a height got a 403 and a generic + // toast, because the confirm screen's own write was not on the list. + // Date of birth, height and sex are closed, validated fields on the + // account's own record and no health data, so the step can complete. + expect( + proxy(makeRequest("/api/auth/profile", "PUT")).status, + "PUT /api/auth/profile is blocked in the demo", + ).not.toBe(403); + }); + + it("refuses the free-text self-context write, which the demo shares", () => { + // The demo is ONE published account every visitor signs into. Free text + // that persists for the next visitor is not a demo affordance, and this + // particular text is fed into the Coach prompt — an anonymous write into + // an LLM prompt on a public instance, held until the next reseed. The + // anamnesis card renders read-only in the demo instead, so nothing here + // reaches a visitor as a failed save. + expect(proxy(makeRequest("/api/coach/about-me", "PUT")).status).toBe(403); + }); + + it("refuses the managed-profile create, which needs a factor the demo has not got", () => { + // `POST /api/managed-profiles` opens with `requireFreshMfa`. The demo + // account has no second factor, so admitting it at the edge buys the + // guardian arm nothing: the call is refused a layer later either way, + // and the confirm screen already says the profile could not be created + // without one. + expect(proxy(makeRequest("/api/managed-profiles", "POST")).status).toBe( + 403, + ); }); it("does not drag in a sibling verb on the baseline paths", () => { diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx index 73f4e8efe..9a2002b98 100644 --- a/src/app/onboarding/[step]/page.tsx +++ b/src/app/onboarding/[step]/page.tsx @@ -102,7 +102,16 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) { return ; } if (screen === "confirm") { - return ; + // `DEMO_MODE` is a server-only env var; resolved here and threaded in, + // as the root layout does for the shell. The confirm screen's anamnesis + // card takes no input on a demo instance, whose account is shared and + // whose self-context write is off the edge allowlist. + return ( + + ); } if (screen === "first-result") { return ; diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx index 1b77b9c6c..5a40ecaf4 100644 --- a/src/components/onboarding/__tests__/confirm-screen.test.tsx +++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx @@ -55,6 +55,7 @@ function state( function render( flow: OnboardingStateDto, modules: Partial> = {}, + demoMode = false, ): string { currentUser = { id: "u1", @@ -68,7 +69,7 @@ function render( return renderToStaticMarkup( - + , ); @@ -126,3 +127,21 @@ describe(" — Settings links on a first run (C5)", () => { expect(html).toContain('href="/settings/account"'); }); }); + +describe(" — the anamnesis card on a shared demo account", () => { + it("takes free text on an ordinary instance", () => { + const html = render(state()); + expect(html).toContain('data-slot="onboarding-anamnesis"'); + expect(html).not.toContain('data-readonly="true"'); + }); + + it("is read-only in the demo, whose account every visitor shares", () => { + // `PUT /api/coach/about-me` is off the demo allowlist: the text persists + // for the next visitor and is fed into the Coach prompt. Offering the + // box and refusing the save would be the generic-failure toast this + // flow exists to remove, so the box does not take input at all. + const html = render(state(), {}, true); + expect(html).toContain('data-slot="onboarding-anamnesis"'); + expect(html).toContain('data-readonly="true"'); + }); +}); diff --git a/src/components/onboarding/anamnesis-card.tsx b/src/components/onboarding/anamnesis-card.tsx index 869345467..c0ca90ba8 100644 --- a/src/components/onboarding/anamnesis-card.tsx +++ b/src/components/onboarding/anamnesis-card.tsx @@ -58,6 +58,16 @@ export interface AnamnesisCardProps { value: AnamnesisValue; onChange: (next: AnamnesisValue) => void; disabled?: boolean; + /** + * v1.39 — the instance cannot persist what is typed here, so nothing may + * be typed. True under `DEMO_MODE`: the demo is one published account + * every visitor signs into, and `PUT /api/coach/about-me` is off the + * demo's edge allowlist because this text persists for the next visitor + * and is fed into the Coach prompt. The card still shows what it asks — + * the demo walks the same screen — it just does not take an answer, which + * is honest where a refused save on Confirm would not be. + */ + readOnly?: boolean; } /** Per-field cap mirrors ABOUT_ME_FIELD_MAX_CHARS on the server. */ @@ -67,6 +77,7 @@ export function AnamnesisCard({ value, onChange, disabled, + readOnly = false, }: AnamnesisCardProps) { const { t } = useTranslations(); const [expanded, setExpanded] = useState(false); @@ -75,7 +86,11 @@ export function AnamnesisCard({ const panelId = useId(); return ( -
+