Files
ThothII/backend/src/config.ts
T

171 lines
7.1 KiB
TypeScript

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<Record<string, string | undefined>>;
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<string, string | undefined>): 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<string, string | undefined> = {};
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,
};
}