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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-11 10:57:42 +02:00
1 parent 4c2175a584
commit 97174f470d
7 files changed
+145 -47

No files matched your search

+40 -22
View File
@@ -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", () => {
+10 -1
View File
@@ -102,7 +102,16 @@ function renderScreen(screen: OnboardingStepId, state: OnboardingStateDto) {
return <UnitsScreen state={state} />;
}
if (screen === "confirm") {
return <ConfirmScreen state={state} />;
// `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 (
<ConfirmScreen
state={state}
demoMode={process.env.DEMO_MODE === "true"}
/>
);
}
if (screen === "first-result") {
return <FirstResultScreen state={state} />;
@@ -55,6 +55,7 @@ function state(
function render(
flow: OnboardingStateDto,
modules: Partial<Record<string, boolean>> = {},
demoMode = false,
): string {
currentUser = {
id: "u1",
@@ -68,7 +69,7 @@ function render(
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">
<ConfirmScreen state={flow} />
<ConfirmScreen state={flow} demoMode={demoMode} />
</I18nProvider>
</QueryClientProvider>,
);
@@ -126,3 +127,21 @@ describe("<ConfirmScreen> — Settings links on a first run (C5)", () => {
expect(html).toContain('href="/settings/account"');
});
});
describe("<ConfirmScreen> — 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"');
});
});
+18 -3
View File
@@ -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 (
<div className="bg-card border-border rounded-xl border">
<div
className="bg-card border-border rounded-xl border"
data-slot="onboarding-anamnesis"
data-readonly={readOnly ? "true" : undefined}
>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
@@ -122,7 +137,7 @@ export function AnamnesisCard({
onChange={(e) =>
onChange({ ...value, conditions: e.target.value })
}
disabled={disabled}
disabled={disabled || readOnly}
maxLength={FIELD_MAX}
rows={2}
placeholder={t("onboarding.anamnesis.conditionsPlaceholder")}
@@ -142,7 +157,7 @@ export function AnamnesisCard({
onChange={(e) =>
onChange({ ...value, allergies: e.target.value })
}
disabled={disabled}
disabled={disabled || readOnly}
maxLength={FIELD_MAX}
rows={2}
placeholder={t("onboarding.anamnesis.allergiesPlaceholder")}
@@ -107,6 +107,7 @@ export function BaselineForm({
backHref,
onConfirmed,
confirming = false,
demoMode = false,
}: {
initial: BaselineInitialValues;
backHref?: string;
@@ -114,6 +115,12 @@ export function BaselineForm({
onConfirmed: () => Promise<void>;
/** True while the completion the parent owns is in flight. */
confirming?: boolean;
/**
* True on a `DEMO_MODE` instance, where the shared account's self-context
* write is off the edge allowlist. Makes the anamnesis card read-only so
* the refusal never reaches a visitor as a failed confirm.
*/
demoMode?: boolean;
}) {
const { t } = useTranslations();
const { preference } = useUnitDisplay();
@@ -252,6 +259,7 @@ export function BaselineForm({
value={anamnesis}
onChange={setAnamnesis}
disabled={saving}
readOnly={demoMode}
/>
<StepActions
+14 -1
View File
@@ -92,7 +92,19 @@ export async function runConfirmFinish(args: {
args.navigate(screenHref(nextScreen(written, "confirm") ?? "done"));
}
export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
export function ConfirmScreen({
state,
demoMode = false,
}: {
state: OnboardingStateDto;
/**
* True on a `DEMO_MODE` instance. Resolved by the page (a server
* component) from the server-only env var and threaded down, the way the
* root layout threads it into the shell — no client-side detection and no
* extra request. The baseline step's anamnesis card is read-only there.
*/
demoMode?: boolean;
}) {
const { t, locale } = useTranslations();
const router = useRouter();
const { user } = useAuth();
@@ -327,6 +339,7 @@ export function ConfirmScreen({ state }: { state: OnboardingStateDto }) {
}}
onConfirmed={finish}
confirming={finishing}
demoMode={demoMode}
/>
)}
</section>
+35 -19
View File
@@ -142,13 +142,21 @@ function isPublicPath(pathname: string): boolean {
* PUT must not drag in the layout-reset DELETE on the same path).
*
* The login family is the historical baseline — a demo visitor still has
* to authenticate. The two dashboard entries are display-only preference
* writes: idempotent, user-scoped, Zod-validated, and touching nothing but
* the caller's own `User.dashboardWidgetsJson` blob (chart-overlay toggles
* + comparison-baseline selector). They carry no health data and are safe
* to exercise in the demo so the above-chart toggles work there. This whole
* block only runs under `DEMO_MODE=true`, so production (apps01) is
* unaffected by construction.
* to authenticate. The rest are idempotent, Zod-validated writes that carry
* no health data and no free text, and that land only on the acting record:
* the two dashboard display-preference blobs (chart-overlay toggles +
* comparison-baseline selector), the setup flow's closed-enum answers, its
* step ledger and disclaimer/tour checkpoints, and the baseline profile
* fields the confirm screen submits.
*
* The demo is ONE published account that every visitor signs into, so
* "user-scoped" is not privacy here — whatever one visitor writes is what
* the next one finds until the instance is reseeded. That is tolerable for
* a unit toggle and a date of birth, and it is why free text, anything that
* reaches an AI prompt, and anything creating a second record are refused.
*
* This whole block only runs under `DEMO_MODE=true`, so production (apps01)
* is unaffected by construction.
*/
const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
[
@@ -174,19 +182,27 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
// Neither creates health data.
{ path: "/api/onboarding/disclaimer", method: "POST" },
{ path: "/api/onboarding/tour", method: "POST" },
// v1.39 (Wave C, C6) — the three writes the confirm screen makes. Without
// them a demo visitor who typed a height got a 403 and a generic toast,
// and the "someone I look after" arm could only take "Finish without the
// profile" — only the empty-form/Skip path completed, so the demo did not
// in fact walk the same flow (design spec §Principles 5). None of the
// three creates health data: the caller's own profile fields, the
// encrypted self-context behind the optional anamnesis card, and the
// managed record the guardian arm of the flow exists to create. The
// per-profile routes (`/api/managed-profiles/{id}`, its guardians) are
// different paths and stay closed.
// v1.39 — the baseline write the confirm screen makes. Without it a demo
// visitor who typed a height got a 403 and a generic toast, so only the
// empty-form/Skip path completed and the demo did not in fact walk the
// same flow (design spec §Principles 5). Date of birth, height and sex
// are closed, validated fields on the caller's own record and no health
// data.
//
// Two neighbours of it are deliberately NOT here. The demo is one shared,
// published account, so "the caller's own" is every visitor at once:
// `PUT /api/coach/about-me` is free text that persists for the next
// visitor AND is fed into the Coach prompt — an anonymous prompt-
// injection surface on a public instance. The flow's anamnesis card
// renders read-only under `DEMO_MODE` instead, so no visitor meets it
// as a failed save.
// `POST /api/managed-profiles` opens with `requireFreshMfa`, and the
// demo account has no second factor, so admitting it here would buy
// the guardian arm nothing — the call is refused a layer later either
// way, and the confirm screen already says so.
// The per-profile routes (`/api/managed-profiles/{id}`, its guardians)
// are different paths and stay closed too.
{ path: "/api/auth/profile", method: "PUT" },
{ path: "/api/coach/about-me", method: "PUT" },
{ path: "/api/managed-profiles", method: "POST" },
];
// Legacy route redirects (German → English)