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