app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 63feea28db87214b1de6a4da14f4d7e3c6b9d587
parent 00711e6f3bb309936e2ad22c2a4aa3a60de6364e
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 02:29:59 +0000

runtime: require configured relay set

- replace the single default relay env with RADROOTS_APP_NOSTR_RELAY_URLS
- normalize configured relay URLs before app startup and direct publish
- pass the relay set through desktop runtime and direct sync transport
- cover relay-set config and direct publish behavior with focused tests

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 6++----
Mcrates/launchers/desktop/src/runtime.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/launchers/desktop/src/window.rs | 22++++++++++++----------
Mcrates/shared/core/src/lib.rs | 2+-
Mcrates/shared/core/src/runtime.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mscripts/run.sh | 8++++----
6 files changed, 145 insertions(+), 95 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -27,10 +27,8 @@ pub fn launch() -> Result<(), AppLaunchError> { bootstrap_logging(&snapshot, runtime_config.local_log_root.as_path())?; install_panic_hook(); - let runtime = DesktopAppRuntime::bootstrap( - runtime_config.default_nostr_relay_url.clone(), - snapshot.clone(), - ); + let runtime = + DesktopAppRuntime::bootstrap(runtime_config.nostr_relay_urls.clone(), snapshot.clone()); if let Err(error) = runtime.sync_on_app_launch() { error!( target: "sync", diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -134,13 +134,10 @@ struct SdkDirectRelayAppSyncTransport { } impl SdkDirectRelayAppSyncTransport { - fn new( - accounts_manager: RadrootsNostrAccountsManager, - default_nostr_relay_url: String, - ) -> Self { + fn new(accounts_manager: RadrootsNostrAccountsManager, nostr_relay_urls: Vec<String>) -> Self { Self { accounts_manager, - relay_urls: vec![default_nostr_relay_url], + relay_urls: nostr_relay_urls, timeout_ms: APP_DIRECT_RELAY_SYNC_TIMEOUT_MS, } } @@ -231,29 +228,27 @@ pub struct DesktopAppRuntime { } impl DesktopAppRuntime { - pub fn bootstrap( - default_nostr_relay_url: String, - runtime_snapshot: AppRuntimeSnapshot, - ) -> Self { - let state = match DesktopAppRuntimeState::try_bootstrap( - default_nostr_relay_url, - runtime_snapshot.clone(), - ) { - Ok(state) => state, - Err(error) => DesktopAppRuntimeState::degraded_with_snapshot(error, runtime_snapshot), - }; + pub fn bootstrap(nostr_relay_urls: Vec<String>, runtime_snapshot: AppRuntimeSnapshot) -> Self { + let state = + match DesktopAppRuntimeState::try_bootstrap(nostr_relay_urls, runtime_snapshot.clone()) + { + Ok(state) => state, + Err(error) => { + DesktopAppRuntimeState::degraded_with_snapshot(error, runtime_snapshot) + } + }; Self::from_state(state) } pub fn bootstrap_with_paths( paths: AppDesktopRuntimePaths, - default_nostr_relay_url: String, + nostr_relay_urls: Vec<String>, ) -> Self { let runtime_snapshot = default_runtime_snapshot(); let state = match DesktopAppRuntimeState::bootstrap_from_paths( paths, - default_nostr_relay_url, + nostr_relay_urls, runtime_snapshot.clone(), ) { Ok(state) => state, @@ -297,8 +292,8 @@ impl DesktopAppRuntime { } } - pub fn default_nostr_relay_url(&self) -> String { - self.lock_state().default_nostr_relay_url.clone() + pub fn nostr_relay_urls(&self) -> Vec<String> { + self.lock_state().nostr_relay_urls.clone() } pub fn selected_settings_section(&self) -> SettingsSection { @@ -1011,7 +1006,7 @@ struct DesktopPreparedSyncRequest { struct DesktopAppRuntimeState { state_store: AppStateStore<AppStatePersistenceRepository>, - default_nostr_relay_url: String, + nostr_relay_urls: Vec<String>, shared_accounts_paths: Option<AppSharedAccountsPaths>, remote_signer_paths: Option<DesktopRemoteSignerPaths>, accounts_manager: Option<RadrootsNostrAccountsManager>, @@ -1061,16 +1056,16 @@ impl fmt::Debug for DesktopAppRuntimeState { impl DesktopAppRuntimeState { fn try_bootstrap( - default_nostr_relay_url: String, + nostr_relay_urls: Vec<String>, runtime_snapshot: AppRuntimeSnapshot, ) -> Result<Self, DesktopAppRuntimeBootstrapError> { let paths = AppDesktopRuntimePaths::current_desktop()?; - Self::bootstrap_from_paths(paths, default_nostr_relay_url, runtime_snapshot) + Self::bootstrap_from_paths(paths, nostr_relay_urls, runtime_snapshot) } fn bootstrap_from_paths( paths: AppDesktopRuntimePaths, - default_nostr_relay_url: String, + nostr_relay_urls: Vec<String>, runtime_snapshot: AppRuntimeSnapshot, ) -> Result<Self, DesktopAppRuntimeBootstrapError> { if let Err(error) = cleanup_prepared_customer_label_asset_root() { @@ -1127,13 +1122,13 @@ impl DesktopAppRuntimeState { match accounts_bootstrap.accounts_manager.as_ref() { Some(accounts_manager) => Box::new(SdkDirectRelayAppSyncTransport::new( accounts_manager.clone(), - default_nostr_relay_url.clone(), + nostr_relay_urls.clone(), )), None => default_sync_transport(), }; let mut state = Self { state_store, - default_nostr_relay_url, + nostr_relay_urls, shared_accounts_paths: Some(paths.shared_accounts.clone()), remote_signer_paths: Some(remote_signer_paths), accounts_manager: accounts_bootstrap.accounts_manager, @@ -1174,7 +1169,7 @@ impl DesktopAppRuntimeState { Self { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: String::new(), + nostr_relay_urls: Vec::new(), shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -3621,7 +3616,7 @@ impl DesktopAppRuntimeState { order_document_json: Some(local_work.payload.clone()), listing_addr: export.listing_addr, listing_event_id: export.listing_event_id, - listing_relays: vec![self.default_nostr_relay_url.clone()], + listing_relays: self.nostr_relay_urls.clone(), buyer_pubkey: export.buyer_pubkey, seller_pubkey: export.seller_pubkey, items: order @@ -4746,10 +4741,17 @@ fn current_runtime_time_ms() -> Result<i64, AppSqliteError> { fn normalized_app_sync_relay_urls( relay_urls: &[String], ) -> Result<Vec<String>, AppSyncTransportError> { + let mut seen = BTreeSet::new(); let normalized = relay_urls .iter() - .map(|relay| relay.trim().to_owned()) - .filter(|relay| !relay.is_empty()) + .filter_map(|relay| { + let relay = relay.trim(); + if relay.is_empty() || !seen.insert(relay.to_owned()) { + None + } else { + Some(relay.to_owned()) + } + }) .collect::<Vec<_>>(); if normalized.is_empty() { return Err(AppSyncTransportError::unavailable( @@ -7051,7 +7053,8 @@ mod tests { #[test] fn runtime_direct_relay_transport_publishes_typed_farm_work() { - let relay = ThreadedAckRelay::spawn(); + let relay_a = ThreadedAckRelay::spawn(); + let relay_b = ThreadedAckRelay::spawn(); let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager .generate_identity(Some("Farmer".to_owned()), true) @@ -7066,8 +7069,10 @@ mod tests { }); let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") .expect("typed farm publish work should serialize"); - let mut transport = - SdkDirectRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); + let mut transport = SdkDirectRelayAppSyncTransport::with_relay_urls( + manager, + vec![relay_a.url().to_owned(), relay_b.url().to_owned()], + ); let result = transport .sync(AppSyncRequest { @@ -7090,7 +7095,23 @@ mod tests { ); assert_eq!( result.published_receipts[0].relay_delivery_json["acknowledged_relays"], - json!([relay.url()]) + json!([relay_a.url(), relay_b.url()]) + ); + } + + #[test] + fn runtime_direct_relay_transport_normalizes_configured_relay_set() { + let relay_urls = super::normalized_app_sync_relay_urls(&[ + " ws://127.0.0.1:8081 ".to_owned(), + "ws://127.0.0.1:8080".to_owned(), + "ws://127.0.0.1:8081".to_owned(), + " ".to_owned(), + ]) + .expect("relay set should normalize"); + + assert_eq!( + relay_urls, + vec!["ws://127.0.0.1:8081", "ws://127.0.0.1:8080"] ); } @@ -7175,7 +7196,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -7229,7 +7250,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -8659,7 +8680,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: Some(paths.clone()), accounts_manager: None, @@ -8690,7 +8711,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: Some(paths.clone()), accounts_manager: None, @@ -9099,7 +9120,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -9201,7 +9222,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -9254,7 +9275,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -9291,7 +9312,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -9326,7 +9347,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: None, @@ -13490,7 +13511,7 @@ mod tests { let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: Some(paths), remote_signer_paths: None, accounts_manager: None, @@ -13519,7 +13540,7 @@ mod tests { DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: None, remote_signer_paths: None, accounts_manager: Some( @@ -13550,7 +13571,7 @@ mod tests { DesktopAppRuntime::from_state(DesktopAppRuntimeState { state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) .expect("in-memory state store should load"), - default_nostr_relay_url: "ws://127.0.0.1:8080".to_owned(), + nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], shared_accounts_paths: Some(paths.clone()), remote_signer_paths: None, accounts_manager: Some( @@ -13586,7 +13607,7 @@ mod tests { DesktopAppRuntime::from_state( DesktopAppRuntimeState::bootstrap_from_paths( paths, - "ws://127.0.0.1:8080".to_owned(), + vec!["ws://127.0.0.1:8080".to_owned()], super::default_runtime_snapshot(), ) .expect("runtime bootstrap should succeed"), diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -559,12 +559,12 @@ impl HomeView { } self.startup_view.clear_notice(); - let relay_url = self.runtime.default_nostr_relay_url(); + let relay_urls = self.runtime.nostr_relay_urls(); cx.notify(); cx.spawn_in(window, async move |this, cx| { let startup_task = cx .background_executor() - .spawn(run_startup_app_init(relay_url)); + .spawn(run_startup_app_init(relay_urls)); Timer::after(Duration::from_secs(1)).await; let startup_result = startup_task.await; let _ = this.update(cx, |this, cx| { @@ -9357,12 +9357,14 @@ fn startup_home_body(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { div().w_full().text_center().child(home_body_text(body)) } -async fn connect_default_relay(relay_url: String) -> Result<RadrootsNostrClient, String> { +async fn connect_configured_relays(relay_urls: Vec<String>) -> Result<RadrootsNostrClient, String> { let client = RadrootsNostrClient::new_signerless(); - client - .add_relay(relay_url.as_str()) - .await - .map_err(|error| format!("failed to add relay `{relay_url}`: {error}"))?; + for relay_url in relay_urls { + client + .add_relay(relay_url.as_str()) + .await + .map_err(|error| format!("failed to add relay `{relay_url}`: {error}"))?; + } client.connect().await; Ok(client) } @@ -9371,8 +9373,8 @@ struct StartupAppInitResult { relay_client: RadrootsNostrClient, } -async fn run_startup_app_init(relay_url: String) -> Result<StartupAppInitResult, String> { - let relay_client = connect_default_relay(relay_url).await?; +async fn run_startup_app_init(relay_urls: Vec<String>) -> Result<StartupAppInitResult, String> { + let relay_client = connect_configured_relays(relay_urls).await?; Ok(StartupAppInitResult { relay_client }) } @@ -13117,7 +13119,7 @@ mod tests { .expect("desktop runtime paths should resolve"); let runtime = crate::runtime::DesktopAppRuntime::bootstrap_with_paths( paths.clone(), - "wss://relay.example".to_owned(), + vec!["wss://relay.example".to_owned()], ); (HomeView::new(runtime), paths, home_dir) diff --git a/crates/shared/core/src/lib.rs b/crates/shared/core/src/lib.rs @@ -28,7 +28,7 @@ pub use paths::{ SHARED_LOCAL_EVENTS_NAMESPACE_VALUE, shared_local_events_database_path_from_shared_accounts, }; pub use runtime::{ - APP_DEFAULT_NOSTR_RELAY_URL_ENV, APP_HOST_PLATFORM, APP_ID, APP_LOCAL_LOG_ROOT_ENV, APP_NAME, + APP_HOST_PLATFORM, APP_ID, APP_LOCAL_LOG_ROOT_ENV, APP_NAME, APP_NOSTR_RELAY_URLS_ENV, APP_PLATFORM_RUNTIME, APP_PROJECTION_SOURCE, APP_RUNTIME_MODE_ENV, APP_RUNTIME_ORIGIN, AppBuildIdentity, AppCoreRuntimeMetadata, AppHostRuntimeMetadata, AppRuntimeCapture, AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeMode, AppRuntimeSnapshot, diff --git a/crates/shared/core/src/runtime.rs b/crates/shared/core/src/runtime.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeSet, path::PathBuf, time::{SystemTime, UNIX_EPOCH}, }; @@ -15,7 +16,7 @@ pub const APP_PROJECTION_SOURCE: &str = "gpui-native"; pub const APP_RUNTIME_ORIGIN: &str = "gpui://localhost"; pub const APP_HOST_PLATFORM: &str = "desktop"; pub const APP_RUNTIME_MODE_ENV: &str = "RADROOTS_APP_RUNTIME_MODE"; -pub const APP_DEFAULT_NOSTR_RELAY_URL_ENV: &str = "RADROOTS_APP_DEFAULT_NOSTR_RELAY_URL"; +pub const APP_NOSTR_RELAY_URLS_ENV: &str = "RADROOTS_APP_NOSTR_RELAY_URLS"; pub const APP_LOCAL_LOG_ROOT_ENV: &str = "RADROOTS_APP_LOCAL_LOG_ROOT"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -28,7 +29,7 @@ pub enum AppRuntimeMode { #[derive(Clone, Debug, Eq, PartialEq)] pub struct AppRuntimeConfig { pub runtime_mode: AppRuntimeMode, - pub default_nostr_relay_url: String, + pub nostr_relay_urls: Vec<String>, pub local_log_root: PathBuf, } @@ -106,8 +107,10 @@ impl AppRuntimeConfig { { let runtime_mode = parse_config_runtime_mode(&require_env_value(&mut read_env, APP_RUNTIME_MODE_ENV)?)?; - let default_nostr_relay_url = - require_env_value(&mut read_env, APP_DEFAULT_NOSTR_RELAY_URL_ENV)?; + let nostr_relay_urls = parse_relay_url_set( + APP_NOSTR_RELAY_URLS_ENV, + require_env_value(&mut read_env, APP_NOSTR_RELAY_URLS_ENV)?, + )?; let local_log_root = read_env(APP_LOCAL_LOG_ROOT_ENV) .map(|value| require_path_value(APP_LOCAL_LOG_ROOT_ENV, value)) .transpose()?; @@ -121,7 +124,7 @@ impl AppRuntimeConfig { Ok(Self { runtime_mode, - default_nostr_relay_url, + nostr_relay_urls, local_log_root, }) } @@ -214,6 +217,26 @@ fn parse_config_runtime_mode(value: &str) -> Result<AppRuntimeMode, AppRuntimeCo } } +fn parse_relay_url_set( + field: &'static str, + value: String, +) -> Result<Vec<String>, AppRuntimeConfigError> { + let mut seen = BTreeSet::new(); + let mut relays = Vec::new(); + for relay in value.split(',') { + let relay = relay.trim(); + if !relay.is_empty() && seen.insert(relay.to_owned()) { + relays.push(relay.to_owned()); + } + } + + if relays.is_empty() { + return Err(AppRuntimeConfigError::MissingField(field)); + } + + Ok(relays) +} + fn require_path_value( field: &'static str, value: String, @@ -283,10 +306,10 @@ mod tests { use std::{collections::BTreeMap, path::PathBuf}; use super::{ - APP_DEFAULT_NOSTR_RELAY_URL_ENV, APP_HOST_PLATFORM, APP_ID, APP_LOCAL_LOG_ROOT_ENV, - APP_NAME, APP_PROJECTION_SOURCE, APP_RUNTIME_MODE_ENV, APP_RUNTIME_ORIGIN, - AppBuildIdentity, AppRuntimeCapture, AppRuntimeConfig, AppRuntimeConfigError, - AppRuntimeMode, AppRuntimeSnapshot, runtime_mode_label, + APP_HOST_PLATFORM, APP_ID, APP_LOCAL_LOG_ROOT_ENV, APP_NAME, APP_NOSTR_RELAY_URLS_ENV, + APP_PROJECTION_SOURCE, APP_RUNTIME_MODE_ENV, APP_RUNTIME_ORIGIN, AppBuildIdentity, + AppRuntimeCapture, AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeMode, + AppRuntimeSnapshot, runtime_mode_label, }; fn test_build_identity() -> AppBuildIdentity { @@ -304,8 +327,8 @@ mod tests { BTreeMap::from([ (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), ( - APP_DEFAULT_NOSTR_RELAY_URL_ENV, - "ws://127.0.0.1:8080".to_owned(), + APP_NOSTR_RELAY_URLS_ENV, + " ws://127.0.0.1:8080 , ws://127.0.0.1:8081 , ws://127.0.0.1:8080 ".to_owned(), ), ]) } @@ -343,10 +366,7 @@ mod tests { #[test] fn runtime_config_requires_explicit_runtime_mode_env() { - let env = BTreeMap::from([( - APP_DEFAULT_NOSTR_RELAY_URL_ENV, - "ws://127.0.0.1:8080".to_owned(), - )]); + let env = BTreeMap::from([(APP_NOSTR_RELAY_URLS_ENV, "ws://127.0.0.1:8080".to_owned())]); let error = AppRuntimeConfig::from_env_with( |name| env.get(name).cloned(), Some(PathBuf::from("/tmp/default-logs")), @@ -363,10 +383,7 @@ mod tests { fn runtime_config_surfaces_explicit_local_log_root() { let env = BTreeMap::from([ (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), - ( - APP_DEFAULT_NOSTR_RELAY_URL_ENV, - "ws://127.0.0.1:8080".to_owned(), - ), + (APP_NOSTR_RELAY_URLS_ENV, "ws://127.0.0.1:8080".to_owned()), (APP_LOCAL_LOG_ROOT_ENV, "/tmp/radroots/logs".to_owned()), ]); let config = AppRuntimeConfig::from_env_with( @@ -376,11 +393,26 @@ mod tests { .expect("valid env config"); assert_eq!(config.runtime_mode, AppRuntimeMode::LocalhostDev); - assert_eq!(config.default_nostr_relay_url, "ws://127.0.0.1:8080"); + assert_eq!(config.nostr_relay_urls, vec!["ws://127.0.0.1:8080"]); assert_eq!(config.local_log_root, PathBuf::from("/tmp/radroots/logs")); } #[test] + fn runtime_config_normalizes_configured_nostr_relay_urls() { + let env = test_runtime_env(); + let config = AppRuntimeConfig::from_env_with( + |name| env.get(name).cloned(), + Some(PathBuf::from("/tmp/default-logs")), + ) + .expect("valid env config"); + + assert_eq!( + config.nostr_relay_urls, + vec!["ws://127.0.0.1:8080", "ws://127.0.0.1:8081"] + ); + } + + #[test] fn runtime_config_defaults_local_log_root_from_runtime_paths() { let env = test_runtime_env(); let config = AppRuntimeConfig::from_env_with( @@ -396,10 +428,7 @@ mod tests { fn runtime_config_accepts_explicit_log_root_without_default_runtime_paths() { let env = BTreeMap::from([ (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), - ( - APP_DEFAULT_NOSTR_RELAY_URL_ENV, - "ws://127.0.0.1:8080".to_owned(), - ), + (APP_NOSTR_RELAY_URLS_ENV, "ws://127.0.0.1:8080".to_owned()), (APP_LOCAL_LOG_ROOT_ENV, "/tmp/explicit-logs".to_owned()), ]); let config = AppRuntimeConfig::from_env_with(|name| env.get(name).cloned(), None) @@ -451,7 +480,7 @@ mod tests { fn runtime_config_rejects_empty_required_fields() { let env = BTreeMap::from([ (APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned()), - (APP_DEFAULT_NOSTR_RELAY_URL_ENV, "".to_owned()), + (APP_NOSTR_RELAY_URLS_ENV, "".to_owned()), ]); let error = AppRuntimeConfig::from_env_with( |name| env.get(name).cloned(), @@ -462,25 +491,25 @@ mod tests { assert!( matches!( error, - AppRuntimeConfigError::MissingField(APP_DEFAULT_NOSTR_RELAY_URL_ENV) + AppRuntimeConfigError::MissingField(APP_NOSTR_RELAY_URLS_ENV) ), "unexpected error: {error}" ); } #[test] - fn runtime_config_rejects_missing_default_nostr_relay_url() { + fn runtime_config_rejects_missing_nostr_relay_urls() { let env = BTreeMap::from([(APP_RUNTIME_MODE_ENV, "localhost-dev".to_owned())]); let error = AppRuntimeConfig::from_env_with( |name| env.get(name).cloned(), Some(PathBuf::from("/tmp/default-logs")), ) - .expect_err("missing default relay url should fail"); + .expect_err("missing relay urls should fail"); assert!( matches!( error, - AppRuntimeConfigError::MissingEnv(APP_DEFAULT_NOSTR_RELAY_URL_ENV) + AppRuntimeConfigError::MissingEnv(APP_NOSTR_RELAY_URLS_ENV) ), "unexpected error: {error}" ); diff --git a/scripts/run.sh b/scripts/run.sh @@ -7,16 +7,16 @@ repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel)" cd "${repo_root}" runtime_mode="${RADROOTS_APP_RUNTIME_MODE:-localhost-dev}" -default_nostr_relay_url="${RADROOTS_APP_DEFAULT_NOSTR_RELAY_URL:-}" +nostr_relay_urls="${RADROOTS_APP_NOSTR_RELAY_URLS:-}" local_log_root="${RADROOTS_APP_LOCAL_LOG_ROOT:-${repo_root}/logs}" -if [[ -z "${default_nostr_relay_url}" ]]; then - echo "missing required env: RADROOTS_APP_DEFAULT_NOSTR_RELAY_URL" >&2 +if [[ -z "${nostr_relay_urls}" ]]; then + echo "missing required env: RADROOTS_APP_NOSTR_RELAY_URLS" >&2 exit 1 fi export RADROOTS_APP_RUNTIME_MODE="${runtime_mode}" -export RADROOTS_APP_DEFAULT_NOSTR_RELAY_URL="${default_nostr_relay_url}" +export RADROOTS_APP_NOSTR_RELAY_URLS="${nostr_relay_urls}" export RADROOTS_APP_LOCAL_LOG_ROOT="${local_log_root}" export RUST_LOG="${RADROOTS_APP_RUST_LOG:-info}"