432 lines
18 KiB
TypeScript
432 lines
18 KiB
TypeScript
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<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;
|
|
/** 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<string, string | undefined>): 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<string, string | undefined>,
|
|
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<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 = 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),
|
|
};
|
|
}
|