import crypto from 'node:crypto'; import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs'; const CONFIG_TABLE = 'h5_memory_v2_admin_config'; const CONFIG_SCOPE = 'global'; const FIELD_SPECS = [ { env: 'MEMORY_ENABLED', group: 'global', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_BACKEND', group: 'global', field: 'backend', type: 'string' }, { env: 'MEMORY_PROFILE_ENABLED', group: 'global', field: 'profileEnabled', type: 'boolean' }, { env: 'MEMORY_EVENT_LOG_ENABLED', group: 'global', field: 'eventLogEnabled', type: 'boolean' }, { env: 'MEMORY_VECTOR_ENABLED', group: 'global', field: 'vectorEnabled', type: 'boolean' }, { env: 'MEMORY_FAIL_OPEN', group: 'global', field: 'failOpen', type: 'boolean' }, { env: 'MEMIND_CHAT_LLM_ROUTER_ENABLED', group: 'chatIntentRouter', field: 'enabled', type: 'boolean' }, { env: 'MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID', group: 'chatIntentRouter', field: 'modelProviderKeyId', type: 'string' }, { env: 'MEMIND_CHAT_ROUTER_MODEL', group: 'chatIntentRouter', field: 'model', type: 'string' }, { env: 'MEMIND_CHAT_ROUTER_MODEL_API', group: 'chatIntentRouter', field: 'modelApiType', type: 'string' }, { env: 'MEMIND_CHAT_ROUTER_MIN_CONFIDENCE', group: 'chatIntentRouter', field: 'minConfidence', type: 'number' }, { env: 'MEMIND_CHAT_ROUTER_MEMORY_ENABLED', group: 'chatIntentRouter', field: 'memoryResolveEnabled', type: 'boolean' }, { env: 'MEMIND_CHAT_ROUTER_MEMORY_LIMIT', group: 'chatIntentRouter', field: 'memoryResolveLimit', type: 'number' }, { env: 'MEMIND_CHAT_ROUTER_TIMEOUT_MS', group: 'chatIntentRouter', field: 'timeoutMs', type: 'number' }, { env: 'MEMIND_CHAT_ROUTER_FALLBACK_ROUTE', group: 'chatIntentRouter', field: 'fallbackRoute', type: 'string' }, { env: 'MEMORY_CANDIDATE_ENABLED', group: 'candidateMemory', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_CANDIDATE_MODE', group: 'candidateMemory', field: 'mode', type: 'string' }, { env: 'MEMORY_CANDIDATE_MIN_IMPORTANCE', group: 'candidateMemory', field: 'minImportance', type: 'number' }, { env: 'MEMORY_CANDIDATE_MIN_CONFIDENCE', group: 'candidateMemory', field: 'minConfidence', type: 'number' }, { env: 'MEMORY_CANDIDATE_MAX_PENDING', group: 'candidateMemory', field: 'maxPending', type: 'number' }, { env: 'MEMORY_CANDIDATE_PERSISTENCE_ENABLED', group: 'candidateMemory', field: 'persistenceEnabled', type: 'boolean' }, { env: 'MEMORY_AGENT_RESOLVE_ENABLED', group: 'runtimeControl', field: 'agentResolveEnabled', type: 'boolean' }, { env: 'MEMORY_AGENT_INJECTION_MODE', group: 'runtimeControl', field: 'agentInjectionMode', type: 'string' }, { env: 'MEMORY_AGENT_CANARY_USER_IDS', group: 'runtimeControl', field: 'agentCanaryUserIds', type: 'string' }, { env: 'MEMORY_AGENT_RESOLVE_LIMIT', group: 'runtimeControl', field: 'agentResolveLimit', type: 'number' }, { env: 'MEMORY_AGENT_RESOLVE_TIMEOUT_MS', group: 'runtimeControl', field: 'agentResolveTimeoutMs', type: 'number' }, { env: 'MEMORY_PROMOTION_ENABLED', group: 'runtimeControl', field: 'promotionEnabled', type: 'boolean' }, { env: 'MEMORY_COMPACTION_V2_ENABLED', group: 'runtimeControl', field: 'compactionV2Enabled', type: 'boolean' }, { env: 'MEMORY_REFLECTION_ENABLED', group: 'runtimeControl', field: 'reflectionEnabled', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_WORKER_ENABLED', group: 'runtimeControl', field: 'lifecycleWorkerEnabled', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_ROLLOUT_MODE', group: 'runtimeControl', field: 'lifecycleRolloutMode', type: 'string' }, { env: 'MEMORY_LIFECYCLE_ROLLOUT_USER_IDS', group: 'runtimeControl', field: 'lifecycleRolloutUserIds', type: 'string' }, { env: 'MEMORY_POLICY_ENABLED', group: 'policy', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_POLICY_SAVE_EXPLICIT', group: 'policy', field: 'saveExplicit', type: 'boolean' }, { env: 'MEMORY_POLICY_REJECT_SENSITIVE', group: 'policy', field: 'rejectSensitive', type: 'boolean' }, { env: 'MEMORY_POLICY_REQUIRE_EVIDENCE', group: 'policy', field: 'requireEvidence', type: 'boolean' }, { env: 'MEMORY_POLICY_RETENTION_DAYS', group: 'policy', field: 'retentionDays', type: 'number' }, { env: 'MEMORY_RETRIEVER_ENABLED', group: 'retriever', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_RETRIEVER_EPISODIC_ENABLED', group: 'retriever', field: 'episodicEnabled', type: 'boolean' }, { env: 'MEMORY_RETRIEVER_EPISODIC_MODE', group: 'retriever', field: 'episodicMode', type: 'string' }, { env: 'MEMORY_RETRIEVER_EPISODIC_CANARY_USER_IDS', group: 'retriever', field: 'episodicCanaryUserIds', type: 'string' }, { env: 'MEMORY_RETRIEVER_SEMANTIC_ENABLED', group: 'retriever', field: 'semanticEnabled', type: 'boolean' }, { env: 'MEMORY_RETRIEVER_PREFERENCE_ENABLED', group: 'retriever', field: 'preferenceEnabled', type: 'boolean' }, { env: 'MEMORY_RETRIEVER_GOAL_ENABLED', group: 'retriever', field: 'goalEnabled', type: 'boolean' }, { env: 'MEMORY_RETRIEVER_LIMIT', group: 'retriever', field: 'limit', type: 'number' }, { env: 'MEMORY_RETRIEVER_TOKEN_BUDGET', group: 'retriever', field: 'tokenBudget', type: 'number' }, { env: 'MEMORY_RETRIEVER_TIMEOUT_MS', group: 'retriever', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_LIFECYCLE_ENABLED', group: 'lifecycle', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_DEDUPE_ENABLED', group: 'lifecycle', field: 'dedupeEnabled', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_CONFLICT_REVIEW', group: 'lifecycle', field: 'conflictReview', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_DECAY_ENABLED', group: 'lifecycle', field: 'decayEnabled', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_FORGETTING_ENABLED', group: 'lifecycle', field: 'forgettingEnabled', type: 'boolean' }, { env: 'MEMORY_LIFECYCLE_COMPACT_INTERVAL_HOURS', group: 'lifecycle', field: 'compactIntervalHours', type: 'number' }, { env: 'MEMORY_PERSONA_ENABLED', group: 'persona', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_PERSONA_PROVIDER', group: 'persona', field: 'provider', type: 'string' }, { env: 'MEMORY_PERSONA_SHADOW_MODE', group: 'persona', field: 'shadowMode', type: 'boolean' }, { env: 'MEMORY_PERSONA_MAX_TOKENS', group: 'persona', field: 'maxTokens', type: 'number' }, { env: 'MEMORY_PERSONA_CACHE_TTL_SECONDS', group: 'persona', field: 'cacheTtlSeconds', type: 'number' }, { env: 'MEMORY_GRAPH_ENABLED', group: 'graph', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_GRAPH_PROVIDER', group: 'graph', field: 'provider', type: 'string' }, { env: 'MEMORY_GRAPH_MAX_DEPTH', group: 'graph', field: 'maxDepth', type: 'number' }, { env: 'MEMORY_GRAPH_RELATION_LIMIT', group: 'graph', field: 'relationLimit', type: 'number' }, { env: 'MEMORY_USER_MANAGEMENT_ENABLED', group: 'userMemory', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_USER_REVIEW_ENABLED', group: 'userMemory', field: 'reviewEnabled', type: 'boolean' }, { env: 'MEMORY_USER_CORRECTION_ENABLED', group: 'userMemory', field: 'correctionEnabled', type: 'boolean' }, { env: 'MEMORY_USER_PIN_ENABLED', group: 'userMemory', field: 'pinEnabled', type: 'boolean' }, { env: 'MEMORY_USER_FORGET_ENABLED', group: 'userMemory', field: 'forgetEnabled', type: 'boolean' }, { env: 'MEMORY_USER_DELETE_PROPAGATION', group: 'userMemory', field: 'deletePropagation', type: 'boolean' }, { env: 'MEMORY_PLUGIN_HEALTH_ENABLED', group: 'pluginHealth', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_PLUGIN_HEALTH_INTERVAL_SECONDS', group: 'pluginHealth', field: 'intervalSeconds', type: 'number' }, { env: 'MEMORY_PLUGIN_HEALTH_TIMEOUT_MS', group: 'pluginHealth', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_PLUGIN_HEALTH_FAILURE_THRESHOLD', group: 'pluginHealth', field: 'failureThreshold', type: 'number' }, { env: 'MEMORY_PLUGIN_HEALTH_AUTO_FALLBACK', group: 'pluginHealth', field: 'autoFallback', type: 'boolean' }, { env: 'MEMORY_PGVECTOR_ENABLED', group: 'pgvector', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_PGVECTOR_DATABASE_URL', group: 'pgvector', field: 'databaseUrl', type: 'secret' }, { env: 'MEMORY_PGVECTOR_TABLE', group: 'pgvector', field: 'table', type: 'string' }, { env: 'MEMORY_PGVECTOR_EMBEDDING_MODULE', group: 'pgvector', field: 'embeddingModule', type: 'string' }, { env: 'MEMORY_PGVECTOR_POOL_MAX', group: 'pgvector', field: 'poolMax', type: 'number' }, { env: 'MEMORY_PGVECTOR_LIMIT', group: 'pgvector', field: 'limit', type: 'number' }, { env: 'MEMORY_QDRANT_ENABLED', group: 'qdrant', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_QDRANT_URL', group: 'qdrant', field: 'url', type: 'string' }, { env: 'MEMORY_QDRANT_COLLECTION', group: 'qdrant', field: 'collection', type: 'string' }, { env: 'MEMORY_QDRANT_EMBEDDING_MODULE', group: 'qdrant', field: 'embeddingModule', type: 'string' }, { env: 'MEMORY_QDRANT_API_KEY', group: 'qdrant', field: 'apiKey', type: 'secret' }, { env: 'MEMORY_QDRANT_TIMEOUT_MS', group: 'qdrant', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_QDRANT_LIMIT', group: 'qdrant', field: 'limit', type: 'number' }, { env: 'MEMORY_WEAVIATE_ENABLED', group: 'weaviate', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_WEAVIATE_URL', group: 'weaviate', field: 'url', type: 'string' }, { env: 'MEMORY_WEAVIATE_COLLECTION', group: 'weaviate', field: 'collection', type: 'string' }, { env: 'MEMORY_WEAVIATE_EMBEDDING_MODULE', group: 'weaviate', field: 'embeddingModule', type: 'string' }, { env: 'MEMORY_WEAVIATE_API_KEY', group: 'weaviate', field: 'apiKey', type: 'secret' }, { env: 'MEMORY_WEAVIATE_TIMEOUT_MS', group: 'weaviate', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_WEAVIATE_LIMIT', group: 'weaviate', field: 'limit', type: 'number' }, { env: 'MEMORY_MEM0_ENABLED', group: 'mem0', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_MEM0_API_KEY', group: 'mem0', field: 'apiKey', type: 'secret' }, { env: 'MEMORY_MEM0_PROJECT_ID', group: 'mem0', field: 'projectId', type: 'string' }, { env: 'MEMORY_MEM0_BASE_URL', group: 'mem0', field: 'baseUrl', type: 'string' }, { env: 'MEMORY_MEM0_WRITE_PATH', group: 'mem0', field: 'writePath', type: 'string' }, { env: 'MEMORY_MEM0_COMPACT_PATH', group: 'mem0', field: 'compactPath', type: 'string' }, { env: 'MEMORY_MEM0_MODEL_PROVIDER_KEY_ID', group: 'mem0', field: 'modelProviderKeyId', type: 'string' }, { env: 'MEMORY_MEM0_MODEL', group: 'mem0', field: 'model', type: 'string' }, { env: 'MEMORY_MEM0_MODEL_API', group: 'mem0', field: 'modelApiType', type: 'string' }, { env: 'MEMORY_MEM0_TIMEOUT_MS', group: 'mem0', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_LETTA_ENABLED', group: 'letta', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_LETTA_API_KEY', group: 'letta', field: 'apiKey', type: 'secret' }, { env: 'MEMORY_LETTA_PROJECT_ID', group: 'letta', field: 'projectId', type: 'string' }, { env: 'MEMORY_LETTA_AGENT_ID', group: 'letta', field: 'agentId', type: 'string' }, { env: 'MEMORY_LETTA_BASE_URL', group: 'letta', field: 'baseUrl', type: 'string' }, { env: 'MEMORY_LETTA_RESOLVE_PATH', group: 'letta', field: 'resolvePath', type: 'string' }, { env: 'MEMORY_LETTA_WRITE_PATH', group: 'letta', field: 'writePath', type: 'string' }, { env: 'MEMORY_LETTA_COMPACT_PATH', group: 'letta', field: 'compactPath', type: 'string' }, { env: 'MEMORY_LETTA_MODEL_PROVIDER_KEY_ID', group: 'letta', field: 'modelProviderKeyId', type: 'string' }, { env: 'MEMORY_LETTA_MODEL', group: 'letta', field: 'model', type: 'string' }, { env: 'MEMORY_LETTA_MODEL_API', group: 'letta', field: 'modelApiType', type: 'string' }, { env: 'MEMORY_LETTA_TIMEOUT_MS', group: 'letta', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_NEO4J_ENABLED', group: 'neo4j', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_NEO4J_URI', group: 'neo4j', field: 'uri', type: 'string' }, { env: 'MEMORY_NEO4J_HTTP_URL', group: 'neo4j', field: 'httpUrl', type: 'string' }, { env: 'MEMORY_NEO4J_USER', group: 'neo4j', field: 'user', type: 'string' }, { env: 'MEMORY_NEO4J_PASSWORD', group: 'neo4j', field: 'password', type: 'secret' }, { env: 'MEMORY_NEO4J_DATABASE', group: 'neo4j', field: 'database', type: 'string' }, { env: 'MEMORY_NEO4J_TIMEOUT_MS', group: 'neo4j', field: 'timeoutMs', type: 'number' }, { env: 'MEMORY_REDIS_STREAMS_ENABLED', group: 'redisStreams', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_REDIS_STREAMS_URL', group: 'redisStreams', field: 'url', type: 'string' }, { env: 'MEMORY_REDIS_STREAMS_STREAM', group: 'redisStreams', field: 'stream', type: 'string' }, { env: 'MEMORY_LANGGRAPH_ENABLED', group: 'langgraph', field: 'enabled', type: 'boolean' }, { env: 'MEMORY_LANGGRAPH_URL', group: 'langgraph', field: 'url', type: 'string' }, { env: 'MEMORY_LANGGRAPH_POLICY_ID', group: 'langgraph', field: 'policyId', type: 'string' }, { env: 'MEMORY_LANGGRAPH_API_KEY', group: 'langgraph', field: 'apiKey', type: 'secret' }, { env: 'MEMORY_LANGGRAPH_RESOLVE_PATH', group: 'langgraph', field: 'resolvePath', type: 'string' }, { env: 'MEMORY_LANGGRAPH_MODEL_PROVIDER_KEY_ID', group: 'langgraph', field: 'modelProviderKeyId', type: 'string' }, { env: 'MEMORY_LANGGRAPH_MODEL', group: 'langgraph', field: 'model', type: 'string' }, { env: 'MEMORY_LANGGRAPH_MODEL_API', group: 'langgraph', field: 'modelApiType', type: 'string' }, { env: 'MEMORY_LANGGRAPH_TIMEOUT_MS', group: 'langgraph', field: 'timeoutMs', type: 'number' }, ]; const GROUPS = [ 'global', 'chatIntentRouter', 'candidateMemory', 'runtimeControl', 'policy', 'retriever', 'lifecycle', 'persona', 'graph', 'userMemory', 'pluginHealth', 'pgvector', 'qdrant', 'weaviate', 'mem0', 'letta', 'neo4j', 'redisStreams', 'langgraph', ]; function defaultConfigShape() { return Object.fromEntries(GROUPS.map((group) => [group, {}])); } function normalizeBoolean(value, fallback = false) { if (value == null || value === '') return fallback; if (typeof value === 'boolean') return value; const normalized = String(value).trim().toLowerCase(); if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; if (['0', 'false', 'no', 'off'].includes(normalized)) return false; return fallback; } function normalizeNumber(value) { if (value == null || value === '') return ''; const parsed = Number(value); return Number.isFinite(parsed) ? String(parsed) : ''; } function normalizeString(value) { if (value == null) return ''; return String(value).trim(); } function normalizeSecretPatch(value) { if (value === undefined) return undefined; if (value === null) return ''; return String(value); } function cloneConfig(config = null) { return structuredClone?.(config ?? defaultConfigShape()) ?? JSON.parse(JSON.stringify(config ?? defaultConfigShape())); } function parseJsonLike(value, fallback) { if (value == null || value === '') return fallback; if (typeof value === 'string') { try { return JSON.parse(value); } catch { return fallback; } } if (typeof value === 'object') return value; return fallback; } function flattenConfig(config, secrets) { const env = {}; for (const spec of FIELD_SPECS) { const value = spec.type === 'secret' ? secrets?.[spec.group]?.[spec.field] : config?.[spec.group]?.[spec.field]; if (spec.type === 'boolean') { env[spec.env] = normalizeBoolean(value, false) ? '1' : '0'; } else if (spec.type === 'number') { env[spec.env] = normalizeNumber(value); } else { env[spec.env] = normalizeString(value); } } if (!env.MEMORY_PGVECTOR_ENABLED) { env.MEMORY_PGVECTOR_ENABLED = env.MEMORY_VECTOR_ENABLED; } return env; } function applyEnv(config, secrets, env = process.env) { for (const spec of FIELD_SPECS) { const raw = env?.[spec.env]; if (spec.type === 'secret') { if (raw != null && String(raw).trim() !== '') { secrets[spec.group][spec.field] = String(raw).trim(); } continue; } if (spec.type === 'boolean') { config[spec.group][spec.field] = normalizeBoolean(raw, Boolean(config[spec.group][spec.field])); } else if (spec.type === 'number') { const normalized = normalizeNumber(raw); if (normalized !== '') config[spec.group][spec.field] = normalized; } else if (raw != null && String(raw).trim() !== '') { config[spec.group][spec.field] = String(raw).trim(); } } if (config.pgvector.enabled === undefined) { config.pgvector.enabled = normalizeBoolean(env?.MEMORY_VECTOR_ENABLED, false); } if (env?.MEMIND_CHAT_ROUTER_MEMORY_ENABLED == null || String(env.MEMIND_CHAT_ROUTER_MEMORY_ENABLED).trim() === '') { config.chatIntentRouter.memoryResolveEnabled = true; } if (!normalizeString(config.chatIntentRouter.modelApiType)) { config.chatIntentRouter.modelApiType = 'chat'; } if (!normalizeString(config.chatIntentRouter.minConfidence)) { config.chatIntentRouter.minConfidence = '0.65'; } if (!normalizeString(config.chatIntentRouter.memoryResolveLimit)) { config.chatIntentRouter.memoryResolveLimit = '8'; } if (!normalizeString(config.chatIntentRouter.timeoutMs)) { config.chatIntentRouter.timeoutMs = '2500'; } if (config.chatIntentRouter.enabled === undefined) { config.chatIntentRouter.enabled = false; } const fallbackRoute = normalizeString(config.chatIntentRouter.fallbackRoute); if (!fallbackRoute || fallbackRoute === 'direct_chat') { config.chatIntentRouter.fallbackRoute = 'agent_orchestration'; } return { config, secrets }; } function buildMaskedView(config, secrets) { const view = cloneConfig(config); for (const spec of FIELD_SPECS) { if (spec.type !== 'secret') continue; const raw = normalizeString(secrets?.[spec.group]?.[spec.field]); view[spec.group][`${spec.field}Configured`] = raw.length > 0; view[spec.group][`${spec.field}Masked`] = raw ? maskApiKey(raw) : ''; } return view; } async function ensureConfigTable(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( config_scope VARCHAR(32) PRIMARY KEY, config_json JSON NOT NULL, secrets_ciphertext MEDIUMTEXT NULL, secrets_iv VARCHAR(255) NULL, secrets_tag VARCHAR(255) NULL, updated_by CHAR(36) NULL, updated_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } function hashState(config, secrets) { return crypto .createHash('sha256') .update(JSON.stringify({ config, secrets })) .digest('hex'); } async function loadStoredState(pool) { await ensureConfigTable(pool); const [rows] = await pool.query( `SELECT config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at FROM ${CONFIG_TABLE} WHERE config_scope = ? LIMIT 1`, [CONFIG_SCOPE], ); const row = rows[0]; if (!row) return null; let secrets = defaultConfigShape(); if (row.secrets_ciphertext && row.secrets_iv && row.secrets_tag) { secrets = { ...defaultConfigShape(), ...parseJsonLike( decryptSecret( { ciphertext: row.secrets_ciphertext, iv: row.secrets_iv, tag: row.secrets_tag, }, ), {}, ), }; } return { config: { ...defaultConfigShape(), ...parseJsonLike(row.config_json, {}) }, secrets, updatedAt: Number(row.updated_at ?? 0) || null, updatedBy: row.updated_by ?? null, }; } function mergePatch(currentConfig, currentSecrets, patch = {}) { const nextConfig = cloneConfig(currentConfig); const nextSecrets = cloneConfig(currentSecrets); for (const spec of FIELD_SPECS) { const section = patch?.[spec.group]; if (!section || !(spec.field in section)) continue; if (spec.type === 'secret') { const value = normalizeSecretPatch(section[spec.field]); if (value === undefined) continue; nextSecrets[spec.group][spec.field] = normalizeString(value); continue; } const value = section[spec.field]; if (spec.type === 'boolean') { nextConfig[spec.group][spec.field] = normalizeBoolean(value, false); } else if (spec.type === 'number') { nextConfig[spec.group][spec.field] = normalizeNumber(value); } else { nextConfig[spec.group][spec.field] = normalizeString(value); } } return { nextConfig, nextSecrets }; } export function createMemoryV2AdminConfigService(pool, { env = process.env } = {}) { async function loadState() { const defaults = applyEnv(defaultConfigShape(), defaultConfigShape(), env); const stored = await loadStoredState(pool); if (!stored) { return { config: defaults.config, secrets: defaults.secrets, updatedAt: null, updatedBy: null, }; } return { config: { ...defaults.config, ...stored.config }, secrets: { ...defaults.secrets, ...stored.secrets }, updatedAt: stored.updatedAt, updatedBy: stored.updatedBy, }; } return { async getAdminConfig() { const state = await loadState(); return { config: buildMaskedView(state.config, state.secrets), updatedAt: state.updatedAt, updatedBy: state.updatedBy, }; }, async updateAdminConfig(patch = {}, { updatedBy = null } = {}) { const state = await loadState(); const { nextConfig, nextSecrets } = mergePatch(state.config, state.secrets, patch); await ensureConfigTable(pool); const now = Date.now(); const encryptedSecrets = encryptSecret(JSON.stringify(nextSecrets)); await pool.query( `INSERT INTO ${CONFIG_TABLE} (config_scope, config_json, secrets_ciphertext, secrets_iv, secrets_tag, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE config_json = VALUES(config_json), secrets_ciphertext = VALUES(secrets_ciphertext), secrets_iv = VALUES(secrets_iv), secrets_tag = VALUES(secrets_tag), updated_by = VALUES(updated_by), updated_at = VALUES(updated_at)`, [ CONFIG_SCOPE, JSON.stringify(nextConfig), encryptedSecrets.ciphertext, encryptedSecrets.iv, encryptedSecrets.tag, updatedBy, now, ], ); return this.getAdminConfig(); }, async getRuntimeState() { const state = await loadState(); const overrides = flattenConfig(state.config, state.secrets); return { source: 'admin-db', updatedAt: state.updatedAt, updatedBy: state.updatedBy, fingerprint: `${state.updatedAt ?? 'env'}:${hashState(state.config, state.secrets)}`, overrides, }; }, }; } export const memoryV2AdminConfigInternals = { CONFIG_SCOPE, CONFIG_TABLE, FIELD_SPECS, defaultConfigShape, flattenConfig, applyEnv, mergePatch, buildMaskedView, };