mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-18 13:14:56 +03:00
* fix(radar): preserve extended feed fields and honor local enable override
applyFeed()'s MergedEntry shape omitted contextWindow/capabilities/limits/
setup even though FeedModel always carries them, so the dashboard's setup
link, Context column, and capability badges never rendered and the setup
page's provider lookup always failed. Both merge paths (mergeOne and
feedModelToMerged) now copy the four fields through, respecting rule 1
(local override wins) same as every other field.
feedModelToMerged() also unconditionally forced enabled:false when the feed
disabled a feed-only entry, even when the operator had locally overridden
enabled:true — mergeOne() already applies overrides after the disable rule
and got this right. feedModelToMerged() now only force-disables when there
is no local `enabled` override, matching mergeOne()'s semantics.
* fix(radar): cap feed sync response body at 10MB
syncRadar() buffered the entire feed response via
Buffer.from(await res.arrayBuffer()) with no size limit, so a
misconfigured or hostile RADAR_FEED_URL (or an upstream serving garbage)
could force an unbounded in-memory buffer. Enforcement is two-layered: a
Content-Length preflight skips reading an already-oversized body entirely,
and a running-total check while reading the stream enforces the cap even
when Content-Length is absent or understates the real size — concatenating
the accumulated chunks preserves the exact bytes the signature check needs.
Exceeding the cap returns a new { status: "too_large" } SyncStatus and
leaves the cache untouched, following the same non-destructive pattern as
every other sync failure (invalid_signature/invalid_schema/stale).
* fix(radar): gate the sidebar radar item behind RADAR_ENABLED
The "radar" sidebar item was registered unconditionally in
sidebarVisibility/sections.ts, but Sidebar.tsx has no feature-flag
awareness (it's a client component), so the link stayed visible and
clickable with RADAR_ENABLED off, landing on a 404 dashboard page.
Sidebar items gain an opt-in `featureFlagKey` field plus a pure
isSidebarItemVisibleForFlags() filter (fails open when a flag isn't in the
map, so a missing/not-yet-loaded key never hides an unrelated item). The
resolved flag value piggy-backs on the /api/settings response the sidebar
already fetches on mount (new `radarEnabled` field) rather than adding a
dedicated round trip.
* fix(radar): require auth on management routes, add GET settings
GET /api/radar/catalog, POST /api/radar/sync, and POST /api/radar/settings
had zero authentication — any client that could reach the local server
could read the merged catalog, trigger a sync, or flip the opt-in/set the
supporter key. All three (plus the new GET below) now call
isAuthenticated() from the shared apiAuth guard, same gate as the rest of
/api/settings/*. The RADAR_ENABLED flag-off 404 check keeps running FIRST
so flag-off inertia stays byte-identical (no auth prompt just to learn the
surface doesn't exist); auth runs after it, before any DB access.
Adds GET /api/radar/settings, returning { optIn, hasSupporterKey,
supporterKeyMasked } — the raw key never leaves the server on either verb.
The dashboard page's fetchSettings() now calls this endpoint instead of
inferring opt-in state from the catalog response (which always defaulted
to unknown/null), so an already-activated operator no longer sees the
activation screen on every reload. handleSync() also handles the new
too_large sync status introduced by the response-cap fix, reusing the
existing generic sync-failed copy (no new UI strings).
* docs(radar): fix stale feed URL, document tier header/auth/size cap
- RADAR_FEED_URL default was documented as radar.omniroute.dev in
ENVIRONMENT.md; the actual default (src/lib/radar/sync.ts) and every
other reference use radar.omniroute.online — fix the one stale spot.
- Correct the FREE_MODEL_BUDGETS source path: it's declared in
freeModelCatalog.data.ts, not freeModelCatalog.ts (which only
re-exports it).
- Document that the signed feed body's `tier` is always "live" (one
signed artifact per version) and the actually-served tier comes from
the `x-omniroute-feed-tier` response header, resolved with a Zod parse
+ fallback to the body field.
- Document that all four /api/radar/* routes now require auth
(isAuthenticated(), same gate as /api/settings/*), the new
GET /api/radar/settings route, and the new too_large sync status from
the 10MB response cap.
* feat(radar): daily sync scheduler + auto-sync on page open
Spec asks for a 1x/day sync while opted in and fresh data on every page
open. The scheduler only arms itself when RADAR_ENABLED AND the opt-in are
already on (boot) or right after the user opts in (settings route) — a
flag-off install never creates the timer, preserving the inertia contract.
The page auto-syncs once per mount when the cached feed is older than 6h.
---------
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
518 lines
20 KiB
TypeScript
518 lines
20 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { getSettings, getSettingsRevision, updateSettings } from "@/lib/localDb";
|
|
import { SettingsRevisionConflictError } from "@/lib/db/settings";
|
|
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 { isFeatureFlagEnabled } from "@/shared/utils/featureFlags";
|
|
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 { isPaidModelTarget } from "@/shared/utils/freeModels";
|
|
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;
|
|
|
|
function settingsResponseHeaders(settingsRevision: number): Record<string, string> {
|
|
return {
|
|
...SETTINGS_RESPONSE_HEADERS,
|
|
ETag: String(settingsRevision),
|
|
};
|
|
}
|
|
|
|
/** Parse opt-in CAS token from If-Match (preferred) or PATCH body. */
|
|
function parseExpectedRevision(
|
|
request: Request,
|
|
body: Record<string, unknown>
|
|
): number | undefined {
|
|
const ifMatch = request.headers.get("If-Match");
|
|
if (ifMatch !== null) {
|
|
const trimmed = ifMatch.replace(/^W\/"/, "").replace(/"$/, "").trim();
|
|
const parsed = Number(trimmed);
|
|
if (Number.isInteger(parsed) && parsed >= 0) return parsed;
|
|
}
|
|
const fromBody = body.expectedRevision;
|
|
if (typeof fromBody === "number" && Number.isInteger(fromBody) && fromBody >= 0) {
|
|
return fromBody;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
"oidcEnabled",
|
|
"oidcClientSecret",
|
|
] 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" &&
|
|
k !== "expectedRevision"
|
|
);
|
|
}
|
|
|
|
/** 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 settingsRevision = await getSettingsRevision();
|
|
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,
|
|
settingsRevision,
|
|
hasPassword: hasManagementPasswordConfigured(settings),
|
|
runtimePorts,
|
|
apiPort: runtimePorts.apiPort,
|
|
dashboardPort: runtimePorts.dashboardPort,
|
|
cloudConfigured: Boolean(cloudUrl),
|
|
cloudUrl,
|
|
machineId,
|
|
// Sidebar.tsx has no server-side feature-flag access (client component);
|
|
// this piggy-backs the RADAR_ENABLED gate onto the settings payload the
|
|
// sidebar already fetches on mount, so the "radar" item can hide itself
|
|
// without a dedicated round trip. See sidebarVisibility.ts's
|
|
// `isSidebarItemVisibleForFlags()`.
|
|
radarEnabled: isFeatureFlagEnabled("RADAR_ENABLED"),
|
|
...(cliproxyapiModelMapping !== null
|
|
? { cliproxyapi_model_mapping: cliproxyapiModelMapping }
|
|
: {}),
|
|
},
|
|
{ headers: settingsResponseHeaders(settingsRevision) }
|
|
);
|
|
} 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);
|
|
const expectedRevision = parseExpectedRevision(request, 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.
|
|
if (body.modelLockout) {
|
|
body.modelLockout = resolveModelLockoutSettings({
|
|
modelLockout: body.modelLockout as Record<string, unknown>,
|
|
}) as typeof body.modelLockout;
|
|
}
|
|
|
|
if (body.oidcEnabled === true) {
|
|
const current = await getSettings();
|
|
const subjects = Array.isArray(body.oidcAllowedSubjects)
|
|
? (body.oidcAllowedSubjects as unknown[])
|
|
: ((current.oidcAllowedSubjects as unknown[] | undefined) ?? []);
|
|
const hasAtLeastOne = subjects.some((s) => typeof s === "string" && s.trim().length > 0);
|
|
if (!hasAtLeastOne) {
|
|
emitSettingsFailureAudit(request, actor, "OIDC_ALLOWED_SUBJECTS_REQUIRED", attemptedKeys);
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: "OIDC_ALLOWED_SUBJECTS_REQUIRED",
|
|
message:
|
|
"oidcAllowedSubjects must contain at least one subject or email when oidcEnabled is true",
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
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",
|
|
});
|
|
const 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.
|
|
// #8950: also treat the request as cold boot when newPassword is present
|
|
// without a stored hash, so the Security tab's two-step flow (enable
|
|
// requireLogin first, then set password) does not deadlock.
|
|
const isColdBoot =
|
|
!storedPasswordHash &&
|
|
(passwordState.settings.requireLogin === false || Boolean(body.newPassword));
|
|
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 }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// #6540: reject a paid-only webSearchRouteModel target when hidePaidModels
|
|
// is on. Business-rule check (needs an async DB read), so it runs after
|
|
// Zod shape validation rather than as a Zod .refine(). Fails open on
|
|
// "unknown" (aliases/combo names) — only a positively-identified paid
|
|
// catalog entry is blocked.
|
|
if (typeof body.webSearchRouteModel === "string" && body.webSearchRouteModel.trim() !== "") {
|
|
const currentSettings = await getSettings();
|
|
if ((currentSettings as Record<string, unknown>)?.hidePaidModels === true) {
|
|
if (isPaidModelTarget(body.webSearchRouteModel) === "paid") {
|
|
emitSettingsFailureAudit(request, actor, "PAID_MODEL_TARGET_BLOCKED", attemptedKeys);
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: "PAID_MODEL_TARGET_BLOCKED",
|
|
message:
|
|
"This field cannot target a paid-only model while 'Hide paid models' is enabled.",
|
|
},
|
|
},
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
delete body.expectedRevision;
|
|
|
|
// Snapshot BEFORE the write so the success row can record a real diff.
|
|
const beforeSnapshot = (await getSettings()) as Record<string, unknown>;
|
|
let settings: Awaited<ReturnType<typeof getSettings>>;
|
|
try {
|
|
settings = await updateSettings(body, { expectedRevision });
|
|
} catch (error) {
|
|
if (error instanceof SettingsRevisionConflictError) {
|
|
emitSettingsFailureAudit(request, actor, "SETTINGS_REVISION_CONFLICT", attemptedKeys);
|
|
return NextResponse.json(
|
|
{
|
|
error: {
|
|
code: "SETTINGS_REVISION_CONFLICT",
|
|
message: "Settings changed since this snapshot; refresh and retry",
|
|
currentRevision: error.currentRevision,
|
|
},
|
|
},
|
|
{ status: 409, headers: settingsResponseHeaders(error.currentRevision) }
|
|
);
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
// 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;
|
|
const settingsRevision = await getSettingsRevision();
|
|
return NextResponse.json(
|
|
{ ...safeSettings, settingsRevision },
|
|
{ headers: settingsResponseHeaders(settingsRevision) }
|
|
);
|
|
} 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);
|
|
}
|