diff --git a/src/lib/server/systemConfig.test.ts b/src/lib/server/systemConfig.test.ts new file mode 100644 index 0000000..3704658 --- /dev/null +++ b/src/lib/server/systemConfig.test.ts @@ -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, + }); + }); +}); diff --git a/src/lib/server/systemConfig.ts b/src/lib/server/systemConfig.ts new file mode 100644 index 0000000..8948477 --- /dev/null +++ b/src/lib/server/systemConfig.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function merge(raw: unknown): SystemConfig { + const source = isRecord(raw) ? raw : {}; + const pick = (key: K): Partial => + isRecord(source[key]) ? (source[key] as Partial) : {}; + 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 | undefined)?.schedule) + ? ((source.backup as Record).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 : "", + }, + }; +}