mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
* 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>
405 lines
16 KiB
TypeScript
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);
|
|
}
|