diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 81c3dbf49b..e9f10d904f 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -302,14 +302,15 @@ fn create_ldk_settings( }, mint_info: cdk_mintd::config::MintInfo::default(), limits: cdk_mintd::config::Limits::default(), - ln: cdk_mintd::config::Ln { + ln: vec![cdk_mintd::config::Ln { ln_backend: cdk_mintd::config::LnBackend::LdkNode, + unit: cdk::nuts::CurrencyUnit::Sat, invoice_description: None, min_mint: 1.into(), max_mint: 500_000.into(), min_melt: 1.into(), max_melt: 500_000.into(), - }, + }], cln: None, lnbits: None, lnd: None, diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index f977f6c96f..f28ad285da 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -220,14 +220,15 @@ pub fn create_fake_wallet_settings( }, mint_info: cdk_mintd::config::MintInfo::default(), limits: cdk_mintd::config::Limits::default(), - ln: cdk_mintd::config::Ln { + ln: vec![cdk_mintd::config::Ln { ln_backend: cdk_mintd::config::LnBackend::FakeWallet, + unit: cdk::nuts::CurrencyUnit::Sat, invoice_description: None, min_mint: DEFAULT_MIN_MINT.into(), max_mint: DEFAULT_MAX_MINT.into(), min_melt: DEFAULT_MIN_MELT.into(), max_melt: DEFAULT_MAX_MELT.into(), - }, + }], cln: None, lnbits: None, lnd: None, @@ -276,14 +277,15 @@ pub fn create_cln_settings( }, mint_info: cdk_mintd::config::MintInfo::default(), limits: cdk_mintd::config::Limits::default(), - ln: cdk_mintd::config::Ln { + ln: vec![cdk_mintd::config::Ln { ln_backend: cdk_mintd::config::LnBackend::Cln, + unit: cdk::nuts::CurrencyUnit::Sat, invoice_description: None, min_mint: DEFAULT_MIN_MINT.into(), max_mint: DEFAULT_MAX_MINT.into(), min_melt: DEFAULT_MIN_MELT.into(), max_melt: DEFAULT_MAX_MELT.into(), - }, + }], cln: Some(cln_config), lnbits: None, lnd: None, @@ -327,14 +329,15 @@ pub fn create_lnd_settings( }, mint_info: cdk_mintd::config::MintInfo::default(), limits: cdk_mintd::config::Limits::default(), - ln: cdk_mintd::config::Ln { + ln: vec![cdk_mintd::config::Ln { ln_backend: cdk_mintd::config::LnBackend::Lnd, + unit: cdk::nuts::CurrencyUnit::Sat, invoice_description: None, min_mint: DEFAULT_MIN_MINT.into(), max_mint: DEFAULT_MAX_MINT.into(), min_melt: DEFAULT_MIN_MELT.into(), max_melt: DEFAULT_MAX_MELT.into(), - }, + }], cln: None, lnbits: None, ldk_node: None, diff --git a/crates/cdk-mintd/README.md b/crates/cdk-mintd/README.md index 7f06a50e36..b3bc6431c6 100644 --- a/crates/cdk-mintd/README.md +++ b/crates/cdk-mintd/README.md @@ -186,6 +186,32 @@ engine = "postgres" url = "postgresql://mint_user:password@localhost:5432/cdk_mint" ``` +### With Multiple Lightning Backends + +A single mint can serve more than one currency unit by configuring a separate backend per unit. Replace the single `[ln]` block with one `[[ln]]` block per `(unit, method)` pair, and keep the existing per-backend config sections (`[cln]`, `[lnbits]`, etc.) as-is. + +```toml +[[ln]] +ln_backend = "cln" +unit = "sat" + +[[ln]] +ln_backend = "lnbits" +unit = "eur" + +[cln] +rpc_path = "/home/bitcoin/.lightning/bitcoin/lightning-rpc" + +[lnbits] +admin_api_key = "..." +invoice_api_key = "..." +lnbits_api = "https://lnbits.example.com" +``` + +Each `[[ln]]` block carries its own `min_mint`, `max_mint`, `min_melt`, `max_melt` if you want different limits per unit. Two entries colliding on the same `(unit, method)` pair are rejected at startup. + +The legacy single `[ln]` form is still accepted; it's equivalent to one `[[ln]]` entry with `unit = "sat"` (the default). `CDK_MINTD_LN_*` environment variables only apply when there is exactly one (or zero) `[[ln]]` entry — multi-backend setups must be configured via the file. + ## Directory Structure After setup and first run, your directory will look like: diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index aa13d27bdb..469082f4c4 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -93,9 +93,19 @@ max_connections = 20 # Connection timeout in seconds (optional, defaults to 10) connection_timeout_seconds = 10 +# Lightning backends. Use [ln] for a single backend, or repeat [[ln]] for one +# backend per (unit, method) pair, e.g.: +# [[ln]] +# ln_backend = "cln" +# unit = "sat" +# [[ln]] +# ln_backend = "lnbits" +# unit = "eur" + [ln] # Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode' ln_backend = "fakewallet" +# unit = "sat" # Optional, defaults to "sat" # min_mint=1 # max_mint=500000 # min_melt=1 @@ -175,7 +185,6 @@ ln_backend = "fakewallet" # webserver_port = 0 # 0 = auto-assign available port [fake_wallet] -supported_units = ["sat"] fee_percent = 0.02 reserve_fee_min = 1 min_delay_time = 1 @@ -202,7 +211,6 @@ max_delay_time = 3 # [grpc_processor] # gRPC Payment Processor configuration -# supported_units = ["sat"] # addr = "127.0.0.1" # port = 50051 # tls_dir = "/path/to/tls" diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index a750c9dbd4..49de7e7159 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -177,17 +177,33 @@ impl std::str::FromStr for LnBackend { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Ln { pub ln_backend: LnBackend, + #[serde(default)] + pub unit: CurrencyUnit, + #[serde(default)] pub invoice_description: Option, + #[serde(default = "default_ln_min")] pub min_mint: Amount, + #[serde(default = "default_ln_max")] pub max_mint: Amount, + #[serde(default = "default_ln_min")] pub min_melt: Amount, + #[serde(default = "default_ln_max")] pub max_melt: Amount, } +fn default_ln_min() -> Amount { + 1.into() +} + +fn default_ln_max() -> Amount { + 500_000.into() +} + impl Default for Ln { fn default() -> Self { Ln { ln_backend: LnBackend::default(), + unit: CurrencyUnit::default(), invoice_description: None, min_mint: 1.into(), max_mint: 500_000.into(), @@ -197,6 +213,23 @@ impl Default for Ln { } } +fn deserialize_ln<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum LnOneOrMany { + Many(Vec), + One(Ln), + } + + match LnOneOrMany::deserialize(deserializer)? { + LnOneOrMany::Many(v) => Ok(v), + LnOneOrMany::One(l) => Ok(vec![l]), + } +} + #[cfg(feature = "lnbits")] #[derive(Clone, Serialize, Deserialize)] pub struct LNbits { @@ -438,8 +471,11 @@ fn default_keyset_version() -> String { #[cfg(feature = "fakewallet")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FakeWallet { + #[serde(default = "default_fake_wallet_supported_units")] pub supported_units: Vec, + #[serde(default = "default_fake_wallet_fee_percent")] pub fee_percent: f32, + #[serde(default = "default_fake_wallet_reserve_fee_min")] pub reserve_fee_min: Amount, #[serde(default = "default_min_delay_time")] pub min_delay_time: u64, @@ -476,6 +512,21 @@ fn default_reserve_fee_min() -> Amount { 2.into() } +#[cfg(feature = "fakewallet")] +fn default_fake_wallet_supported_units() -> Vec { + vec![CurrencyUnit::Sat] +} + +#[cfg(feature = "fakewallet")] +fn default_fake_wallet_fee_percent() -> f32 { + 0.02 +} + +#[cfg(feature = "fakewallet")] +fn default_fake_wallet_reserve_fee_min() -> Amount { + 2.into() +} + #[cfg(feature = "fakewallet")] fn default_min_delay_time() -> u64 { 1 @@ -647,7 +698,8 @@ fn default_blind() -> AuthType { pub struct Settings { pub info: Info, pub mint_info: MintInfo, - pub ln: Ln, + #[serde(default, deserialize_with = "deserialize_ln")] + pub ln: Vec, /// Transaction limits for DoS protection #[serde(default)] pub limits: Limits, @@ -859,6 +911,95 @@ mod tests { assert!(debug_output.contains(" Result { @@ -90,7 +90,23 @@ impl Settings { self.info = self.info.clone().from_env(); self.mint_info = self.mint_info.clone().from_env(); - self.ln = self.ln.clone().from_env(); + // CDK_MINTD_LN_* env vars only apply when there is exactly one (or zero) [[ln]] entry. + match self.ln.len() { + 0 => { + let ln = Ln::default().from_env(); + if ln.ln_backend != LnBackend::None { + self.ln.push(ln); + } + } + 1 => { + self.ln[0] = self.ln[0].clone().from_env(); + } + _ => { + tracing::warn!( + "CDK_MINTD_LN_* environment variables ignored: multiple [[ln]] entries configured" + ); + } + } self.limits = self.limits.clone().from_env(); { @@ -120,35 +136,38 @@ impl Settings { self.prometheus = Some(self.prometheus.clone().unwrap_or_default().from_env()); } - match self.ln.ln_backend { - #[cfg(feature = "cln")] - LnBackend::Cln => { - self.cln = Some(self.cln.clone().unwrap_or_default().from_env()); - } - #[cfg(feature = "lnbits")] - LnBackend::LNbits => { - self.lnbits = Some(self.lnbits.clone().unwrap_or_default().from_env()); - } - #[cfg(feature = "fakewallet")] - LnBackend::FakeWallet => { - self.fake_wallet = Some(self.fake_wallet.clone().unwrap_or_default().from_env()); - } - #[cfg(feature = "lnd")] - LnBackend::Lnd => { - self.lnd = Some(self.lnd.clone().unwrap_or_default().from_env()); - } - #[cfg(feature = "ldk-node")] - LnBackend::LdkNode => { - self.ldk_node = Some(self.ldk_node.clone().unwrap_or_default().from_env()); - } - #[cfg(feature = "grpc-processor")] - LnBackend::GrpcProcessor => { - self.grpc_processor = - Some(self.grpc_processor.clone().unwrap_or_default().from_env()); + if self.ln.len() == 1 { + match self.ln[0].ln_backend { + #[cfg(feature = "cln")] + LnBackend::Cln => { + self.cln = Some(self.cln.clone().unwrap_or_default().from_env()); + } + #[cfg(feature = "lnbits")] + LnBackend::LNbits => { + self.lnbits = Some(self.lnbits.clone().unwrap_or_default().from_env()); + } + #[cfg(feature = "fakewallet")] + LnBackend::FakeWallet => { + self.fake_wallet = + Some(self.fake_wallet.clone().unwrap_or_default().from_env()); + } + #[cfg(feature = "lnd")] + LnBackend::Lnd => { + self.lnd = Some(self.lnd.clone().unwrap_or_default().from_env()); + } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + self.ldk_node = Some(self.ldk_node.clone().unwrap_or_default().from_env()); + } + #[cfg(feature = "grpc-processor")] + LnBackend::GrpcProcessor => { + self.grpc_processor = + Some(self.grpc_processor.clone().unwrap_or_default().from_env()); + } + LnBackend::None => bail!("Ln backend must be set"), + #[allow(unreachable_patterns)] + _ => bail!("Selected Ln backend is not enabled in this build"), } - LnBackend::None => bail!("Ln backend must be set"), - #[allow(unreachable_patterns)] - _ => bail!("Selected Ln backend is not enabled in this build"), } Ok(self.clone()) diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 70eb3e99a4..b1323fc807 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -476,81 +476,112 @@ async fn configure_lightning_backend( work_dir: &Path, _kv_store: Option + Send + Sync>>, ) -> Result { - let mint_melt_limits = MintMeltLimits { - mint_min: settings.ln.min_mint, - mint_max: settings.ln.max_mint, - melt_min: settings.ln.min_melt, - melt_max: settings.ln.max_melt, - }; + if settings.ln.is_empty() { + bail!("Lightning backend must be configured"); + } - tracing::debug!("Ln backend: {:?}", settings.ln.ln_backend); - - match settings.ln.ln_backend { - #[cfg(feature = "cln")] - LnBackend::Cln => { - let cln_settings = settings - .cln - .clone() - .expect("Config checked at load that cln is some"); - let cln = cln_settings - .setup(settings, CurrencyUnit::Msat, None, work_dir, _kv_store) - .await?; - #[cfg(feature = "prometheus")] - let cln = MetricsMintPayment::new(cln); + let mut seen: HashSet<(CurrencyUnit, PaymentMethod)> = HashSet::new(); - mint_builder = configure_backend_for_unit( - settings, - mint_builder, - CurrencyUnit::Sat, - mint_melt_limits, - Arc::new(cln), - ) - .await?; - } - #[cfg(feature = "lnbits")] - LnBackend::LNbits => { - let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); - let lnbits = lnbits_settings - .setup(settings, CurrencyUnit::Sat, None, work_dir, None) + for ln_entry in &settings.ln { + let mint_melt_limits = MintMeltLimits { + mint_min: ln_entry.min_mint, + mint_max: ln_entry.max_mint, + melt_min: ln_entry.min_melt, + melt_max: ln_entry.max_melt, + }; + + tracing::debug!( + "Ln backend: {:?} (unit: {:?})", + ln_entry.ln_backend, + ln_entry.unit + ); + + match ln_entry.ln_backend { + #[cfg(feature = "cln")] + LnBackend::Cln => { + let cln_settings = settings + .cln + .clone() + .expect("Config checked at load that cln is some"); + let cln = cln_settings + .setup( + settings, + CurrencyUnit::Msat, + None, + work_dir, + _kv_store.clone(), + ) + .await?; + #[cfg(feature = "prometheus")] + let cln = MetricsMintPayment::new(cln); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + ln_entry.unit.clone(), + mint_melt_limits, + Arc::new(cln), + &mut seen, + ) .await?; - #[cfg(feature = "prometheus")] - let lnbits = MetricsMintPayment::new(lnbits); + } + #[cfg(feature = "lnbits")] + LnBackend::LNbits => { + let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); + let lnbits = lnbits_settings + .setup(settings, CurrencyUnit::Sat, None, work_dir, None) + .await?; + #[cfg(feature = "prometheus")] + let lnbits = MetricsMintPayment::new(lnbits); - mint_builder = configure_backend_for_unit( - settings, - mint_builder, - CurrencyUnit::Sat, - mint_melt_limits, - Arc::new(lnbits), - ) - .await?; - } - #[cfg(feature = "lnd")] - LnBackend::Lnd => { - let lnd_settings = settings.clone().lnd.expect("Checked at config load"); - let lnd = lnd_settings - .setup(settings, CurrencyUnit::Msat, None, work_dir, _kv_store) + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + ln_entry.unit.clone(), + mint_melt_limits, + Arc::new(lnbits), + &mut seen, + ) .await?; - #[cfg(feature = "prometheus")] - let lnd = MetricsMintPayment::new(lnd); + } + #[cfg(feature = "lnd")] + LnBackend::Lnd => { + let lnd_settings = settings.clone().lnd.expect("Checked at config load"); + let lnd = lnd_settings + .setup( + settings, + CurrencyUnit::Msat, + None, + work_dir, + _kv_store.clone(), + ) + .await?; + #[cfg(feature = "prometheus")] + let lnd = MetricsMintPayment::new(lnd); - mint_builder = configure_backend_for_unit( - settings, - mint_builder, - CurrencyUnit::Sat, - mint_melt_limits, - Arc::new(lnd), - ) - .await?; - } - #[cfg(feature = "fakewallet")] - LnBackend::FakeWallet => { - let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); - tracing::info!("Using fake wallet: {:?}", fake_wallet); + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + ln_entry.unit.clone(), + mint_melt_limits, + Arc::new(lnd), + &mut seen, + ) + .await?; + } + #[cfg(feature = "fakewallet")] + LnBackend::FakeWallet => { + let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); + tracing::info!("Using fake wallet: {:?}", fake_wallet); - for unit in fake_wallet.clone().supported_units { let fake = fake_wallet - .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) + .setup( + settings, + ln_entry.unit.clone(), + None, + work_dir, + _kv_store.clone(), + ) .await?; #[cfg(feature = "prometheus")] let fake = MetricsMintPayment::new(fake); @@ -558,49 +589,48 @@ async fn configure_lightning_backend( mint_builder = configure_backend_for_unit( settings, mint_builder, - unit.clone(), + ln_entry.unit.clone(), mint_melt_limits, Arc::new(fake), + &mut seen, ) .await?; - } - for rotation_cfg in &fake_wallet.keyset_rotations { - use cdk::mint::KeysetRotation; + for rotation_cfg in &fake_wallet.keyset_rotations { + use cdk::mint::KeysetRotation; - let amounts = cdk::mint::UnitConfig::default().amounts; - let final_expiry = if rotation_cfg.expired { - Some(cdk::util::unix_time().saturating_sub(3600)) - } else { - None - }; + let amounts = cdk::mint::UnitConfig::default().amounts; + let final_expiry = if rotation_cfg.expired { + Some(cdk::util::unix_time().saturating_sub(3600)) + } else { + None + }; - mint_builder = mint_builder.with_keyset_rotation(KeysetRotation { - unit: rotation_cfg.unit.clone(), - amounts, - input_fee_ppk: rotation_cfg.input_fee_ppk, - use_keyset_v2: rotation_cfg.version == "v2", - final_expiry, - }); + mint_builder = mint_builder.with_keyset_rotation(KeysetRotation { + unit: rotation_cfg.unit.clone(), + amounts, + input_fee_ppk: rotation_cfg.input_fee_ppk, + use_keyset_v2: rotation_cfg.version == "v2", + final_expiry, + }); + } } - } - #[cfg(feature = "grpc-processor")] - LnBackend::GrpcProcessor => { - let grpc_processor = settings - .clone() - .grpc_processor - .expect("grpc processor config defined"); - - tracing::info!( - "Attempting to start with gRPC payment processor at {}:{}.", - grpc_processor.addr, - grpc_processor.port - ); + #[cfg(feature = "grpc-processor")] + LnBackend::GrpcProcessor => { + let grpc_processor = settings + .clone() + .grpc_processor + .expect("grpc processor config defined"); + + tracing::info!( + "Attempting to start with gRPC payment processor at {}:{} for unit {:?}.", + grpc_processor.addr, + grpc_processor.port, + ln_entry.unit + ); - for unit in grpc_processor.clone().supported_units { - tracing::debug!("Adding unit: {:?}", unit); let processor = grpc_processor - .setup(settings, unit.clone(), None, work_dir, None) + .setup(settings, ln_entry.unit.clone(), None, work_dir, None) .await?; #[cfg(feature = "prometheus")] let processor = MetricsMintPayment::new(processor); @@ -608,39 +638,47 @@ async fn configure_lightning_backend( mint_builder = configure_backend_for_unit( settings, mint_builder, - unit.clone(), + ln_entry.unit.clone(), mint_melt_limits, Arc::new(processor), + &mut seen, ) .await?; } - } - #[cfg(feature = "ldk-node")] - LnBackend::LdkNode => { - let ldk_node_settings = settings.clone().ldk_node.expect("Checked at config load"); - tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings); + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + let ldk_node_settings = settings.clone().ldk_node.expect("Checked at config load"); + tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings); + + let ldk_node = ldk_node_settings + .setup( + settings, + CurrencyUnit::Sat, + _runtime.clone(), + work_dir, + None, + ) + .await?; - let ldk_node = ldk_node_settings - .setup(settings, CurrencyUnit::Sat, _runtime, work_dir, None) + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + ln_entry.unit.clone(), + mint_melt_limits, + Arc::new(ldk_node), + &mut seen, + ) .await?; - - mint_builder = configure_backend_for_unit( - settings, - mint_builder, - CurrencyUnit::Sat, - mint_melt_limits, - Arc::new(ldk_node), - ) - .await?; - } - LnBackend::None => { - tracing::error!( - "Payment backend was not set or feature disabled. {:?}", - settings.ln.ln_backend - ); - bail!("Lightning backend must be configured"); + } + LnBackend::None => { + tracing::error!( + "Payment backend was not set or feature disabled. {:?}", + ln_entry.ln_backend + ); + bail!("Lightning backend must be configured"); + } } - }; + } Ok(mint_builder) } @@ -652,6 +690,7 @@ async fn configure_backend_for_unit( unit: cdk::nuts::CurrencyUnit, mint_melt_limits: MintMeltLimits, backend: Arc + Send + Sync>, + seen: &mut HashSet<(cdk::nuts::CurrencyUnit, PaymentMethod)>, ) -> Result { let payment_settings = backend.get_settings().await?; @@ -674,6 +713,13 @@ async fn configure_backend_for_unit( // Add all supported payment methods to the mint builder for method in &methods { + if !seen.insert((unit.clone(), method.clone())) { + bail!( + "Duplicate (unit, method) configured: ({:?}, {:?}). Each (unit, method) pair must be served by exactly one backend.", + unit, + method + ); + } mint_builder .add_payment_processor( unit.clone(), @@ -1461,6 +1507,49 @@ mod tests { use super::*; + #[cfg(feature = "fakewallet")] + #[tokio::test] + async fn fakewallet_dispatcher_uses_ln_entry_unit() { + use cdk::mint::MintBuilder; + use cdk_sqlite::mint::memory; + + use crate::config::{FakeWallet, Ln, LnBackend}; + + let settings = config::Settings { + ln: vec![Ln { + ln_backend: LnBackend::FakeWallet, + unit: CurrencyUnit::Eur, + ..Default::default() + }], + fake_wallet: Some(FakeWallet::default()), + ..Default::default() + }; + + let localstore = Arc::new(memory::empty().await.unwrap()); + let builder = MintBuilder::new(localstore); + let builder = + configure_lightning_backend(&settings, builder, None, &std::env::temp_dir(), None) + .await + .expect("dispatcher should succeed"); + + let mint_info = builder.current_mint_info(); + let units: Vec<_> = mint_info + .nuts + .nut04 + .methods + .iter() + .map(|m| m.unit.clone()) + .collect(); + assert!( + units.contains(&CurrencyUnit::Eur), + "expected Eur, got {units:?}" + ); + assert!( + !units.contains(&CurrencyUnit::Sat), + "Sat would only appear if supported_units leaked through; got {units:?}" + ); + } + #[test] fn test_postgres_auth_url_validation() { // Test that the auth database config requires explicit configuration