app

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

commit b0b194d8ca663be54e2f9a1d6a20a34624dec2c4
parent c68f93ef61bea6916b2cbb836ace420430f25183
Author: triesap <tyson@radroots.org>
Date:   Mon,  2 Feb 2026 03:44:14 +0000

app: seed default relays and profile data

- add default relay parsing with env fallback
- store relays in setup state and tests
- add profile seed datastore helper
- persist profile seed and nip05 request on finalize

Diffstat:
Mapp/src/app.rs | 33+++++++++++++++++++++++++++++++++
Mapp/src/bootstrap.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/src/config.rs | 31++++++++++++++++++++++++++++++-
Mapp/src/data.rs | 11+++++++++++
Mapp/src/lib.rs | 3+++
Mapp/src/setup.rs | 10++++++++--
6 files changed, 258 insertions(+), 3 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -41,6 +41,7 @@ use crate::{ app_datastore_clear_setup_draft, app_datastore_read_state, app_datastore_read_setup_draft, + app_datastore_write_profile_seed, app_datastore_write_setup_draft, app_keystore_nostr_ensure_key, app_state_notifications_permission_value, @@ -59,6 +60,7 @@ use crate::{ RadrootsAppNotifications, RadrootsAppLogsPage, RadrootsAppKeystoreError, + RadrootsAppProfileSeed, RadrootsAppRole, RadrootsAppSettingsPage, RadrootsAppSetupDraft, @@ -341,6 +343,8 @@ fn SetupPage() -> impl IntoView { if matches!(current_step, RadrootsAppSetupStep::Eula) { let key_choice = setup_key_choice.get(); let nostr_key_add = nostr_key_add.get(); + let profile_name = profile_name.get(); + let profile_nip05 = profile_nip05.get(); let eula_date = app_setup_eula_date(); let setup_required = setup_required.clone(); let backends = backends.clone(); @@ -392,11 +396,40 @@ fn SetupPage() -> impl IntoView { } }, }; + let nip05_key = if profile_nip05 { + let profile_name = profile_name.trim(); + if profile_name.is_empty() { + None + } else { + Some(profile_name.to_string()) + } + } else { + None + }; + if !profile_name.trim().is_empty() { + let profile_seed = RadrootsAppProfileSeed { + public_key: active_key.clone(), + name: profile_name.trim().to_string(), + display_name: Some(profile_name.trim().to_string()), + nip05_request: profile_nip05, + }; + if let Err(err) = app_datastore_write_profile_seed( + datastore.as_ref(), + &key_maps, + &profile_seed, + ) + .await + { + let _ = app_log_error_emit(&err); + return; + } + } if let Err(err) = app_setup_finalize_with_key( datastore.as_ref(), &key_maps, active_key, eula_date, + nip05_key, ) .await { diff --git a/app/src/bootstrap.rs b/app/src/bootstrap.rs @@ -6,10 +6,12 @@ use radroots_app_core::notifications::RadrootsClientNotificationsPermission; use crate::{ app_datastore_obj_key_state, app_datastore_obj_key_setup_draft, + app_datastore_param_key, app_log_debug_emit, app_state_record_new, app_state_record_validate, app_state_timestamp_ms, + RadrootsAppProfileSeed, RadrootsAppState, RadrootsAppSetupDraft, RadrootsAppStateError, @@ -174,6 +176,22 @@ pub async fn app_datastore_clear_setup_draft<T: RadrootsClientDatastore>( } } +pub async fn app_datastore_write_profile_seed<T: RadrootsClientDatastore>( + datastore: &T, + key_maps: &RadrootsAppKeyMapConfig, + profile: &RadrootsAppProfileSeed, +) -> RadrootsAppInitResult<RadrootsAppProfileSeed> { + let param = app_datastore_param_key(key_maps, "nostr_profile") + .map_err(RadrootsAppInitError::Config)?; + let key = param(&profile.public_key); + let stored = datastore + .set_obj(&key, profile) + .await + .map_err(RadrootsAppInitError::Datastore)?; + let _ = app_log_debug_emit("log.app.bootstrap.profile", "write", Some(key)); + Ok(stored) +} + pub async fn app_state_set_notifications_permission<T: RadrootsClientDatastore>( datastore: &T, key_maps: &RadrootsAppKeyMapConfig, @@ -212,6 +230,7 @@ mod tests { app_datastore_read_setup_draft, app_datastore_update_state, app_datastore_write_setup_draft, + app_datastore_write_profile_seed, app_state_set_notifications_permission, app_state_set_notifications_permission_value, app_state_notifications_permission_value, @@ -220,6 +239,7 @@ mod tests { use crate::{ app_key_maps_default, RadrootsAppInitError, + RadrootsAppProfileSeed, RadrootsAppRole, RadrootsAppState, RadrootsAppStateError, @@ -245,6 +265,10 @@ mod tests { draft: RefCell<Option<RadrootsAppSetupDraft>>, } + struct ProfileSeedDatastore { + profile: RefCell<Option<RadrootsAppProfileSeed>>, + } + #[async_trait(?Send)] impl RadrootsClientDatastore for SetupDraftDatastore { fn get_config(&self) -> RadrootsClientIdbConfig { @@ -371,6 +395,132 @@ mod tests { } } + #[async_trait(?Send)] + impl RadrootsClientDatastore for ProfileSeedDatastore { + fn get_config(&self) -> RadrootsClientIdbConfig { + IDB_CONFIG_DATASTORE + } + + fn get_store_id(&self) -> &str { + "test" + } + + async fn init(&self) -> RadrootsClientDatastoreResult<()> { + Ok(()) + } + + async fn set(&self, _key: &str, _value: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn set_obj<T>( + &self, + _key: &str, + value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + let encoded = serde_json::to_string(value) + .map_err(|_| RadrootsClientDatastoreError::IdbUndefined)?; + if let Ok(parsed) = serde_json::from_str::<RadrootsAppProfileSeed>(&encoded) { + *self.profile.borrow_mut() = Some(parsed); + return Ok(value.clone()); + } + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn update_obj<T>( + &self, + _key: &str, + _value: &T, + ) -> RadrootsClientDatastoreResult<T> + where + T: Serialize + DeserializeOwned + Clone, + { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_obj<T>(&self, _key: &str) -> RadrootsClientDatastoreResult<T> + where + T: DeserializeOwned, + { + if let Some(profile) = self.profile.borrow().as_ref() { + let encoded = serde_json::to_string(profile) + .map_err(|_| RadrootsClientDatastoreError::NoResult)?; + return serde_json::from_str(&encoded) + .map_err(|_| RadrootsClientDatastoreError::NoResult); + } + Err(RadrootsClientDatastoreError::NoResult) + } + + async fn del_obj(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + *self.profile.borrow_mut() = None; + Ok("cleared".to_string()) + } + + async fn del(&self, _key: &str) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn del_pref(&self, _key_prefix: &str) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn set_param( + &self, + _key: &str, + _key_param: &str, + _value: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn get_param( + &self, + _key: &str, + _key_param: &str, + ) -> RadrootsClientDatastoreResult<String> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn keys(&self) -> RadrootsClientDatastoreResult<Vec<String>> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries(&self) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn entries_pref( + &self, + _key_prefix: &str, + ) -> RadrootsClientDatastoreResult<RadrootsClientDatastoreEntries> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn reset(&self) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn export_backup( + &self, + ) -> RadrootsClientDatastoreResult<RadrootsClientBackupDatastorePayload> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + + async fn import_backup( + &self, + _payload: RadrootsClientBackupDatastorePayload, + ) -> RadrootsClientDatastoreResult<()> { + Err(RadrootsClientDatastoreError::IdbUndefined) + } + } + struct TestDatastore { state: Option<RadrootsAppState>, record: RefCell<Option<RadrootsAppStateRecord>>, @@ -726,4 +876,27 @@ mod tests { .expect("read draft"); assert!(loaded.is_none()); } + + #[test] + fn profile_seed_write_persists_data() { + let datastore = ProfileSeedDatastore { + profile: RefCell::new(None), + }; + let key_maps = app_key_maps_default(); + let profile = RadrootsAppProfileSeed { + public_key: "pub".to_string(), + name: "radroots".to_string(), + display_name: Some("Radroots".to_string()), + nip05_request: true, + }; + let stored = futures::executor::block_on(app_datastore_write_profile_seed( + &datastore, + &key_maps, + &profile, + )) + .expect("profile seed"); + assert_eq!(stored, profile); + let stored_profile = datastore.profile.borrow().clone(); + assert_eq!(stored_profile, Some(profile)); + } } diff --git a/app/src/config.rs b/app/src/config.rs @@ -1,6 +1,6 @@ #![forbid(unsafe_code)] -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use radroots_app_core::idb::{ RadrootsClientIdbConfig, @@ -244,6 +244,27 @@ pub fn app_assets_geocoder_db_url(config: &RadrootsAppConfig) -> Option<&str> { config.assets.geocoder_db_url.as_deref() } +const APP_DEFAULT_RELAY_FALLBACK: &str = "ws://localhost:8080"; + +pub fn app_default_relays() -> Vec<String> { + let raw = option_env!("VITE_PUBLIC_DEFAULT_RELAYS") + .or(option_env!("VITE_PUBLIC_RADROOTS_RELAY")) + .unwrap_or(APP_DEFAULT_RELAY_FALLBACK); + let mut seen = BTreeSet::new(); + let mut relays = Vec::new(); + for relay in raw.split(',') { + let relay = relay.trim(); + if relay.is_empty() || !seen.insert(relay.to_string()) { + continue; + } + relays.push(relay.to_string()); + } + if relays.is_empty() { + relays.push(APP_DEFAULT_RELAY_FALLBACK.to_string()); + } + relays +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsAppConfig { pub datastore: RadrootsAppDatastoreConfig, @@ -289,6 +310,7 @@ mod tests { use super::{ app_config_default, app_config_from_env, + app_default_relays, app_datastore_param_nostr_profile, app_datastore_param_log_entry, app_datastore_param_radroots_profile, @@ -337,6 +359,13 @@ mod tests { } #[test] + fn default_relays_are_non_empty() { + let relays = app_default_relays(); + assert!(!relays.is_empty()); + assert!(relays.iter().all(|relay| !relay.trim().is_empty())); + } + + #[test] fn app_config_helpers_return_defaults() { let config = app_config_default(); let from_env = app_config_from_env(); diff --git a/app/src/data.rs b/app/src/data.rs @@ -22,6 +22,7 @@ pub struct RadrootsAppState { pub eula_date: String, pub eula_version: String, pub eula_hash: String, + pub relays: Vec<String>, pub nip05_key: Option<String>, pub notifications_permission: Option<String>, } @@ -34,6 +35,7 @@ impl Default for RadrootsAppState { eula_date: String::new(), eula_version: String::from("0.1.0"), eula_hash: String::from("unknown"), + relays: Vec::new(), nip05_key: None, notifications_permission: None, } @@ -48,6 +50,14 @@ pub struct RadrootsAppSetupDraft { pub nip05_request: Option<bool>, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RadrootsAppProfileSeed { + pub public_key: String, + pub name: String, + pub display_name: Option<String>, + pub nip05_request: bool, +} + pub const APP_STATE_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -190,6 +200,7 @@ mod tests { assert_eq!(data.eula_date, ""); assert_eq!(data.eula_version, "0.1.0"); assert_eq!(data.eula_hash, "unknown"); + assert!(data.relays.is_empty()); assert!(data.nip05_key.is_none()); assert!(data.notifications_permission.is_none()); } diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -27,6 +27,7 @@ pub use bootstrap::{ app_datastore_read_setup_draft, app_datastore_write_setup_draft, app_datastore_clear_setup_draft, + app_datastore_write_profile_seed, app_datastore_update_state, app_state_set_notifications_permission, app_state_set_notifications_permission_value, @@ -39,6 +40,7 @@ pub use data::{ app_state_record_new, app_state_record_validate, app_state_timestamp_ms, + RadrootsAppProfileSeed, RadrootsAppRole, RadrootsAppState, RadrootsAppSetupDraft, @@ -135,6 +137,7 @@ pub use tangle::{RadrootsAppTangleClient, RadrootsAppTangleClientStub, RadrootsA pub use config::{ app_config_default, app_config_from_env, + app_default_relays, app_datastore_key, app_datastore_key_eula_date, app_datastore_key_nostr_key, diff --git a/app/src/setup.rs b/app/src/setup.rs @@ -12,6 +12,7 @@ use chrono::{SecondsFormat, Utc}; use crate::{ app_datastore_create_state, app_datastore_key_nostr_key, + app_default_relays, app_keystore_nostr_ensure_key, app_keystore_nostr_verify_key, app_log_debug_emit, @@ -40,6 +41,7 @@ pub fn app_setup_state_new(active_key: String, eula_date: String) -> RadrootsApp eula_date, eula_version: String::from("0.1.0"), eula_hash: String::from("unknown"), + relays: app_default_relays(), nip05_key: None, notifications_permission: None, } @@ -108,7 +110,7 @@ pub async fn app_setup_initialize<T: RadrootsClientDatastore, K: RadrootsClientK RadrootsAppInitError::Keystore(RadrootsClientKeystoreError::NostrInvalidSecretKey) } })?; - app_setup_finalize_with_key(datastore, key_maps, active_key, app_setup_eula_date()).await + app_setup_finalize_with_key(datastore, key_maps, active_key, app_setup_eula_date(), None).await } pub async fn app_setup_finalize_with_key<T: RadrootsClientDatastore>( @@ -116,8 +118,10 @@ pub async fn app_setup_finalize_with_key<T: RadrootsClientDatastore>( key_maps: &RadrootsAppKeyMapConfig, active_key: String, eula_date: String, + nip05_key: Option<String>, ) -> RadrootsAppInitResult<RadrootsAppState> { - let state = app_setup_state_new(active_key.clone(), eula_date); + let mut state = app_setup_state_new(active_key.clone(), eula_date); + state.nip05_key = nip05_key; let stored_state = app_datastore_create_state(datastore, key_maps, &state).await?; let key_name = app_datastore_key_nostr_key(key_maps).map_err(RadrootsAppInitError::Config)?; datastore @@ -334,6 +338,7 @@ mod tests { assert_eq!(state.active_key, "pub"); assert_eq!(state.role, RadrootsAppRole::Public); assert_eq!(state.eula_date, "2025-01-01T00:00:00Z"); + assert!(!state.relays.is_empty()); assert!(state.nip05_key.is_none()); assert!(state.notifications_permission.is_none()); } @@ -456,6 +461,7 @@ mod tests { &key_maps, "pub".to_string(), "2025-01-01T00:00:00Z".to_string(), + None, )) .expect("finalize"); assert_eq!(state.active_key, "pub");