From fc3b417f403ab83171c09a600e583632f4cf8f65 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 22 Jun 2026 17:07:49 -0300 Subject: [PATCH] fix(api/settings): prevent cached /api/settings responses (port from 9router#951) (#4566) Integrated into release/v3.8.34 (rebuilt onto tip) --- src/app/api/settings/route.ts | 44 ++++++++++++++++++++++----------- tests/unit/settings-api.test.ts | 29 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 811da78b65..00bc0cf398 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -26,6 +26,19 @@ import { isCliTokenAuthValid } from "@/lib/middleware/cliTokenAuth"; import { extractApiKey } from "@/sse/services/auth"; import { getApiKeyMetadata } from "@/lib/db/apiKeys"; +/** + * Force this route to run dynamically per-request and never be cached/prerendered. + * Combined with the `Cache-Control: no-store` response header below, this keeps + * persisted settings (e.g. dashboard preferences, debugMode, hidden sidebar + * items) visible immediately after refresh or restart instead of falling back + * to stale Next.js fetch cache. Ported from upstream decolua/9router#951. + */ +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +/** Response headers applied to every successful GET/PATCH on /api/settings. */ +const SETTINGS_RESPONSE_HEADERS = { "Cache-Control": "no-store" } as const; + /** * Settings keys whose change broadens attack surface. Spec §Security: * password re-auth is required when any of these is present in a PATCH body. @@ -162,19 +175,22 @@ export async function GET(request: Request) { // best effort — don't fail GET /api/settings if this lookup fails } - return NextResponse.json({ - ...safeSettings, - hasPassword: hasManagementPasswordConfigured(settings), - runtimePorts, - apiPort: runtimePorts.apiPort, - dashboardPort: runtimePorts.dashboardPort, - cloudConfigured: Boolean(cloudUrl), - cloudUrl, - machineId, - ...(cliproxyapiModelMapping !== null - ? { cliproxyapi_model_mapping: cliproxyapiModelMapping } - : {}), - }); + return NextResponse.json( + { + ...safeSettings, + hasPassword: hasManagementPasswordConfigured(settings), + runtimePorts, + apiPort: runtimePorts.apiPort, + dashboardPort: runtimePorts.dashboardPort, + cloudConfigured: Boolean(cloudUrl), + cloudUrl, + machineId, + ...(cliproxyapiModelMapping !== null + ? { cliproxyapi_model_mapping: cliproxyapiModelMapping } + : {}), + }, + { headers: SETTINGS_RESPONSE_HEADERS } + ); } catch (error) { console.log("Error getting settings:", error); return NextResponse.json({ error: "Failed to load settings" }, { status: 500 }); @@ -376,7 +392,7 @@ export async function PATCH(request: Request) { } const { password, ...safeSettings } = settings; - return NextResponse.json(safeSettings); + return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS }); } catch (error) { console.log("Error updating settings:", error); return NextResponse.json({ error: "Failed to update settings" }, { status: 500 }); diff --git a/tests/unit/settings-api.test.ts b/tests/unit/settings-api.test.ts index 4c2c8ecc8f..8ff0dd80a1 100644 --- a/tests/unit/settings-api.test.ts +++ b/tests/unit/settings-api.test.ts @@ -209,6 +209,35 @@ describe("Settings API - persisted preferences", () => { assert.equal(settings.responsesPreviousResponseIdMode, "strip"); }); + test("GET /api/settings returns Cache-Control: no-store (ported from upstream #951)", async () => { + const response = await harness.settingsRoute.GET( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "GET", + }) + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Cache-Control"), + "no-store", + "GET /api/settings must return Cache-Control: no-store so persisted settings stay fresh after refresh/restart" + ); + }); + + test("PATCH /api/settings returns Cache-Control: no-store (ported from upstream #951)", async () => { + const response = await harness.settingsRoute.PATCH( + await makeManagementSessionRequest("http://localhost/api/settings", { + method: "PATCH", + body: { debugMode: true }, + }) + ); + assert.equal(response.status, 200); + assert.equal( + response.headers.get("Cache-Control"), + "no-store", + "PATCH /api/settings must return Cache-Control: no-store" + ); + }); + test("PUT /api/settings reuses the PATCH update flow", async () => { const response = await harness.settingsRoute.PUT( await makeManagementSessionRequest("http://localhost/api/settings", {