feat(demo): allow chart display-pref toggles under demo mode

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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-07 23:33:19 +02:00
1 parent 384ca762e8
commit bea6b9fed4
2 files changed
+100 -6

No files matched your search

@@ -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);
});
});
+25 -6
View File
@@ -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<string, string> = {
@@ -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(
{