Files
OmniRoute/src/app/api/settings/route.ts
Diego Rodrigues de Sa e Souza 19d91d82e2 Release v3.8.34 (#4614)
* chore(release): open v3.8.34 development cycle

* chore(quality): release-green pre-flight validator + nightly signal (C+D) (#4622)

C — scripts/quality/validate-release-green.mjs (npm run check:release-green):
reproduces the release-equivalent validation (typecheck, eslint, db-rules,
public-creds, full unit, vitest, ratchets, optional --with-build package-artifact)
against the current working tree and classifies each red as HARD (real defect,
exit 1) vs DRIFT (ratchet — reported, never affects exit / never blocks). Pure
helpers exported + orchestration behind a direct-run guard; unit-tested.

D — .github/workflows/nightly-release-green.yml: runs C on the active release
branch nightly (and on workflow_dispatch) and opens/updates a single tracking
issue on HARD failures. Never a required check, never touches a contributor PR.

Closes the gap where the full gate (ci.yml) only ran on the release PR, so reds
accrued silently on release/** and surfaced in 40-min layers at release time.
Non-blocking by construction; drift is the maintainer's to rebaseline at release.

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>

* fix(providers): show revealed connection API keys (#4583)

Integrated into release/v3.8.34

* fix(resilience): respect upstream retry hint toggle (#4585)

Integrated into release/v3.8.34

* feat(settings): expose stream recovery feature flags (#4586)

Integrated into release/v3.8.34

* fix(logs): make active request stale sweep configurable (#4599)

Integrated into release/v3.8.34

* fix(plugin): auto-prefix providerId with 'opencode-' for OC 1.17.8+ native gate (#4527)

Integrated into release/v3.8.34 (supersedes #4445)

* fix(models): treat unknown output caps as unset (#4584)

Integrated into release/v3.8.34

* fix(executors): strip temperature for GitHub Copilot gpt-5.4 family (#4564)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(oauth): update Qwen OAuth URLs from chat.qwen.ai to qwen.ai (#4561)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(api/settings): prevent cached /api/settings responses (port from 9router#951) (#4566)

Integrated into release/v3.8.34 (rebuilt onto tip)

* feat(audio): MiniMax T2A v2 TTS dispatch in audioSpeech (port #1043) (#4553)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(dashboard): surface manual config CTA when Open Claw CLI auto-detect fails (#4562)

Integrated into release/v3.8.34 (rebuilt onto tip)

* feat(providers): optional model ID for custom API-key validation (#4555)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(cli): align data dir and env loading with runtime (#4607)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(quota): expose Bailian quota windows (#4610)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix: retain provider cooldowns for configured max window (#4588)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix: reject invalid provider cooldown bounds (#4589)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix: preserve production combo metrics on shadow eviction (#4590)

Integrated into release/v3.8.34 (rebuilt — bundled commits stripped)

* fix(stream): estimate input tokens when upstream reports prompt_tokens=0 (#4615)

Integrated into release/v3.8.34 (rebuilt onto tip)

* fix(catalog): shorten no-thinking gateway prefix to no-think/ (#4525)

Integrated into release/v3.8.34 (rebuilt — kept only the prefix rename, dropped stale-base reverts)

* fix(relay): apply IP rate limit to bifrost sidecar (#4593)

Integrated into release/v3.8.34 (rebuilt onto tip; merge before #4612)

* fix(bifrost): finalize SSE relay usage after stream (#4612)

Integrated into release/v3.8.34 (rebuilt + reconciled with #4593)

* feat(compression): per-request `x-omniroute-compression` header (Phase 3) (#4645)

* docs(compression): Phase 3 per-request header design spec

Approved brainstorming output for the x-omniroute-compression header:
header-first precedence, name-first combo matching (Decision A), explicit
value bypasses auto-trigger (Decision B), DerivedPlan.source, and the
X-OmniRoute-Compression response header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compression): Phase 3 per-request header implementation plan

4-task TDD plan (resolver header-first + source, parser, chatCore wiring +
response header, docs/file-size) with full code and exact commands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): header-first resolver + plan source (Phase 3 core)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): resolveCompressionHeader parser (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(compression): wire x-omniroute-compression header + response header (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(compression): extract plan-resolution leaf (planResolution.ts) under size cap (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(compression): document x-omniroute-compression header (Phase 3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(compression): harden named-combo map + trim engine: header id (Phase 3 review)

Addresses gemini-code-assist review on #4645:
- Extract buildNamedComboLookup (pure) so a blank/whitespace/null combo name
  contributes only its id key (no '' key, no throw that disables all combos).
- Trim the engine:<id> header value so 'engine: rtk' resolves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix: exclude exhausted connections from auto scoring (#4592)

Integrated into release/v3.8.34 (rebuilt + opt-in gate fix)

* fix(dashboard): memoize compatible provider groups (#4613)

Integrated into release/v3.8.34 (rebuilt + test added)

* fix(dashboard): isolate quota widget refresh clock (#4611)

Integrated into release/v3.8.34 (rebuilt + jsdom test)

* fix(dashboard): gate topology side effects behind widget visibility (#4606)

Integrated into release/v3.8.34 (rebuilt + jsdom test)

* fix(dashboard): keep play_arrow spinning on provider Test All buttons (#4563)

Integrated into release/v3.8.34 (rebuilt onto tip; UI-cosmetic per owner)

* fix(db): schedule retention cleanup + fix cleanup table/column names (extracted from #4428) (#4691)

Integrated into release/v3.8.34 (cleanup core extracted from #4428, credit @oyi77)

* fix(telemetry): back off live-WS event forwarding when the sidecar is unreachable (#4604) (#4687)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(api): serve GET /v1/models/{model} as JSON, not the HTML dashboard (#4674) (#4677)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* feat(opencode): add go deepseek reasoning variants (#4647)

Integrated into release/v3.8.34

* fix(executors): robust deepseek-web tool-call parsing and agentic context retention (#4644)

Integrated into release/v3.8.34

* fix(cli): authenticate `omniroute logs` and honor active context (#4638)

Integrated into release/v3.8.34 (authored by Rahul Sharma, AI co-author trailer stripped per project policy)

* fix(proxy): apply pipelining:0 + connections cap to the direct dispatcher (#4580) (#4684)

Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>

* fix(executors): Firecrawl web_fetch 500 with include_metadata=true (#4692)

Integrated into release/v3.8.34

* fix(routing): include all noAuth models in auto-combos + add reka-flash + best-free template (#4621)

Integrated into release/v3.8.34 (dead getFirstRegistryModelId dropped, rebuilt onto tip)

* fix(dashboard): gate home topology live-WS networking (#4596) (#4618)

Integrated into release/v3.8.34 (adapted onto #4606's extracted topology section: default-hidden flip + enabled gate on useLiveDashboard)

* fix(cli): align `omniroute` env loading with the runtime data dir (#4597) (#4619)

Integrated into release/v3.8.34 (data-dir.mjs refactor reconciled with #4607; loadEnvFile aligned to getDefaultDataDir)

* chore(quality): reconcile file-size baseline for #4644 (deepseek-web.ts 1117->1125) (#4695)

file-size reconcile for #4644

* Support quota scraping for OpenCode Go and Ollama Cloud (#4642)

Integrated into release/v3.8.34 (Ollama Cloud + OpenCode Go dashboard quota scraping; rebuilt onto tip, gates green: typecheck/public-creds/file-size/lint/docs-sync + 31 tests)

* feat(executors): land M365 Copilot pure framing + connection helpers (#4042) (#4696)

Land M365 pure modules ahead of draft #4400

* deps: bump production + development groups; migrate js-yaml to v5 ESM (#4697)

Incorporates Dependabot #4667 + #4668 + js-yaml v5 ESM migration into release/v3.8.34

* fix: noAuth provider validation + kimi executor routing (#4699)

Integrated into release/v3.8.34 (noAuth in NOAUTH_PROVIDERS dynamic check + remove misrouted kimi web alias; 9 tests)

* refactor(imageGeneration): extract 8 provider families to co-located files (#4609)

Integrated into release/v3.8.34 (extraction completed: added missing imports/exports per module, main imports handlers locally; 145 image-gen tests pass, typecheck/cycles/file-size green)

* chore(release): v3.8.34 — finalize changelog, rebaseline drift, fix release-green reds

- Finalize CHANGELOG [3.8.34] (43 bullets, full contributor attribution) + seed i18n mirrors
- Rebaseline inherited cycle drift surfaced by release-green pre-flight: eslint warnings
  3900->3907, cognitive-complexity 797->801 (release-finalize touches no prod code; all
  drift is from this cycle's contributor merges)
- fix(providers): keep reka-flash-3 as the Reka provider default. #4621 inserted reka-flash
  at the head of the model list, silently changing the default from reka-flash-3 (the
  free-tier model) to reka-flash; reorder so reka-flash-3 stays default, reka-flash retained.
- test: align provider-models-config / provider-models-route / web-cookie-providers-new with
  #4621 (reka-flash now in the Reka catalog) and #4699 (the `kimi` API-key provider correctly
  falls through to DefaultExecutor instead of KimiWebExecutor)
- chore(quality): allowlist the COMPRESSION_GUIDE doc name in check-fabricated-docs
  (false-positive env-var match; docs/compression/COMPRESSION_GUIDE.md exists)

* fix(release-green): resolve release-PR full-CI reds for v3.8.34

Surfaced only on the release PR (these gates don't run on PR->release fast-gates):

- fix(quota): complete HTML-comment sanitization in opencodeOllamaUsage SSR reset-time
  parsing — strip any <!--...--> generically instead of the two literal React hydration
  markers, so no partial "<!--" can survive (CodeQL js/incomplete-multi-character-
  sanitization, HIGH, introduced by #4642). Regression test added.
- test(codex): correct the Codex-fingerprint body key order assertion to match the
  canonical bodyFieldOrder (prompt_cache_key precedes include); #4584 flipped the two
  and integration tests don't run on fast-gates so it never executed until the release PR.
- chore(quality): rebaseline inherited cycle drift surfaced by full CI —
  zizmorFindings 152->155 (+3 unpinned-uses in nightly-release-green.yml from #4622,
  same @vN convention as ci.yml) and openapiCoverage.pct 38.4->37.8 (-0.6, contributor
  routes added faster than openapi docs). Release-finalize touches no prod routes.

* fix(release-green): complete CodeQL sanitization + rebaseline complexity drift

- fix(quota): handle unterminated HTML comments in opencodeOllamaUsage SSR reset-time
  parsing — the `(?:-->|$)` arm consumes a trailing "<!--" with no closing "-->", so no
  partial "<!--" can survive (CodeQL js/incomplete-multi-character-sanitization persisted
  with the plain <!--...--> form because an unclosed comment could still leave "<!--").
- chore(quality): rebaseline cyclomatic complexity 1915->1916 (+1) — inherited v3.8.34
  cycle drift (contributor feature branches); check:complexity does not run on PR->release
  fast-gates so it surfaced only on the release PR. Release-finalize adds 0 complexity
  (measured 1916 with/without the regex tweak). dead-code/cognitive/type-coverage/
  compression-budget/codeql ratchets all pass.

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <diego.souza@cdwasolutions.com.br>
Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com>
Co-authored-by: Abhishek Divekar <adivekar@utexas.edu>
Co-authored-by: Rahul sharma <sharmaR0810@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <souzamiriamrodrigues790@gmail.com>
Co-authored-by: Ronald Estacion <DevEstacion@users.noreply.github.com>
Co-authored-by: Igor <60442260+BugsBag@users.noreply.github.com>
Co-authored-by: Oonishi <275808243+ponkcore@users.noreply.github.com>
Co-authored-by: Paijo <14921983+oyi77@users.noreply.github.com>
Co-authored-by: Jan Leon <Jan.gaschler@gmail.com>
2026-06-23 03:08:29 -03:00

405 lines
16 KiB
TypeScript

import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { updateSettingsSchema } from "@/shared/validation/settingsSchemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { resolveModelLockoutSettings } from "@/lib/resilience/modelLockoutSettings";
import {
validateProxyUrl,
upsertUpstreamProxyConfig,
getUpstreamProxyConfig,
} from "@/lib/db/upstreamProxy";
import { getProviderConnections } from "@/lib/db/providers";
import { clearCliproxyapiUrlCache } from "@omniroute/open-sse/executors/cliproxyapi.ts";
import {
ensurePersistentManagementPasswordHash,
getStoredManagementPassword,
hasManagementPasswordConfigured,
hashManagementPassword,
verifyManagementPassword,
} from "@/lib/auth/managementPassword";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getAuditRequestContext, logAuditEvent } from "@/lib/compliance";
import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth";
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.
*
* - `localOnlyManageScopeBypassEnabled` / `localOnlyManageScopeBypassPrefixes`:
* T-011 bypass kill-switch + per-prefix list. Operator must re-confirm
* before broadening the LOCAL_ONLY carve-out.
* - `requireLogin`: dashboard login enforcement toggle.
* - `newPassword`: password rotation (existing). Handled by the same gate so
* the password-verify only fires ONCE per PATCH.
*
* Note: `mcpEnabled` is NOT gated server-side — the dedicated MCP page
* (/dashboard/mcp) toggles it via patchSetting() without a currentPassword
* prompt. The Authz section can still prompt client-side for consistency,
* but the server accepts the change without re-auth.
*/
const SECURITY_IMPACTING_KEYS = [
"localOnlyManageScopeBypassEnabled",
"localOnlyManageScopeBypassPrefixes",
"requireLogin",
"newPassword",
] as const;
/**
* Derive an audit actor string from the inbound request. Falls back to
* `"dashboard"` for cookie sessions, `"apikey:<id>"` for Bearer API keys,
* `"cli"` for CLI machine-token sessions, and `"anonymous"` otherwise. Best
* effort — any lookup error degrades to `"unknown"` so the audit row still
* carries actor context.
*/
async function deriveAuditActor(request: Request): Promise<string> {
try {
if (await isDashboardSessionAuthenticated(request)) return "dashboard";
} catch {
/* fall through */
}
try {
if (await isCliTokenAuthValid(request)) return "cli";
} catch {
/* fall through */
}
try {
const apiKey = extractApiKey(request);
if (apiKey) {
const meta = await getApiKeyMetadata(apiKey);
if (meta?.id) return `apikey:${meta.id}`;
return "apikey:unknown";
}
} catch {
return "unknown";
}
return "anonymous";
}
/** Deep-equality for diff detection. JSON round-trip handles plain settings. */
function isDeepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== "object" || typeof b !== "object") return false;
try {
return JSON.stringify(a) === JSON.stringify(b);
} catch {
return false;
}
}
/** Build per-key `{before, after}` diff for changed keys (top-level only). */
function computeSettingsDiff(
before: Record<string, unknown>,
after: Record<string, unknown>,
candidateKeys: string[]
): Record<string, { before: unknown; after: unknown }> {
const diff: Record<string, { before: unknown; after: unknown }> = {};
for (const key of candidateKeys) {
if (!isDeepEqual(before[key], after[key])) {
diff[key] = { before: before[key], after: after[key] };
}
}
return diff;
}
/** List of top-level body keys the operator attempted to change (audit context). */
function attemptedKeysOf(body: Record<string, unknown> | null | undefined): string[] {
if (!body || typeof body !== "object") return [];
return Object.keys(body).filter(
(k) => k !== "currentPassword" && k !== "newPassword" && k !== "password"
);
}
/** Emit a settings.update_failed row. Never throws — audit must not break flow. */
function emitSettingsFailureAudit(
request: Request,
actor: string,
reason: string,
attemptedKeys: string[]
) {
try {
const { ipAddress, requestId } = getAuditRequestContext(request);
logAuditEvent({
action: "settings.update_failed",
actor,
target: "settings",
resourceType: "settings",
status: "failure",
ipAddress: ipAddress || undefined,
requestId: requestId || undefined,
details: { reason, attempted_keys: attemptedKeys },
});
} catch {
/* best effort */
}
}
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const settings = await getSettings();
const { password, ...safeSettings } = settings;
const runtimePorts = getRuntimePorts();
const cloudUrl = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL || null;
const machineId = await getConsistentMachineId();
// Include cliproxyapi_model_mapping from upstream_proxy_config table
let cliproxyapiModelMapping: Record<string, string> | null = null;
try {
const proxyConfig = await getUpstreamProxyConfig("cliproxyapi");
if (proxyConfig?.cliproxyapiModelMapping) {
cliproxyapiModelMapping = proxyConfig.cliproxyapiModelMapping as Record<string, string>;
}
} catch {
// 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 }
: {}),
},
{ headers: SETTINGS_RESPONSE_HEADERS }
);
} catch (error) {
console.log("Error getting settings:", error);
return NextResponse.json({ error: "Failed to load settings" }, { status: 500 });
}
}
export async function PATCH(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
// Derive actor + raw body once so the rejection paths can audit consistently.
const actor = await deriveAuditActor(request);
let rawBody: Record<string, unknown> = {};
try {
rawBody = (await request.json()) as Record<string, unknown>;
} catch {
// Malformed JSON — surface a zod-style failure path so the rejection
// is auditable like every other 400.
emitSettingsFailureAudit(request, actor, "INVALID_JSON", []);
return NextResponse.json(
{ error: { code: "INVALID_JSON", message: "Request body is not valid JSON" } },
{ status: 400 }
);
}
const attemptedKeys = attemptedKeysOf(rawBody);
try {
// Zod validation
const validation = validateBody(updateSettingsSchema, rawBody);
if (isValidationFailure(validation)) {
// Detect spawn-capable prefix rejection (spec AC-8) so the audit row
// names the correct error code; otherwise fall back to the generic
// validation-failure label.
const isBypassPrefixRejection = (validation.error.details || []).some(
(d) => typeof d.message === "string" && d.message.includes("BYPASS_PREFIX_NOT_ALLOWED")
);
emitSettingsFailureAudit(
request,
actor,
isBypassPrefixRejection ? "BYPASS_PREFIX_NOT_ALLOWED" : "VALIDATION_FAILED",
attemptedKeys
);
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const body: typeof validation.data & { password?: string } = { ...validation.data };
// Sanitize model lockout settings: clamp values to valid bounds so that
// stale DB values or hand-crafted requests don't bypass range validation.
if (body.modelLockout) {
body.modelLockout = resolveModelLockoutSettings({
modelLockout: body.modelLockout as Record<string, unknown>,
}) as typeof body.modelLockout;
}
// Security-impacting gate (T-011, spec AC-4 / AC-5). Computed from the
// VALIDATED body so we never trip on stray unknown keys. If any security
// key is present, require currentPassword + verify against the stored
// bcrypt hash. Dedupes with the previous inline newPassword reauth — the
// password is verified at most once per PATCH.
const touchedSecurityKeys = SECURITY_IMPACTING_KEYS.filter((k) => k in validation.data);
let storedPasswordHash = "";
if (touchedSecurityKeys.length > 0) {
const settings = await getSettings();
// Lazy-hash any plaintext INITIAL_PASSWORD migration BEFORE we read the
// stored hash, so the gate works on fresh deploys too.
const passwordState = await ensurePersistentManagementPasswordHash({
settings,
source: "settings.security_impacting_update",
});
storedPasswordHash = getStoredManagementPassword(passwordState.settings);
// Cold-boot exception: same condition the existing newPassword path
// honoured before T-011 — when no password is configured yet AND login
// is currently disabled, allow the first write to set policy (incl.
// the password itself). Once a hash exists the gate always fires.
const isColdBoot = !storedPasswordHash && passwordState.settings.requireLogin === false;
if (!isColdBoot) {
if (!body.currentPassword) {
emitSettingsFailureAudit(request, actor, "PASSWORD_REQUIRED", attemptedKeys);
return NextResponse.json(
{
error: {
code: "PASSWORD_REQUIRED",
message: "currentPassword required for security-impacting setting changes",
keys: touchedSecurityKeys,
},
},
{ status: 400 }
);
}
const isValid = await verifyManagementPassword(body.currentPassword, storedPasswordHash);
if (!isValid) {
emitSettingsFailureAudit(request, actor, "PASSWORD_MISMATCH", attemptedKeys);
return NextResponse.json(
{
error: {
code: "PASSWORD_MISMATCH",
message: "Invalid current password",
},
},
{ status: 401 }
);
}
}
}
// Password rotation: hash the new value AFTER the gate has accepted the
// currentPassword (or the cold-boot exception fired). The gate already
// included `newPassword` in SECURITY_IMPACTING_KEYS, so no separate
// verify happens here — strictly hashing + body rewriting.
if (body.newPassword) {
body.password = await hashManagementPassword(body.newPassword);
delete body.newPassword;
}
delete body.currentPassword;
// Snapshot BEFORE the write so the success row can record a real diff.
const beforeSnapshot = (await getSettings()) as Record<string, unknown>;
const settings = await updateSettings(body);
// Sync CLIProxyAPI settings to upstream_proxy_config table
const cpaUrl = rawBody.cliproxyapi_url as string | undefined;
const cpaFallback = rawBody.cliproxyapi_fallback_enabled as boolean | undefined;
if (cpaUrl && typeof cpaUrl === "string") {
const urlValidation = validateProxyUrl(cpaUrl);
if (urlValidation.valid === false) {
emitSettingsFailureAudit(request, actor, "CLIPROXY_URL_INVALID", attemptedKeys);
return NextResponse.json(
{ error: `Invalid CLIProxyAPI URL: ${urlValidation.error}` },
{ status: 400 }
);
}
// Invalidate the executor's URL cache so it picks up the new URL immediately
clearCliproxyapiUrlCache();
}
const cpaModelMapping = rawBody.cliproxyapi_model_mapping as Record<string, string> | undefined;
if (cpaFallback !== undefined || cpaUrl !== undefined || cpaModelMapping !== undefined) {
const enabled =
cpaFallback ?? (settings as Record<string, unknown>).cliproxyapi_fallback_enabled;
const mode = enabled ? "fallback" : "native";
// Get all distinct active provider IDs so each one gets its own
// upstream_proxy_config row. chatCore reads per-provider config
// (e.g. getUpstreamProxyConfig("anthropic")), not a single global row.
// Embedded service IDs are not real routing targets and must be skipped.
const EMBEDDED_SERVICE_IDS = new Set(["cliproxyapi", "9router"]);
const activeConnections = await getProviderConnections({ isActive: true });
const activeProviderIds = [
...new Set(
activeConnections
.map((c: Record<string, unknown>) => c.provider as string)
.filter((id: string) => !EMBEDDED_SERVICE_IDS.has(id))
),
];
for (const providerId of activeProviderIds) {
await upsertUpstreamProxyConfig({
providerId,
mode,
enabled: !!enabled,
...(cpaModelMapping !== undefined ? { cliproxyapiModelMapping: cpaModelMapping } : {}),
});
}
// Update the "cliproxyapi" sentinel row used by GET /api/settings to
// retrieve cliproxyapi_model_mapping. This row is NOT used for routing
// (chatCore reads per-real-provider rows above); it exists solely as
// storage for the global model-mapping blob.
await upsertUpstreamProxyConfig({
providerId: "cliproxyapi",
mode,
enabled: !!enabled,
...(cpaModelMapping !== undefined ? { cliproxyapiModelMapping: cpaModelMapping } : {}),
});
}
// Audit success — diff of changed keys only. Idempotent PATCH (no diff)
// intentionally writes NO row (spec §Observability + AC-9/AC-11).
try {
const afterSnapshot = settings as Record<string, unknown>;
const candidateKeys = Object.keys(body);
const diff = computeSettingsDiff(beforeSnapshot, afterSnapshot, candidateKeys);
if (Object.keys(diff).length > 0) {
const { ipAddress, requestId } = getAuditRequestContext(request);
logAuditEvent({
action: "settings.update",
actor,
target: "settings",
resourceType: "settings",
status: "success",
ipAddress: ipAddress || undefined,
requestId: requestId || undefined,
details: { diff },
});
}
} catch {
// Audit failure must never break the write — swallow.
}
const { password, ...safeSettings } = settings;
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 });
}
}
export async function PUT(request: Request) {
return PATCH(request);
}