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:
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 : "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user