From bea6b9fed48db3adec54b54ebb52de1fe76c789e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 7 Jun 2026 23:33:19 +0200 Subject: [PATCH] feat(demo): allow chart display-pref toggles under demo mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DEMO_MODE block 403s every non-GET /api/* request outside a narrow allowlist, which also caught the above-chart display toggles — the chart-overlay switches and the comparison-baseline selector — even though they only write the caller's own dashboard-prefs blob. Admit PUT /api/dashboard/chart-overlay-prefs and PUT /api/dashboard/widgets in demo mode. The allowlist now pins method per path, so the widgets PUT does not drag in the layout-reset DELETE. The block only runs under DEMO_MODE=true, so production is unaffected. --- .../proxy-demo-mode-allowlist.test.ts | 75 +++++++++++++++++++ src/proxy.ts | 31 ++++++-- 2 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 src/__tests__/proxy-demo-mode-allowlist.test.ts diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts new file mode 100644 index 000000000..6e0043b97 --- /dev/null +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * 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. + * + * This guard locks the allowlist shape in place so a refactor can't + * silently widen it (admitting a data-bearing mutation) or narrow it + * (re-breaking the demo toggles). The whole block is gated on + * `DEMO_MODE === "true"`, so production is unaffected by construction — + * the final case proves the gate. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function makeRequest(pathname: string, method: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { method }); +} + +const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; + +describe("proxy.ts DEMO_MODE mutation allowlist", () => { + beforeEach(() => { + process.env.DEMO_MODE = "true"; + }); + + afterEach(() => { + if (ORIGINAL_DEMO_MODE === undefined) { + delete process.env.DEMO_MODE; + } else { + process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; + } + }); + + it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => { + const res = proxy( + makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"), + ); + expect(res.status).not.toBe(403); + }); + + it("permits the dashboard-widgets PUT (comparison-baseline selector)", () => { + const res = proxy(makeRequest("/api/dashboard/widgets", "PUT")); + expect(res.status).not.toBe(403); + }); + + it("still blocks a health-data mutation (POST /api/measurements)", () => { + const res = proxy(makeRequest("/api/measurements", "POST")); + expect(res.status).toBe(403); + }); + + it("does not drag in the layout-reset DELETE on the allowlisted widgets path", () => { + // The allowlist pins method per path; admitting the widgets PUT + // must not open DELETE (which wipes the user's dashboard layout). + const res = proxy(makeRequest("/api/dashboard/widgets", "DELETE")); + expect(res.status).toBe(403); + }); + + it("is inert when DEMO_MODE is off — production is unaffected", () => { + delete process.env.DEMO_MODE; + // With the demo gate off, the proxy never short-circuits an API + // mutation; the route's own auth/handler runs instead. + const res = proxy(makeRequest("/api/measurements", "POST")); + expect(res.status).not.toBe(403); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index 98daa8a79..382b7c815 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -83,12 +83,29 @@ function isPublicPath(pathname: string): boolean { /** * API paths that are allowed to accept mutations (POST/PUT/PATCH/DELETE) * even when DEMO_MODE is enabled. Everything else is read-only. + * + * Each entry pins the exact path AND the exact method, so admitting one + * verb on a route never opens its siblings (e.g. the dashboard-widgets + * 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. */ -const DEMO_MUTATION_ALLOWLIST = [ - "/api/auth/login", - "/api/auth/passkey/login-options", - "/api/auth/passkey/login-verify", -]; +const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = + [ + { path: "/api/auth/login", method: "POST" }, + { path: "/api/auth/passkey/login-options", method: "POST" }, + { path: "/api/auth/passkey/login-verify", method: "POST" }, + // Chart display-pref toggles above the charts (dashboard + insights): + { path: "/api/dashboard/chart-overlay-prefs", method: "PUT" }, + { path: "/api/dashboard/widgets", method: "PUT" }, + ]; // Legacy route redirects (German → English) const LEGACY_REDIRECTS: Record = { @@ -170,7 +187,9 @@ export function proxy(request: NextRequest) { if ( isApi && isMutation && - !DEMO_MUTATION_ALLOWLIST.some((p) => pathname === p) + !DEMO_MUTATION_ALLOWLIST.some( + (entry) => pathname === entry.path && method === entry.method, + ) ) { return NextResponse.json( {