import path from "node:path"; import { statSync } from "node:fs"; import { createAuthenticationConfigProvider, type AuthenticationConfigProvider, type AuthMode, } from "./auth/config.js"; import { createProjectedAuthenticationConfigProvider } from "./auth/runtime-projection.js"; import type { WorkspaceRegistryConfig } from "./workspaces/types.js"; import type { CatalogConnectionConfig } from "./catalog/repository.js"; export interface AppConfig { host: string; port: number; harnessDir: string; thtBin: string; piBin: string; authMode: AuthMode; authConfigFile: string; authStateRoot: string; authentication?: AuthenticationConfigProvider; publicExposure: boolean; sessionStorage: { mode: "local" | "postgres"; host?: string; port?: number; database?: string; runtimeUser?: string; runtimePasswordFile?: string; sslmode?: "verify-ca" | "verify-full"; sslrootcert?: string; }; /** Installation-local metadata catalog. Omitted installations expose an unavailable admin surface. */ catalogDatabase?: CatalogConnectionConfig; defaults: { provider?: string; model?: string; thinking?: string }; maxPiProcesses: number; settingsFile: string; maintenanceFile: string; dataRoot?: string; ollamaEnsureTimeoutMs: number; piManagementTimeoutMs: number; secretsFile?: string; installationConfigFile?: string; piAuthFile?: 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; /** Explicit compatibility mode for old loopback clients that send `workspace` in POST /sessions. */ legacyWorkspaceMode: boolean; workspaceDiagnosticTimeoutMs: number; catalogSyncTimeoutMs: number; workspaceRegistry: WorkspaceRegistryConfig; workspaceSecretStoreRoot: string; workspaceSecretRuntimeRoot: string; internalQdrantUrl: string; internalEmbeddingUrl: string; internalEmbeddingModel: string; internalEmbeddingDimensions: number; } export const MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS = 10_000; 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 absoluteAuthPath(value: string, label: string): string { if (value.length === 0 || value.trim() !== value || value.includes("\0") || !path.isAbsolute(value)) { throw new Error(`authentication ${label} configuration is invalid`); } return value; } function authConfigFileExists(file: string): boolean { try { if (!statSync(file).isFile()) throw new Error("authentication configuration is invalid"); return true; } catch (error: any) { if (error?.code === "ENOENT") return false; throw new Error("authentication configuration is invalid"); } } function refSafeGitBranch(value: string): string { const branch = requiredRegistryValue(value, "branch"); if ( branch === "@" || branch === "HEAD" || branch.startsWith("-") || branch.startsWith("/") || branch.endsWith("/") || branch.endsWith(".") || branch.includes("..") || branch.includes("@{") || branch.includes("//") || branch.split("/").some((component) => component.startsWith(".") || component.endsWith(".lock")) || /[\p{Cc} ~^:?*\[\\]/u.test(branch) ) { throw new Error("workspace registry branch configuration is invalid"); } return branch; } function safeInstallationId(value: string): string { const installationId = requiredRegistryValue(value, "installation ID"); if (/\p{Cc}/u.test(installationId)) { throw new Error("workspace registry installation ID configuration is invalid"); } return installationId; } function positiveImportLimit(value: string | undefined, fallback: number): number { const limit = Number(value ?? fallback); if (!Number.isSafeInteger(limit) || limit <= 0) { throw new Error("workspace limit configuration is invalid"); } return limit; } function diagnosticTimeout(value: string | undefined): number { const timeout = Number(value ?? 5_000); if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > MAX_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS) { throw new Error("workspace diagnostic timeout configuration is invalid"); } return timeout; } function catalogSyncTimeout(value: string | undefined): number { const timeout = Number(value ?? 600_000); if (!Number.isSafeInteger(timeout) || timeout < 1_000 || timeout > 3_600_000) { throw new Error("catalog synchronization timeout configuration is invalid"); } return timeout; } function piManagementTimeout(value: string | undefined): number { const timeout = Number(value ?? 8_000); if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 30_000) { throw new Error("Pi management timeout configuration is invalid"); } return timeout; } function loopbackHost(host: string): boolean { return host === "::1" || host === "127.0.0.1" || /^127(?:\.\d{1,3}){3}$/.test(host); } function internalServiceUrl( value: string | undefined, fallback: string, label: string, allowedHosts: readonly string[], ): string { const raw = value ?? fallback; let parsed: URL; try { parsed = new URL(raw); } catch { throw new Error(`${label} configuration is invalid`); } if ( parsed.protocol !== "http:" || parsed.username.length > 0 || parsed.password.length > 0 || parsed.pathname !== "/" || parsed.search.length > 0 || parsed.hash.length > 0 || (!allowedHosts.includes(parsed.hostname) && !loopbackHost(parsed.hostname)) ) { throw new Error(`${label} configuration is invalid`); } return parsed.toString().replace(/\/$/, ""); } function positiveDimension(value: string | undefined, fallback: number): number { const parsed = Number(value ?? fallback); if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new Error("internal embedding dimensions configuration is invalid"); } return parsed; } function catalogDatabase(env: Record): CatalogConnectionConfig | undefined { const value = env.THT_CATALOG_DATABASE_URL; if (value !== undefined) { try { const parsed = new URL(value); if ((parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") || !parsed.hostname || !parsed.pathname.slice(1) || parsed.hash || parsed.search) throw new Error(); return { connectionString: value }; } catch { throw new Error("catalog database configuration is invalid"); } } const host = env.THT_CATALOG_DB_HOST; if (host === undefined) return undefined; const port = Number(env.THT_CATALOG_DB_PORT ?? 5432); const database = env.THT_CATALOG_DB_NAME; const user = env.THT_CATALOG_RUNTIME_USER; const passwordFile = env.THT_CATALOG_RUNTIME_PASSWORD_FILE; try { if (!host.trim() || !database?.trim() || !user?.trim() || !passwordFile || !path.isAbsolute(passwordFile) || !Number.isInteger(port) || port < 1 || port > 65_535) throw new Error(); return { host, port, database, user, passwordFile }; } catch { throw new Error("catalog database configuration is invalid"); } } export function loadConfig( env: Record, options: { surface?: "application" | "workspace-maintenance" } = {}, ): AppConfig { const applicationSurface = options.surface !== "workspace-maintenance"; const defaultAuthConfigFile = "/run/thothii-auth/auth.yaml"; const authConfigFile = absoluteAuthPath(env.THT_AUTH_CONFIG_FILE ?? defaultAuthConfigFile, "file"); const authStateRoot = absoluteAuthPath(env.THT_AUTH_STATE_ROOT ?? "/data/auth", "state root"); const runtimeProjectionRoot = env.THT_AUTH_RUNTIME_PROJECTION_ROOT; let hasAuthenticationConfig = false; let authentication: AuthenticationConfigProvider | undefined; if (runtimeProjectionRoot !== undefined) { let projectionRoot: string; try { projectionRoot = absoluteAuthPath(runtimeProjectionRoot, "runtime projection root"); } catch { throw new Error("authentication configuration is invalid"); } if (env.THT_AUTH_CONFIG_FILE !== undefined && env.THT_AUTH_CONFIG_FILE !== defaultAuthConfigFile) { throw new Error("authentication configuration is invalid"); } authentication = createProjectedAuthenticationConfigProvider(projectionRoot); hasAuthenticationConfig = true; } else { hasAuthenticationConfig = authConfigFileExists(authConfigFile); authentication = hasAuthenticationConfig ? createAuthenticationConfigProvider(authConfigFile) : undefined; } if (hasAuthenticationConfig && env.AUTH_MODE !== undefined) { throw new Error("authentication configuration and AUTH_MODE cannot both be set"); } let authMode: AuthMode; if (authentication) { authMode = authentication.current().value.mode; } else { const requestedMode = env.AUTH_MODE ?? "none"; if (!(["none", "mock", "upstream"] as const).includes(requestedMode as "none" | "mock" | "upstream")) { throw new Error(`unsupported AUTH_MODE=${requestedMode}; use none, mock, or upstream`); } const nodeEnvironment = env.NODE_ENV ?? process.env.NODE_ENV; if (applicationSurface && (requestedMode === "none" || requestedMode === "mock") && nodeEnvironment !== "development" && nodeEnvironment !== "test") { throw new Error("production requires auth.yaml or AUTH_MODE=upstream"); } authMode = requestedMode as "none" | "mock" | "upstream"; } const publicExposure = applicationSurface && env.THOTH_PUBLIC_EXPOSURE === "true"; if (publicExposure && authMode !== "oidc" && authMode !== "upstream") { throw new Error("public exposure requires AUTH_MODE=upstream or configured OIDC 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" && publicExposure) { throw new Error("local session storage requires loopback-only deployment"); } const legacyWorkspaceMode = env.THT_LEGACY_WORKSPACE_MODE; if (legacyWorkspaceMode !== undefined && legacyWorkspaceMode !== "local") { throw new Error("legacy workspace mode configuration is invalid"); } if (legacyWorkspaceMode === "local" && sessionStorageMode !== "local") { throw new Error("legacy workspace mode requires local session storage"); } 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" && authMode !== "oidc") || !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" && authMode !== "oidc") { throw new Error("server session storage requires AUTH_MODE=upstream or configured OIDC"); } 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 installationConfigFile = env.THT_INSTALLATION_CONFIG_FILE; if (installationConfigFile !== undefined && ( installationConfigFile.trim() !== installationConfigFile || installationConfigFile.length === 0 || installationConfigFile.includes("\0") || !path.isAbsolute(installationConfigFile) )) throw new Error("installation configuration is invalid"); const piAuthFile = env.THT_PI_AUTH_FILE; if (piAuthFile !== undefined && ( piAuthFile.trim() !== piAuthFile || piAuthFile.length === 0 || piAuthFile.includes("\0") || !path.isAbsolute(piAuthFile) )) throw new Error("Pi authentication source 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 = refSafeGitBranch(env.THT_WORKSPACE_GIT_BRANCH ?? "main"); const installationId = safeInstallationId( env.THT_WORKSPACE_INSTALLATION_ID ?? "local", ); 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, installationId, secretRoots, dataRoot: env.THT_DATA_ROOT, maxEvidenceEntries: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_ENTRIES, 4096), maxEvidenceBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_BYTES, 64 * 1024 * 1024), maxEvidenceFileBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_FILE_BYTES, 8 * 1024 * 1024), maxEvidencePathBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_PATH_BYTES, 4096), maxEvidenceManifestBytes: positiveImportLimit(env.THT_WORKSPACE_MAX_EVIDENCE_MANIFEST_BYTES, 1024 * 1024), }; const workspaceSecretStoreRoot = absoluteRegistryPath( env.THT_WORKSPACE_SECRET_STORE_ROOT ?? path.join(path.dirname(registryRoot), "workspace-secrets"), "secret store root", ); const workspaceSecretRuntimeRoot = absoluteRegistryPath( env.THT_WORKSPACE_SECRET_RUNTIME_ROOT ?? "/tmp/thothii-workspace-secrets", "secret runtime root", ); const settingsFile = env.SETTINGS_FILE ?? "data/settings.json"; const internalQdrantUrl = internalServiceUrl( env.THT_INTERNAL_QDRANT_URL, "http://qdrant:6333", "internal Qdrant URL", ["qdrant", "localhost"], ); const internalEmbeddingUrl = internalServiceUrl( env.THT_INTERNAL_EMBEDDING_URL, "http://embedding:11434", "internal embedding URL", ["embedding", "localhost"], ); 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, authConfigFile, authStateRoot, authentication, publicExposure, sessionStorage, catalogDatabase: catalogDatabase(env), defaults: { provider: env.PI_PROVIDER, model: env.PI_MODEL, thinking: env.PI_THINKING }, maxPiProcesses: Number(env.MAX_PI_PROCESSES ?? 4), settingsFile, maintenanceFile: env.THT_MAINTENANCE_FILE ?? path.join(path.dirname(settingsFile), "maintenance.json"), dataRoot: env.THT_DATA_ROOT, ollamaEnsureTimeoutMs: Number(env.OLLAMA_ENSURE_TIMEOUT_MS ?? 60000), piManagementTimeoutMs: piManagementTimeout(env.PI_MANAGEMENT_TIMEOUT_MS), secretsFile, installationConfigFile, piAuthFile, secretFiles, modelApiKeyFile, dwhPrecheck: env.THT_DWH_PRECHECK === "true" || env.THT_DWH_PRECHECK === "1", legacyWorkspaceMode: legacyWorkspaceMode === "local", workspaceDiagnosticTimeoutMs: diagnosticTimeout(env.THT_WORKSPACE_DIAGNOSTIC_TIMEOUT_MS), catalogSyncTimeoutMs: catalogSyncTimeout(env.THT_CATALOG_SYNC_TIMEOUT_MS), workspaceRegistry, workspaceSecretStoreRoot, workspaceSecretRuntimeRoot, internalQdrantUrl, internalEmbeddingUrl, internalEmbeddingModel: env.THT_INTERNAL_EMBEDDING_MODEL ?? "qwen3-embedding:0.6b", internalEmbeddingDimensions: positiveDimension(env.THT_INTERNAL_EMBEDDING_DIMENSIONS, 1024), }; }