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 (
-