Files
memind/memory-v2-admin-config.mjs
john 93d7bc0cd5
Memind CI / Test, build, and release guards (pull_request) Successful in 4m37s
feat: add episodic history recall
2026-07-22 12:36:20 +08:00

473 lines
22 KiB
JavaScript

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,
};