From 97174f470dea8f6deb5c6332cae248800b5e869b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 10:57:42 +0200 Subject: [PATCH] fix(demo): the shared demo account takes no free text and no new record The demo allowlist had grown two entries it should not carry. The demo is one published account every visitor signs into, so `PUT /api/coach/about-me` was an anonymous write of arbitrary text into the Coach prompt, persisted for every later visitor until the next reseed; it is gone, and the baseline step's anamnesis card renders read-only under `DEMO_MODE` so no visitor meets the refusal as a failed confirm. `POST /api/managed-profiles` bought nothing: the route opens with a fresh-MFA requirement the demo account cannot satisfy, so the guardian arm was limited to finishing without the profile either way. The baseline profile write stays, and the block's preamble now describes what is actually admitted and why the two refusals are refusals. --- .../proxy-demo-mode-allowlist.test.ts | 62 ++++++++++++------- src/app/onboarding/[step]/page.tsx | 11 +++- .../__tests__/confirm-screen.test.tsx | 21 ++++++- src/components/onboarding/anamnesis-card.tsx | 21 ++++++- src/components/onboarding/baseline-form.tsx | 8 +++ src/components/onboarding/confirm-screen.tsx | 15 ++++- src/proxy.ts | 54 ++++++++++------ 7 files changed, 145 insertions(+), 47 deletions(-) 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 ( -
+