Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,24 @@ export default function ModelAliasesUnified() {
if (!fromValue.trim() || !toValue.trim()) return;
setSaving(true);
try {
const updated = [...wildcardAliases, { pattern: fromValue.trim(), target: toValue.trim() }];
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
if (!settingsRes.ok) throw new Error("Failed to load current settings");
const settingsData = await settingsRes.json();
const revision =
typeof settingsData.settingsRevision === "number" ? settingsData.settingsRevision : 0;
const currentWildcards = Array.isArray(settingsData.wildcardAliases)
? settingsData.wildcardAliases
: wildcardAliases;
const updated = [
...currentWildcards,
{ pattern: fromValue.trim(), target: toValue.trim() },
];
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wildcardAliases: updated }),
body: JSON.stringify({ wildcardAliases: updated, expectedRevision: revision }),
});
if (res.status === 409) throw new Error("Settings conflict — refresh and retry");
if (!res.ok) throw new Error("Failed to save wildcard alias");
setWildcardAliases(updated);
setFromValue("");
Expand All @@ -132,12 +144,21 @@ export default function ModelAliasesUnified() {
const removeWildcardAlias = async (index: number) => {
setSaving(true);
try {
const updated = wildcardAliases.filter((_, currentIndex) => currentIndex !== index);
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
if (!settingsRes.ok) throw new Error("Failed to load current settings");
const settingsData = await settingsRes.json();
const revision =
typeof settingsData.settingsRevision === "number" ? settingsData.settingsRevision : 0;
const currentWildcards = Array.isArray(settingsData.wildcardAliases)
? settingsData.wildcardAliases
: wildcardAliases;
const updated = currentWildcards.filter((_, currentIndex) => currentIndex !== index);
const res = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wildcardAliases: updated }),
body: JSON.stringify({ wildcardAliases: updated, expectedRevision: revision }),
});
if (res.status === 409) throw new Error("Settings conflict — refresh and retry");
if (!res.ok) throw new Error("Failed to remove wildcard alias");
setWildcardAliases(updated);
showStatus("success", translateOrFallback(t, "saved", "Saved"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ function useProviderAccountRoutingState(providerKey: string) {
const res = await fetch("/api/settings", { cache: "no-store" });
if (!res.ok) throw new Error("Failed to fetch current settings");
const data = await res.json();
const revision = typeof data.settingsRevision === "number" ? data.settingsRevision : 0;
const current = (data?.providerStrategies || {}) as Record<
string,
ProviderStrategyOverride
Expand All @@ -70,8 +71,12 @@ function useProviderAccountRoutingState(providerKey: string) {
const patchRes = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerStrategies: updated }),
body: JSON.stringify({ providerStrategies: updated, expectedRevision: revision }),
});
if (patchRes.status === 409) {
await load();
throw new Error("Settings conflict — refreshed from server");
}
if (!patchRes.ok) throw new Error("Failed to save provider routing settings");
} catch (e) {
console.error(e);
Expand Down
66 changes: 61 additions & 5 deletions src/app/api/settings/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
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";
Expand Down Expand Up @@ -40,6 +41,31 @@ 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.
Expand Down Expand Up @@ -127,7 +153,11 @@ function computeSettingsDiff(
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) =>
k !== "currentPassword" &&
k !== "newPassword" &&
k !== "password" &&
k !== "expectedRevision"
);
}

Expand Down Expand Up @@ -161,6 +191,7 @@ export async function GET(request: Request) {

try {
const settings = await getSettings();
const settingsRevision = await getSettingsRevision();
const { password, ...safeSettings } = settings;

const runtimePorts = getRuntimePorts();
Expand All @@ -181,6 +212,7 @@ export async function GET(request: Request) {
return NextResponse.json(
{
...safeSettings,
settingsRevision,
hasPassword: hasManagementPasswordConfigured(settings),
runtimePorts,
apiPort: runtimePorts.apiPort,
Expand All @@ -192,7 +224,7 @@ export async function GET(request: Request) {
? { cliproxyapi_model_mapping: cliproxyapiModelMapping }
: {}),
},
{ headers: SETTINGS_RESPONSE_HEADERS }
{ headers: settingsResponseHeaders(settingsRevision) }
);
} catch (error) {
console.log("Error getting settings:", error);
Expand All @@ -219,6 +251,7 @@ export async function PATCH(request: Request) {
);
}
const attemptedKeys = attemptedKeysOf(rawBody);
const expectedRevision = parseExpectedRevision(request, rawBody);

try {
// Zod validation
Expand Down Expand Up @@ -350,10 +383,29 @@ export async function PATCH(request: Request) {
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>;
const settings = await updateSettings(body);
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;
Expand Down Expand Up @@ -437,7 +489,11 @@ export async function PATCH(request: Request) {
}

const { password, ...safeSettings } = settings;
return NextResponse.json(safeSettings, { headers: SETTINGS_RESPONSE_HEADERS });
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 });
Expand Down
44 changes: 42 additions & 2 deletions src/lib/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,35 @@ function withFamilyDefault(value: ProxyValue): ProxyValue {

// ──────────────── Settings ────────────────

/** Internal key_value row — not exposed as a user-facing settings field. */
export const SETTINGS_REVISION_KEY = "_settingsRevision";

export class SettingsRevisionConflictError extends Error {
readonly code = "SETTINGS_REVISION_CONFLICT" as const;

constructor(public readonly currentRevision: number) {
super("Settings revision mismatch");
this.name = "SettingsRevisionConflictError";
}
}

function readSettingsRevision(db: ReturnType<typeof getDbInstance>): number {
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
.get(SETTINGS_REVISION_KEY) as { value?: string } | undefined;
if (!row?.value) return 0;
try {
const parsed = JSON.parse(row.value) as unknown;
return typeof parsed === "number" && Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
} catch {
return 0;
}
}

export async function getSettingsRevision(): Promise<number> {
return readSettingsRevision(getDbInstance());
}

/**
* #7274: read-fallback for the codexSessionAffinityTtlMs -> sessionAffinityTtlMs
* rename. Migration 124 already backfills the new key from any pre-existing
Expand Down Expand Up @@ -217,7 +246,7 @@ export async function getSettings() {
const record = toRecord(row);
const key = typeof record.key === "string" ? record.key : null;
const rawValue = typeof record.value === "string" ? record.value : null;
if (!key || rawValue === null) continue;
if (!key || rawValue === null || key.startsWith("_")) continue;
try {
settings[key] = JSON.parse(rawValue);
} catch {
Expand Down Expand Up @@ -246,7 +275,10 @@ export async function getSettings() {
return settings;
}

export async function updateSettings(updates: Record<string, unknown>) {
export async function updateSettings(
updates: Record<string, unknown>,
options?: { expectedRevision?: number }
) {
// Detect first-time setup completion before we overwrite settings.
let setupJustCompleted = false;
if (updates.setupComplete === true) {
Expand All @@ -263,10 +295,18 @@ export async function updateSettings(updates: Record<string, unknown>) {
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
);
const tx = db.transaction(() => {
const currentRevision = readSettingsRevision(db);
if (
options?.expectedRevision !== undefined &&
options.expectedRevision !== currentRevision
) {
throw new SettingsRevisionConflictError(currentRevision);
}
for (const [key, value] of Object.entries(updates)) {
const toStore = key === "oidcClientSecret" ? encrypt(value as string) : value;
insert.run(key, JSON.stringify(toStore));
}
insert.run(SETTINGS_REVISION_KEY, JSON.stringify(currentRevision + 1));
});
tx();
backupDbFile("pre-write");
Expand Down
1 change: 1 addition & 0 deletions src/lib/localDb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export type {
export {
// Settings
getSettings,
getSettingsRevision,
updateSettings,
isCloudEnabled,

Expand Down
2 changes: 2 additions & 0 deletions src/shared/validation/settingsSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ const transformObfuscateWordsSchema = z.object({
});

export const updateSettingsSchema = z.object({
/** #7784: opt-in optimistic concurrency — must match GET settingsRevision / ETag. */
expectedRevision: z.number().int().nonnegative().optional(),
newPassword: z.string().min(1).max(200).optional(),
currentPassword: z.string().max(200).optional(),
credentialRedactionEnabled: z.boolean().optional(),
Expand Down
Loading
Loading