feat(webui): system config override store with effective security derivation

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-08-11 08:35:52 +08:00
parent 7c9193df2e
commit 0a9148d178
2 changed files with 383 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("server-only", () => ({}));
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "evosci-syscfg-"));
const originalDataDir = process.env.EVOSCIENTIST_DATA_DIR;
process.env.EVOSCIENTIST_DATA_DIR = dataDir;
const {
getSystemConfig,
saveSystemConfig,
validateSystemConfig,
effectiveSecurity,
systemConfigPath,
resetSystemConfigCacheForTests,
SystemConfigError,
} = await import("./systemConfig");
describe("systemConfig", () => {
beforeEach(() => {
fs.rmSync(systemConfigPath(), { force: true });
resetSystemConfigCacheForTests();
});
afterAll(() => {
if (originalDataDir === undefined) delete process.env.EVOSCIENTIST_DATA_DIR;
else process.env.EVOSCIENTIST_DATA_DIR = originalDataDir;
fs.rmSync(dataDir, { recursive: true, force: true });
});
it("returns defaults when the file is absent", () => {
const config = getSystemConfig();
expect(config.branding.wordmark).toBe("AI4Scientist");
expect(config.branding.logoFile).toBeNull();
expect(config.loginTerms).toEqual({ enabled: false, markdown: "" });
expect(config.security.authEnabled).toBeNull();
expect(config.backup.schedule).toEqual({
enabled: false,
intervalHours: 24,
keepCount: 7,
});
});
it("round-trips a saved config and merges partial files with defaults", () => {
const config = getSystemConfig();
config.branding.wordmark = "MyLab";
config.security.sessionTtlHours = 4;
saveSystemConfig(config);
resetSystemConfigCacheForTests();
expect(getSystemConfig().branding.wordmark).toBe("MyLab");
expect(getSystemConfig().security.sessionTtlHours).toBe(4);
fs.writeFileSync(
systemConfigPath(),
JSON.stringify({ branding: { wordmark: "Partial" } })
);
resetSystemConfigCacheForTests();
const merged = getSystemConfig();
expect(merged.branding.wordmark).toBe("Partial");
expect(merged.security.captchaThreshold).toBeNull();
});
it("falls back to defaults on a corrupt file", () => {
fs.writeFileSync(systemConfigPath(), "{not json");
resetSystemConfigCacheForTests();
expect(getSystemConfig().branding.wordmark).toBe("AI4Scientist");
});
it("validates bounds and types", () => {
expect(() =>
validateSystemConfig({ branding: { wordmark: "" } })
).toThrow(SystemConfigError);
expect(() =>
validateSystemConfig({ security: { sessionTtlHours: 0 } })
).toThrow(/session/i);
expect(() =>
validateSystemConfig({ security: { passwordMinLength: 3 } })
).toThrow(/password/i);
expect(() =>
validateSystemConfig({ backup: { schedule: { keepCount: 0 } } })
).toThrow(/keep/i);
const valid = validateSystemConfig({
security: { captchaEnabled: false, captchaThreshold: 5 },
});
expect(valid.security.captchaEnabled).toBe(false);
expect(valid.security.captchaThreshold).toBe(5);
});
it("rejects a non-existent backend data dir", () => {
expect(() =>
validateSystemConfig({ backup: { backendDataDir: "/no/such/dir-xyz" } })
).toThrow(/backend data dir/i);
});
it("derives effective security with env/code defaults", () => {
const sec = effectiveSecurity();
expect(sec.authEnabled).toBe(false); // WEBUI_AUTH_ENABLED unset
expect(sec.sessionTtlSeconds).toBe(12 * 60 * 60);
expect(sec.passwordMinLength).toBe(8);
expect(sec.captchaEnabled).toBe(true);
expect(sec.captchaThreshold).toBe(3);
const config = getSystemConfig();
config.security = {
authEnabled: true,
sessionTtlHours: 2,
passwordMinLength: 12,
captchaEnabled: false,
captchaThreshold: 5,
};
saveSystemConfig(config);
resetSystemConfigCacheForTests();
const overridden = effectiveSecurity();
expect(overridden).toEqual({
authEnabled: true,
sessionTtlSeconds: 7200,
passwordMinLength: 12,
captchaEnabled: false,
captchaThreshold: 5,
});
});
});
+258
View File
@@ -0,0 +1,258 @@
import "server-only";
import fs from "node:fs";
import path from "node:path";
import { sessionTtlSeconds } from "@/lib/auth";
import { webuiDataDir } from "./dataDir";
export interface SystemConfig {
branding: { wordmark: string; logoFile: string | null; faviconFile: string | null };
loginTerms: { enabled: boolean; markdown: string };
security: {
authEnabled: boolean | null;
sessionTtlHours: number | null;
passwordMinLength: number | null;
captchaEnabled: boolean | null;
captchaThreshold: number | null;
};
backup: {
backendDataDir: string;
schedule: { enabled: boolean; intervalHours: number; keepCount: number };
};
}
export class SystemConfigError extends Error {}
const DEFAULTS: SystemConfig = {
branding: { wordmark: "AI4Scientist", logoFile: null, faviconFile: null },
loginTerms: { enabled: false, markdown: "" },
security: {
authEnabled: null,
sessionTtlHours: null,
passwordMinLength: null,
captchaEnabled: null,
captchaThreshold: null,
},
backup: {
backendDataDir: "",
schedule: { enabled: false, intervalHours: 24, keepCount: 7 },
},
};
const globalCache = globalThis as {
__evoscientistSystemConfig?: { mtimeMs: number; config: SystemConfig };
};
export function systemConfigPath(): string {
return path.join(webuiDataDir(), "system-config.json");
}
export function brandingDir(): string {
return path.join(webuiDataDir(), "branding");
}
export function resetSystemConfigCacheForTests(): void {
globalCache.__evoscientistSystemConfig = undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function merge(raw: unknown): SystemConfig {
const source = isRecord(raw) ? raw : {};
const pick = <K extends keyof SystemConfig>(key: K): Partial<SystemConfig[K]> =>
isRecord(source[key]) ? (source[key] as Partial<SystemConfig[K]>) : {};
return {
branding: { ...DEFAULTS.branding, ...pick("branding") },
loginTerms: { ...DEFAULTS.loginTerms, ...pick("loginTerms") },
security: { ...DEFAULTS.security, ...pick("security") },
backup: {
...DEFAULTS.backup,
...pick("backup"),
schedule: {
...DEFAULTS.backup.schedule,
...(isRecord((source.backup as Record<string, unknown> | undefined)?.schedule)
? ((source.backup as Record<string, unknown>).schedule as Partial<
SystemConfig["backup"]["schedule"]
>)
: {}),
},
},
};
}
export function getSystemConfig(): SystemConfig {
const file = systemConfigPath();
let mtimeMs = 0;
try {
mtimeMs = fs.statSync(file).mtimeMs;
} catch {
// Absent config file: defaults.
}
const cached = globalCache.__evoscientistSystemConfig;
if (cached && cached.mtimeMs === mtimeMs) return cached.config;
let raw: unknown = null;
if (mtimeMs > 0) {
try {
raw = JSON.parse(fs.readFileSync(file, "utf8"));
} catch {
raw = null; // Corrupt file: defaults, never crash the page.
}
}
const config = merge(raw);
globalCache.__evoscientistSystemConfig = { mtimeMs, config };
return config;
}
export function saveSystemConfig(config: SystemConfig): void {
const file = systemConfigPath();
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
const tmp = `${file}.${process.pid}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
fs.renameSync(tmp, file);
resetSystemConfigCacheForTests();
}
function optBool(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
}
function optInt(
value: unknown,
min: number,
max: number,
label: string
): number | null {
if (value === undefined || value === null) return null;
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
throw new SystemConfigError(`${label} must be an integer between ${min} and ${max}.`);
}
return value as number;
}
export function validateSystemConfig(input: unknown): SystemConfig {
const source = isRecord(input) ? input : {};
const merged = merge(source);
const wordmark = merged.branding.wordmark;
if (
typeof wordmark !== "string" ||
wordmark.trim().length === 0 ||
wordmark.length > 30
) {
throw new SystemConfigError("Wordmark must be 1-30 characters.");
}
merged.branding.wordmark = wordmark.trim();
for (const key of ["logoFile", "faviconFile"] as const) {
const value = merged.branding[key];
if (value !== null && typeof value !== "string") {
throw new SystemConfigError(`branding.${key} must be a string or null.`);
}
}
merged.loginTerms.enabled = merged.loginTerms.enabled === true;
if (
typeof merged.loginTerms.markdown !== "string" ||
merged.loginTerms.markdown.length > 20000
) {
throw new SystemConfigError("Login terms must be text of at most 20000 characters.");
}
const rawSecurity = isRecord(source.security) ? source.security : {};
merged.security = {
authEnabled: optBool(rawSecurity.authEnabled),
sessionTtlHours: optInt(rawSecurity.sessionTtlHours, 1, 720, "Session TTL"),
passwordMinLength: optInt(rawSecurity.passwordMinLength, 6, 64, "Password minimum length"),
captchaEnabled: optBool(rawSecurity.captchaEnabled),
captchaThreshold: optInt(rawSecurity.captchaThreshold, 1, 10, "Captcha threshold"),
};
const rawBackup = isRecord(source.backup) ? source.backup : {};
const backendDataDir =
typeof rawBackup.backendDataDir === "string" ? rawBackup.backendDataDir.trim() : "";
if (backendDataDir.length > 500) {
throw new SystemConfigError("Backend data dir is too long.");
}
if (backendDataDir) {
let stat: fs.Stats;
try {
stat = fs.statSync(backendDataDir);
} catch {
throw new SystemConfigError(
`Backend data dir does not exist: ${backendDataDir}`
);
}
if (!stat.isDirectory()) {
throw new SystemConfigError(
`Backend data dir is not a directory: ${backendDataDir}`
);
}
}
const rawSchedule = isRecord(rawBackup.schedule) ? rawBackup.schedule : {};
merged.backup = {
backendDataDir,
schedule: {
enabled: rawSchedule.enabled === true,
intervalHours:
optInt(rawSchedule.intervalHours, 1, 168, "Backup interval") ??
DEFAULTS.backup.schedule.intervalHours,
keepCount:
optInt(rawSchedule.keepCount, 1, 100, "Backup keep count") ??
DEFAULTS.backup.schedule.keepCount,
},
};
return merged;
}
export interface EffectiveSecurity {
authEnabled: boolean;
sessionTtlSeconds: number;
passwordMinLength: number;
captchaEnabled: boolean;
captchaThreshold: number;
}
export function effectiveSecurity(): EffectiveSecurity {
const security = getSystemConfig().security;
return {
authEnabled: security.authEnabled ?? process.env.WEBUI_AUTH_ENABLED === "true",
sessionTtlSeconds: security.sessionTtlHours
? security.sessionTtlHours * 60 * 60
: sessionTtlSeconds(),
passwordMinLength: security.passwordMinLength ?? 8,
captchaEnabled: security.captchaEnabled ?? true,
captchaThreshold: security.captchaThreshold ?? 3,
};
}
export function brandingVersion(kind: "logo" | "favicon"): number {
const file =
kind === "logo" ? getSystemConfig().branding.logoFile : getSystemConfig().branding.faviconFile;
if (!file) return 0;
try {
return fs.statSync(path.join(brandingDir(), file)).mtimeMs;
} catch {
return 0;
}
}
export interface PublicSystemConfig {
wordmark: string;
logoVersion: number;
faviconVersion: number;
loginTerms: { enabled: boolean; markdown: string };
}
export function publicSystemConfig(): PublicSystemConfig {
const config = getSystemConfig();
return {
wordmark: config.branding.wordmark,
logoVersion: brandingVersion("logo"),
faviconVersion: brandingVersion("favicon"),
loginTerms: {
enabled: config.loginTerms.enabled,
markdown: config.loginTerms.enabled ? config.loginTerms.markdown : "",
},
};
}