import path from "node:path"; import type { WorkspaceRegistryConfig } from "./workspaces/types.js"; export interface AppConfig { host: string; port: number; harnessDir: string; thtBin: string; piBin: string; authMode: "none" | "mock" | "upstream"; sessionStorage: { mode: "local" | "postgres"; host?: string; port?: number; database?: string; runtimeUser?: string; runtimePasswordFile?: string; sslmode?: "verify-ca" | "verify-full"; sslrootcert?: string; }; defaults: { provider?: string; model?: string; thinking?: string }; maxPiProcesses: number; settingsFile: string; dataRoot?: string; ollamaEnsureTimeoutMs: number; secretsFile?: string; secretFiles: Readonly>; modelApiKeyFile?: string; /** * Local-only: when true, POST /sessions probes DWH reachability (`tht db ping`) and * refuses to create a session if it is down. Off by default so containers/CI never * pay the probe; the local dev launcher (run-stack.sh) opts in via THT_DWH_PRECHECK. */ dwhPrecheck: boolean; workspaceRegistry: WorkspaceRegistryConfig; } function requiredRegistryValue(value: string, label: string): string { if (value.length === 0 || value.trim() !== value || value.includes("\0")) { throw new Error(`workspace registry ${label} configuration is invalid`); } return value; } function absoluteRegistryPath(value: string, label: string): string { const pathValue = requiredRegistryValue(value, label); if (!path.isAbsolute(pathValue)) { throw new Error(`workspace registry ${label} must be absolute`); } return pathValue; } function positiveImportLimit(value: string | undefined, fallback: number): number { const limit = Number(value ?? fallback); if (!Number.isSafeInteger(limit) || limit <= 0) { throw new Error("workspace import limit configuration is invalid"); } return limit; } export function loadConfig(env: Record): AppConfig { const authMode = env.AUTH_MODE ?? "none"; if (!(["none", "mock", "upstream"] as const).includes(authMode as AppConfig["authMode"])) { throw new Error(`unsupported AUTH_MODE=${authMode}; use none, mock, or upstream`); } if (env.THOTH_PUBLIC_EXPOSURE === "true" && authMode !== "upstream") { throw new Error("public exposure requires AUTH_MODE=upstream behind a trusted proxy"); } const sessionStorageMode = env.THT_SESSION_STORAGE ?? "local"; if (sessionStorageMode !== "local" && sessionStorageMode !== "postgres") { throw new Error("session storage configuration is invalid"); } if (sessionStorageMode === "local" && env.THOTH_PUBLIC_EXPOSURE === "true") { throw new Error("local session storage requires loopback-only deployment"); } const sessionStorage: AppConfig["sessionStorage"] = { mode: sessionStorageMode }; if (sessionStorageMode === "postgres") { const host = env.THT_SESSION_DB_HOST; const database = env.THT_SESSION_DB_NAME; const runtimeUser = env.THT_SESSION_RUNTIME_USER; const runtimePasswordFile = env.THT_SESSION_RUNTIME_PASSWORD_FILE; const sslmode = env.THT_SESSION_DB_SSLMODE; const sslrootcert = env.THT_SESSION_DB_SSLROOTCERT; const port = Number(env.THT_SESSION_DB_PORT ?? 5432); if ( authMode !== "upstream" || !host || !database || !runtimeUser || !runtimePasswordFile || !path.isAbsolute(runtimePasswordFile) || (sslmode !== "verify-ca" && sslmode !== "verify-full") || !sslrootcert || !path.isAbsolute(sslrootcert) || !Number.isInteger(port) || port < 1 || port > 65535 ) { if (authMode !== "upstream") { throw new Error("server session storage requires AUTH_MODE=upstream"); } throw new Error("server session storage configuration is invalid"); } sessionStorage.host = host; sessionStorage.port = port; sessionStorage.database = database; sessionStorage.runtimeUser = runtimeUser; sessionStorage.runtimePasswordFile = runtimePasswordFile; sessionStorage.sslmode = sslmode; sessionStorage.sslrootcert = sslrootcert; } const modelApiKeyFile = env.THT_MODEL_API_KEY_FILE; if (modelApiKeyFile !== undefined && ( modelApiKeyFile.trim() !== modelApiKeyFile || modelApiKeyFile.length === 0 || modelApiKeyFile.includes("\0") || !path.isAbsolute(modelApiKeyFile) )) { throw new Error("model credential configuration is invalid"); } const secretsFile = env.THT_SECRETS_FILE; if (secretsFile !== undefined && ( secretsFile.trim() !== secretsFile || secretsFile.length === 0 || secretsFile.includes("\0") || !path.isAbsolute(secretsFile) )) throw new Error("secret bundle configuration is invalid"); const secretFiles: Record = {}; for (const name of [ "THT_MODEL_API_KEY_SECRET_FILE", "THT_DWH_API_KEY_SECRET_FILE", "THT_VEC_API_KEY_SECRET_FILE", "THT_VEC_WRITE_API_KEY_SECRET_FILE", "THT_CA_SECRET_FILE", "THT_VECTOR_BOOTSTRAP_PASSWORD_SECRET_FILE", "THT_VECTOR_MIGRATOR_PASSWORD_SECRET_FILE", "THT_VECTOR_READER_PASSWORD_SECRET_FILE", "THT_VECTOR_WRITER_PASSWORD_SECRET_FILE", ]) secretFiles[name] = env[name]; const registryRoot = absoluteRegistryPath( env.THT_WORKSPACE_REGISTRY_ROOT ?? "/data/workspace-registry", "root", ); const registryBranch = requiredRegistryValue(env.THT_WORKSPACE_GIT_BRANCH ?? "main", "branch"); const installationId = requiredRegistryValue( env.THT_WORKSPACE_INSTALLATION_ID ?? "local", "installation ID", ); const remoteUrl = env.THT_WORKSPACE_GIT_REMOTE === undefined ? undefined : requiredRegistryValue(env.THT_WORKSPACE_GIT_REMOTE, "remote"); const secretRoots = (env.THT_WORKSPACE_SECRET_ROOTS ?? "") .split(",") .filter((root) => root.length > 0) .map((root) => absoluteRegistryPath(root, "secret root")); const workspaceRegistry: WorkspaceRegistryConfig = { root: registryRoot, remoteUrl, branch: registryBranch, gitAuthorName: requiredRegistryValue( env.THT_WORKSPACE_GIT_AUTHOR_NAME ?? "Thoth Workspace Registry", "Git author name", ), gitAuthorEmail: requiredRegistryValue( env.THT_WORKSPACE_GIT_AUTHOR_EMAIL ?? "thoth-workspace-registry@localhost", "Git author email", ), installationId, secretRoots, maxImportBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_BYTES, 10 * 1024 * 1024), maxImportEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_IMPORT_ENTRIES, 32), }; return { host: env.HOST ?? "127.0.0.1", port: Number(env.PORT ?? 8787), harnessDir: env.THT_HARNESS_DIR ?? "../harness", thtBin: env.THT_BIN ?? "tht", piBin: env.PI_BIN ?? "pi", authMode: authMode as AppConfig["authMode"], sessionStorage, defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), settingsFile: env.SETTINGS_FILE ?? "data/settings.json", dataRoot: env.THT_DATA_ROOT, ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000), secretsFile, secretFiles, modelApiKeyFile, dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1", workspaceRegistry, }; }