/** * Cloud-agnostic secret resolution for Node.js services + dashboards. * * Call resolveSecrets() BEFORE Zod config parsing to populate * process.env from a secrets provider. Falls back gracefully when unavailable. * * Provider selection via SECRETS_PROVIDER env var: * - 'azure-keyvault' (default if AZURE_KEYVAULT_URL is set) — Azure Key Vault * - 'env' (default if no vault URL) — do nothing, use .env values as-is * * Backward compatible: resolveKeyVaultSecrets() still works identically. */ export type SecretsProviderType = 'azure-keyvault' | 'env'; export interface SecretMapping { /** Provider-specific secret name (e.g. 'lysnr-cosmos-key' for AKV) */ kvName: string; /** Environment variable name to populate (e.g. 'COSMOS_KEY') */ envVar: string; } /** * Resolve which secrets provider to use. */ function resolveSecretsProvider(): SecretsProviderType { const explicit = (process.env.SECRETS_PROVIDER || '').toLowerCase(); if (explicit === 'azure-keyvault' || explicit === 'azure') return 'azure-keyvault'; if (explicit === 'env') return 'env'; // Auto-detect: use AKV if AZURE_KEYVAULT_URL is set if (process.env.AZURE_KEYVAULT_URL) return 'azure-keyvault'; return 'env'; } /** * Cloud-agnostic secret resolution into process.env. * * - Only fetches secrets whose env var is empty/unset (env takes precedence). * - Skips entirely if provider is 'env' or no vault is configured. * - Logs warnings but does NOT throw — services fall back to .env values. * * @param secrets - Array of {kvName, envVar} mappings * @param opts - Optional overrides */ export async function resolveSecrets( secrets: SecretMapping[], opts?: { vaultUrl?: string; provider?: SecretsProviderType } ): Promise { const provider = opts?.provider ?? resolveSecretsProvider(); if (provider === 'env') return; // Nothing to resolve — use env vars as-is if (provider === 'azure-keyvault') { return resolveAzureKeyVaultSecrets(secrets, opts); } } /** * Resolve secrets from Azure Key Vault into process.env. * @deprecated Use resolveSecrets() instead — this is kept for backward compatibility. */ export async function resolveKeyVaultSecrets( secrets: SecretMapping[], opts?: { vaultUrl?: string } ): Promise { return resolveAzureKeyVaultSecrets(secrets, opts); } /** * Azure Key Vault implementation. */ async function resolveAzureKeyVaultSecrets( secrets: SecretMapping[], opts?: { vaultUrl?: string } ): Promise { const vaultUrl = opts?.vaultUrl || process.env.AZURE_KEYVAULT_URL; if (!vaultUrl) return; // No KV configured — use env vars as-is // Filter to only secrets that are missing from env const missing = secrets.filter(s => !process.env[s.envVar]); if (missing.length === 0) return; // All secrets already in env try { const { DefaultAzureCredential } = await import('@azure/identity'); const { SecretClient } = await import('@azure/keyvault-secrets'); const client = new SecretClient(vaultUrl, new DefaultAzureCredential()); const results = await Promise.allSettled( missing.map(async s => { const secret = await client.getSecret(s.kvName); if (secret.value) { process.env[s.envVar] = secret.value; } }) ); const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { console.warn( `[secrets] ${failures.length}/${missing.length} secrets failed to resolve — falling back to env vars` ); } } catch { console.warn(`[secrets] Unable to connect to Key Vault at ${vaultUrl} — using env vars`); } } /** * Standard secret mappings used across all LysnrAI services. * Services pick the subset they need. */ export const LYSNR_SECRETS = { COSMOS_KEY: { kvName: 'lysnr-cosmos-key', envVar: 'COSMOS_KEY' }, COSMOS_ENDPOINT: { kvName: 'lysnr-cosmos-endpoint', envVar: 'COSMOS_ENDPOINT' }, JWT_SECRET: { kvName: 'lysnr-jwt-secret', envVar: 'JWT_SECRET' }, STRIPE_SECRET_KEY: { kvName: 'lysnr-stripe-secret-key', envVar: 'STRIPE_SECRET_KEY' }, STRIPE_WEBHOOK_SECRET: { kvName: 'lysnr-stripe-webhook-secret', envVar: 'STRIPE_WEBHOOK_SECRET' }, BILLING_INTERNAL_KEY: { kvName: 'lysnr-billing-internal-key', envVar: 'BILLING_INTERNAL_KEY' }, AZURE_BLOB_CONNECTION_STRING: { kvName: 'lysnr-blob-connection-string', envVar: 'AZURE_BLOB_CONNECTION_STRING', }, AZURE_BLOB_ACCOUNT_KEY: { kvName: 'lysnr-blob-account-key', envVar: 'AZURE_BLOB_ACCOUNT_KEY' }, GEMINI_API_KEY: { kvName: 'lysnr-gemini-api-key', envVar: 'GEMINI_API_KEY' }, SEED_SECRET: { kvName: 'lysnr-seed-secret', envVar: 'SEED_SECRET' }, AZURE_SPEECH_KEY: { kvName: 'lysnr-azure-speech-key', envVar: 'AZURE_SPEECH_KEY' }, AZURE_OPENAI_KEY: { kvName: 'lysnr-azure-openai-key', envVar: 'AZURE_OPENAI_KEY' }, AZURE_OPENAI_ENDPOINT: { kvName: 'lysnr-azure-openai-endpoint', envVar: 'AZURE_OPENAI_ENDPOINT', }, } as const satisfies Record;