lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 5326034e3c4641f1e4ad3986b718780ec81dde39
parent 8bbfbb46cad58b92ffff4b71fb367d4175fd5b01
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 18:32:19 +0000

refactor account store to default account semantics

Diffstat:
Mcrates/net/src/net.rs | 2+-
Mcrates/nostr_accounts/src/error.rs | 6++++++
Mcrates/nostr_accounts/src/lib.rs | 4++--
Mcrates/nostr_accounts/src/manager.rs | 528++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/nostr_accounts/src/model.rs | 9+++++----
Mcrates/nostr_accounts/src/ndb_bridge.rs | 12++++++------
Mcrates/nostr_accounts/src/store.rs | 2+-
7 files changed, 385 insertions(+), 178 deletions(-)

diff --git a/crates/net/src/net.rs b/crates/net/src/net.rs @@ -141,7 +141,7 @@ impl Net { pub fn selected_nostr_signer(&self) -> Option<RadrootsNostrSignerCapability> { self.signer .clone() - .or_else(|| self.accounts.selected_signer_capability().ok().flatten()) + .or_else(|| self.accounts.default_signer_capability().ok().flatten()) } #[cfg(feature = "nostr-client")] diff --git a/crates/nostr_accounts/src/error.rs b/crates/nostr_accounts/src/error.rs @@ -20,6 +20,12 @@ pub enum RadrootsNostrAccountsError { #[error("invalid account state: {0}")] InvalidState(String), + #[error("invalid account selector: {0}")] + InvalidAccountSelector(String), + + #[error("account selector is ambiguous: {0}")] + AmbiguousAccountSelector(String), + #[error("public key does not match secret key")] PublicKeyMismatch, } diff --git a/crates/nostr_accounts/src/lib.rs b/crates/nostr_accounts/src/lib.rs @@ -20,10 +20,10 @@ pub mod prelude { #[cfg(feature = "std")] pub use crate::model::{ RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, RadrootsNostrAccountRecord, - RadrootsNostrAccountStoreState, RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountStatus, RadrootsNostrAccountStoreState, }; #[cfg(feature = "ndb-bridge")] - pub use crate::ndb_bridge::radroots_nostr_accounts_register_selected_secret_with_ndb; + pub use crate::ndb_bridge::radroots_nostr_accounts_register_default_secret_with_ndb; #[cfg(feature = "std")] pub use crate::store::{ RadrootsNostrAccountStore, RadrootsNostrFileAccountStore, RadrootsNostrMemoryAccountStore, diff --git a/crates/nostr_accounts/src/manager.rs b/crates/nostr_accounts/src/manager.rs @@ -1,6 +1,6 @@ use crate::error::RadrootsNostrAccountsError; use crate::model::{ - RadrootsNostrAccountRecord, RadrootsNostrAccountStoreState, RadrootsNostrSelectedAccountStatus, + RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountStoreState, }; #[cfg(feature = "memory-vault")] use crate::store::RadrootsNostrMemoryAccountStore; @@ -47,23 +47,35 @@ impl RadrootsNostrAccountsManager { vault: Arc<dyn RadrootsSecretVault>, ) -> Result<Self, RadrootsNostrAccountsError> { let mut state = store.load()?; - if state.version != crate::model::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION { - return Err(RadrootsNostrAccountsError::InvalidState(format!( - "unsupported accounts schema version {}", - state.version - ))); - } + let mut state_dirty = match state.version { + 1 => { + state.version = crate::model::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION; + true + } + crate::model::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION => false, + _ => { + return Err(RadrootsNostrAccountsError::InvalidState(format!( + "unsupported accounts schema version {}", + state.version + ))); + } + }; - if let Some(selected) = state.selected_account_id.clone() { + if let Some(default_account_id) = state.default_account_id.clone() { let exists = state .accounts .iter() - .any(|record| record.account_id == selected); + .any(|record| record.account_id == default_account_id); if !exists { - state.selected_account_id = None; + state.default_account_id = None; + state_dirty = true; } } + if state_dirty { + store.save(&state)?; + } + Ok(Self { store, vault, @@ -125,60 +137,60 @@ impl RadrootsNostrAccountsManager { Ok(guard.accounts.clone()) } - pub fn selected_account_id( + pub fn default_account_id( &self, ) -> Result<Option<RadrootsIdentityId>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; - Ok(guard.selected_account_id.clone()) + Ok(guard.default_account_id.clone()) } - pub fn selected_account( + pub fn default_account( &self, ) -> Result<Option<RadrootsNostrAccountRecord>, RadrootsNostrAccountsError> { let guard = self.state.read().map_err(|_| { RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) })?; - let Some(selected) = guard.selected_account_id.as_ref() else { + let Some(default_account_id) = guard.default_account_id.as_ref() else { return Ok(None); }; Ok(guard .accounts .iter() - .find(|record| &record.account_id == selected) + .find(|record| &record.account_id == default_account_id) .cloned()) } - pub fn selected_public_identity( + pub fn default_public_identity( &self, ) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrAccountsError> { Ok(self - .selected_account()? + .default_account()? .map(|record| record.public_identity.clone())) } - pub fn selected_account_status( + pub fn default_account_status( &self, - ) -> Result<RadrootsNostrSelectedAccountStatus, RadrootsNostrAccountsError> { - let Some(record) = self.selected_account()? else { - return Ok(RadrootsNostrSelectedAccountStatus::NotConfigured); + ) -> Result<RadrootsNostrAccountStatus, RadrootsNostrAccountsError> { + let Some(record) = self.default_account()? else { + return Ok(RadrootsNostrAccountStatus::NotConfigured); }; Ok(match self.local_signer_availability(&record)? { RadrootsNostrLocalSignerAvailability::PublicOnly => { - RadrootsNostrSelectedAccountStatus::PublicOnly { account: record } + RadrootsNostrAccountStatus::PublicOnly { account: record } } RadrootsNostrLocalSignerAvailability::SecretBacked => { - RadrootsNostrSelectedAccountStatus::Ready { account: record } + RadrootsNostrAccountStatus::Ready { account: record } } }) } - pub fn selected_signing_identity( + pub fn default_signing_identity( &self, ) -> Result<Option<RadrootsIdentity>, RadrootsNostrAccountsError> { - let Some(record) = self.selected_account()? else { + let Some(record) = self.default_account()? else { return Ok(None); }; self.resolve_signing_identity(record) @@ -203,10 +215,10 @@ impl RadrootsNostrAccountsManager { self.resolve_signing_identity(record) } - pub fn selected_signer_capability( + pub fn default_signer_capability( &self, ) -> Result<Option<RadrootsNostrSignerCapability>, RadrootsNostrAccountsError> { - let Some(record) = self.selected_account()? else { + let Some(record) = self.default_account()? else { return Ok(None); }; Ok(Some(self.local_signer_capability(record)?)) @@ -247,7 +259,7 @@ impl RadrootsNostrAccountsManager { &self, identity: &RadrootsIdentity, label: Option<String>, - make_selected: bool, + make_default: bool, ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { let account_id = identity.id(); let secret_key_hex = Zeroizing::new(identity.secret_key_hex()); @@ -257,14 +269,14 @@ impl RadrootsNostrAccountsManager { )?; let public_identity = identity.to_public(); - self.upsert_public_identity(public_identity, label, make_selected) + self.upsert_public_identity(public_identity, label, make_default) } pub fn upsert_public_identity( &self, public_identity: RadrootsIdentityPublic, label: Option<String>, - make_selected: bool, + make_default: bool, ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { let updated_at_unix = now_unix_secs(); let account_id = public_identity.id.clone(); @@ -292,8 +304,8 @@ impl RadrootsNostrAccountsManager { )); } - if state.selected_account_id.is_none() || make_selected { - state.selected_account_id = Some(account_id.clone()); + if state.default_account_id.is_none() || make_default { + state.default_account_id = Some(account_id.clone()); } Ok(()) })?; @@ -303,13 +315,13 @@ impl RadrootsNostrAccountsManager { pub fn generate_identity( &self, label: Option<String>, - make_selected: bool, + make_default: bool, ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { let identity = RadrootsIdentity::generate(); - self.upsert_identity(&identity, label, make_selected) + self.upsert_identity(&identity, label, make_default) } - pub fn select_account( + pub fn set_default_account( &self, account_id: &RadrootsIdentityId, ) -> Result<(), RadrootsNostrAccountsError> { @@ -324,11 +336,62 @@ impl RadrootsNostrAccountsManager { account_id.to_string(), )); } - state.selected_account_id = Some(account_id); + state.default_account_id = Some(account_id); Ok(()) }) } + pub fn clear_default_account(&self) -> Result<(), RadrootsNostrAccountsError> { + self.update_state(|state| { + state.default_account_id = None; + Ok(()) + }) + } + + pub fn resolve_account_selector( + &self, + selector: &str, + ) -> Result<RadrootsNostrAccountRecord, RadrootsNostrAccountsError> { + let normalized = selector.trim(); + if normalized.is_empty() { + return Err(RadrootsNostrAccountsError::InvalidAccountSelector( + "account selector cannot be empty".to_owned(), + )); + } + + let guard = self.state.read().map_err(|_| { + RadrootsNostrAccountsError::Store("accounts state lock poisoned".into()) + })?; + if let Some(record) = guard + .accounts + .iter() + .find(|record| { + record.account_id.as_str() == normalized + || record.public_identity.public_key_npub == normalized + }) + .cloned() + { + return Ok(record); + } + + let mut label_matches = guard + .accounts + .iter() + .filter(|record| record.label.as_deref() == Some(normalized)) + .cloned(); + let Some(record) = label_matches.next() else { + return Err(RadrootsNostrAccountsError::AccountNotFound( + normalized.to_owned(), + )); + }; + if label_matches.next().is_some() { + return Err(RadrootsNostrAccountsError::AmbiguousAccountSelector( + normalized.to_owned(), + )); + } + Ok(record) + } + pub fn remove_account( &self, account_id: &RadrootsIdentityId, @@ -345,11 +408,8 @@ impl RadrootsNostrAccountsManager { )); } - if state.selected_account_id.as_ref() == Some(&account_id) { - state.selected_account_id = state - .accounts - .first() - .map(|record| record.account_id.clone()); + if state.default_account_id.as_ref() == Some(&account_id) { + state.default_account_id = None; } Ok(()) })?; @@ -371,10 +431,10 @@ impl RadrootsNostrAccountsManager { &self, path: impl AsRef<Path>, label: Option<String>, - make_selected: bool, + make_default: bool, ) -> Result<RadrootsIdentityId, RadrootsNostrAccountsError> { let identity = RadrootsIdentity::load_from_path_auto(path)?; - self.upsert_identity(&identity, label, make_selected) + self.upsert_identity(&identity, label, make_default) } fn resolve_signing_identity( @@ -497,6 +557,8 @@ mod tests { RadrootsHostVaultCapabilities, RadrootsSecretBackend, RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, }; + use serde_json::json; + use std::fs; use std::sync::Arc; use std::sync::RwLock; use std::thread; @@ -673,26 +735,24 @@ mod tests { .join(); } - fn status_kind(status: &RadrootsNostrSelectedAccountStatus) -> &'static str { + fn status_kind(status: &RadrootsNostrAccountStatus) -> &'static str { match status { - RadrootsNostrSelectedAccountStatus::NotConfigured => "not-configured", - RadrootsNostrSelectedAccountStatus::PublicOnly { .. } => "public-only", - RadrootsNostrSelectedAccountStatus::Ready { .. } => "ready", + RadrootsNostrAccountStatus::NotConfigured => "not-configured", + RadrootsNostrAccountStatus::PublicOnly { .. } => "public-only", + RadrootsNostrAccountStatus::Ready { .. } => "ready", } } - fn status_account( - status: &RadrootsNostrSelectedAccountStatus, - ) -> Option<&RadrootsNostrAccountRecord> { + fn status_account(status: &RadrootsNostrAccountStatus) -> Option<&RadrootsNostrAccountRecord> { match status { - RadrootsNostrSelectedAccountStatus::NotConfigured => None, - RadrootsNostrSelectedAccountStatus::PublicOnly { account } - | RadrootsNostrSelectedAccountStatus::Ready { account } => Some(account), + RadrootsNostrAccountStatus::NotConfigured => None, + RadrootsNostrAccountStatus::PublicOnly { account } + | RadrootsNostrAccountStatus::Ready { account } => Some(account), } } #[test] - fn manager_persists_selection_and_restores_signing_identity() { + fn manager_persists_default_account_and_restores_signing_identity() { let temp = tempfile::tempdir().expect("tempdir"); let store = Arc::new(RadrootsNostrFileAccountStore::new( temp.path().join("accounts.json"), @@ -704,28 +764,28 @@ mod tests { .generate_identity(Some("primary".into()), true) .expect("create identity"); - let selected = manager - .selected_account_id() - .expect("selected") - .expect("selected id"); - assert_eq!(selected, created_id); + let default_account_id = manager + .default_account_id() + .expect("default") + .expect("default id"); + assert_eq!(default_account_id, created_id); let manager2 = RadrootsNostrAccountsManager::new(store, vault).expect("manager2"); - let selected2 = manager2 - .selected_account_id() - .expect("selected2") - .expect("selected2 id"); - assert_eq!(selected2, created_id); + let default_account_id_2 = manager2 + .default_account_id() + .expect("default2") + .expect("default2 id"); + assert_eq!(default_account_id_2, created_id); assert!( manager2 - .selected_signing_identity() + .default_signing_identity() .expect("signing") .is_some() ); } #[test] - fn new_file_backed_with_vault_persists_selection() { + fn new_file_backed_with_vault_persists_default_account() { let temp = tempfile::tempdir().expect("tempdir"); let path = temp.path().join("accounts.json"); let manager = RadrootsNostrAccountsManager::new_file_backed_with_vault( @@ -745,13 +805,66 @@ mod tests { .expect("reloaded"); assert_eq!( - reloaded.selected_account_id().expect("selected"), + reloaded.default_account_id().expect("default"), Some(account_id) ); assert_eq!(reloaded.list_accounts().expect("accounts").len(), 1); } #[test] + fn new_migrates_legacy_store_file_to_default_account_semantics() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("accounts.json"); + let identity = RadrootsIdentity::generate(); + let public_identity = identity.to_public(); + let account_id = public_identity.id.clone(); + let legacy_record = + RadrootsNostrAccountRecord::new(public_identity, Some("legacy".into()), 1); + fs::write( + &path, + serde_json::to_vec_pretty(&json!({ + "version": 1, + "selected_account_id": account_id, + "accounts": [legacy_record], + })) + .expect("serialize legacy store"), + ) + .expect("write legacy store"); + + let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); + vault + .store_secret( + account_secret_slot(&account_id).as_str(), + identity.secret_key_hex().as_str(), + ) + .expect("store secret"); + + let manager = RadrootsNostrAccountsManager::new( + Arc::new(RadrootsNostrFileAccountStore::new(&path)), + vault, + ) + .expect("manager"); + + assert_eq!( + manager.default_account_id().expect("default"), + Some(account_id.clone()) + ); + + let migrated_store: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("read migrated store")) + .expect("parse migrated store"); + assert_eq!( + migrated_store["version"], + serde_json::Value::from(crate::model::RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION), + ); + assert_eq!( + migrated_store["default_account_id"], + serde_json::Value::from(account_id.to_string()), + ); + assert!(migrated_store.get("selected_account_id").is_none()); + } + + #[test] fn resolve_local_backend_applies_shared_fallback_policy() { let resolved = RadrootsNostrAccountsManager::resolve_local_backend( RadrootsSecretBackendSelection { @@ -817,39 +930,39 @@ mod tests { assert!( manager - .selected_signing_identity() + .default_signing_identity() .expect("signing") .is_none() ); let status = manager - .selected_account_status() - .expect("selected account status"); + .default_account_status() + .expect("default account status"); assert_eq!(status_kind(&status), "public-only"); let account = status_account(&status).expect("account"); assert_eq!(account.label.as_deref(), Some("watch")); } #[test] - fn selected_account_status_reports_ready_for_signing_identity() { + fn default_account_status_reports_ready_for_signing_identity() { let manager = RadrootsNostrAccountsManager::new_in_memory(); - let selected_id = manager + let default_account_id = manager .generate_identity(Some("primary".into()), true) .expect("generate"); let status = manager - .selected_account_status() - .expect("selected account status"); + .default_account_status() + .expect("default account status"); assert_eq!(status_kind(&status), "ready"); let account = status_account(&status).expect("account"); - assert_eq!(account.account_id, selected_id); + assert_eq!(account.account_id, default_account_id); assert_eq!(account.label.as_deref(), Some("primary")); let signer = manager - .selected_signer_capability() - .expect("selected signer capability") + .default_signer_capability() + .expect("default signer capability") .expect("signer capability"); let local = signer.local_account().expect("local signer"); - assert_eq!(local.account_id, selected_id); + assert_eq!(local.account_id, default_account_id); assert!(local.is_secret_backed()); } @@ -872,9 +985,9 @@ mod tests { .expect("migrate"); assert_eq!( manager - .selected_account_id() - .expect("selected") - .expect("selected id"), + .default_account_id() + .expect("default") + .expect("default id"), id ); } @@ -887,8 +1000,8 @@ mod tests { .expect("generate"); let existing = manager - .selected_public_identity() - .expect("selected public") + .default_public_identity() + .expect("default public") .expect("public identity"); manager .upsert_public_identity(existing, None, false) @@ -917,47 +1030,47 @@ mod tests { } #[test] - fn new_clears_orphaned_selected_account() { + fn new_clears_orphaned_default_account() { let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); let mut state = RadrootsNostrAccountStoreState::default(); - state.selected_account_id = Some(RadrootsIdentity::generate().id()); + state.default_account_id = Some(RadrootsIdentity::generate().id()); store.save(&state).expect("save"); let manager = RadrootsNostrAccountsManager::new(store, vault).expect("manager"); - assert!(manager.selected_account_id().expect("selected").is_none()); + assert!(manager.default_account_id().expect("default").is_none()); } #[test] - fn selected_methods_return_none_when_state_is_empty() { + fn default_methods_return_none_when_state_is_empty() { let manager = RadrootsNostrAccountsManager::new_in_memory(); assert!( manager - .selected_account() - .expect("selected account") + .default_account() + .expect("default account") .is_none() ); assert!( manager - .selected_public_identity() - .expect("selected public") + .default_public_identity() + .expect("default public") .is_none() ); assert!( manager - .selected_signing_identity() - .expect("selected signing") + .default_signing_identity() + .expect("default signing") .is_none() ); assert!( manager - .selected_signer_capability() - .expect("selected signer capability") + .default_signer_capability() + .expect("default signer capability") .is_none() ); let status = manager - .selected_account_status() - .expect("selected account status"); + .default_account_status() + .expect("default account status"); assert_eq!(status_kind(&status), "not-configured"); assert!(status_account(&status).is_none()); @@ -977,7 +1090,7 @@ mod tests { } #[test] - fn selected_account_status_propagates_secret_integrity_errors() { + fn default_account_status_propagates_secret_integrity_errors() { let manager = RadrootsNostrAccountsManager::new_in_memory(); let account_id = manager .generate_identity(Some("primary".into()), true) @@ -988,8 +1101,8 @@ mod tests { .expect("remove secret"); let status = manager - .selected_account_status() - .expect("selected account status"); + .default_account_status() + .expect("default account status"); assert_eq!(status_kind(&status), "public-only"); let account = status_account(&status).expect("account"); assert_eq!(account.account_id, account_id); @@ -1004,19 +1117,19 @@ mod tests { .expect("store wrong secret"); let err = manager - .selected_account_status() + .default_account_status() .expect_err("public key mismatch"); assert_eq!(err.to_string(), "public key does not match secret key"); } #[test] - fn selected_account_status_propagates_store_vault_and_secret_parse_errors() { + fn default_account_status_propagates_store_vault_and_secret_parse_errors() { let poisoned_manager = RadrootsNostrAccountsManager::new_in_memory(); poison_manager_state(&poisoned_manager); - let selected_err = poisoned_manager - .selected_account_status() - .expect_err("selected status poisoned"); - assert!(selected_err.to_string().starts_with("store error:")); + let default_err = poisoned_manager + .default_account_status() + .expect_err("default status poisoned"); + assert!(default_err.to_string().starts_with("store error:")); let mut load_error_state = RadrootsNostrAccountStoreState::default(); let load_error_public = RadrootsIdentity::generate().to_public(); @@ -1027,7 +1140,7 @@ mod tests { Some("watch".into()), 1, )); - load_error_state.selected_account_id = Some(load_error_public.id.clone()); + load_error_state.default_account_id = Some(load_error_public.id.clone()); let load_error_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); load_error_store .save(&load_error_state) @@ -1036,7 +1149,7 @@ mod tests { RadrootsNostrAccountsManager::new(load_error_store, Arc::new(VaultLoadError)) .expect("manager"); let vault_load_error = vault_load_error_manager - .selected_account_status() + .default_account_status() .expect_err("vault load error"); assert!(vault_load_error.to_string().starts_with("vault error:")); @@ -1049,7 +1162,7 @@ mod tests { Some("invalid".into()), 1, )); - invalid_secret_state.selected_account_id = Some(invalid_secret_public.id.clone()); + invalid_secret_state.default_account_id = Some(invalid_secret_public.id.clone()); let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); invalid_secret_store .save(&invalid_secret_state) @@ -1058,7 +1171,7 @@ mod tests { RadrootsNostrAccountsManager::new(invalid_secret_store, Arc::new(VaultInvalidSecret)) .expect("manager"); let invalid_secret = invalid_secret_manager - .selected_account_status() + .default_account_status() .expect_err("invalid secret"); assert!(invalid_secret.to_string().starts_with("identity error:")); } @@ -1074,7 +1187,7 @@ mod tests { Some("invalid".into()), 1, )); - invalid_secret_state.selected_account_id = Some(invalid_secret_public.id.clone()); + invalid_secret_state.default_account_id = Some(invalid_secret_public.id.clone()); let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); invalid_secret_store .save(&invalid_secret_state) @@ -1083,11 +1196,11 @@ mod tests { RadrootsNostrAccountsManager::new(invalid_secret_store, Arc::new(VaultInvalidSecret)) .expect("manager"); - let selected_signer_error = invalid_secret_manager - .selected_signer_capability() - .expect_err("selected signer invalid secret"); + let default_signer_error = invalid_secret_manager + .default_signer_capability() + .expect_err("default signer invalid secret"); assert!( - selected_signer_error + default_signer_error .to_string() .starts_with("identity error:") ); @@ -1108,9 +1221,11 @@ mod tests { .generate_identity(Some("second".into()), false) .expect("second"); - manager.select_account(&second_id).expect("select second"); + manager + .set_default_account(&second_id) + .expect("set default second"); assert_eq!( - manager.selected_account_id().expect("selected"), + manager.default_account_id().expect("default"), Some(second_id.clone()) ); assert!( @@ -1127,21 +1242,28 @@ mod tests { ); manager.remove_account(&second_id).expect("remove second"); - assert_eq!( - manager.selected_account_id().expect("selected"), - Some(first_id) - ); + assert_eq!(manager.default_account_id().expect("default"), None); assert!( manager .export_secret_hex(&second_id) .expect("export after remove") .is_none() ); + assert!( + manager + .get_signing_identity(&first_id) + .expect("first signing") + .is_some() + ); - let select_missing = manager - .select_account(&second_id) - .expect_err("missing select"); - assert!(select_missing.to_string().contains("account not found")); + let set_default_missing = manager + .set_default_account(&second_id) + .expect_err("missing default"); + assert!( + set_default_missing + .to_string() + .contains("account not found") + ); let remove_missing = manager .remove_account(&second_id) .expect_err("missing remove"); @@ -1149,15 +1271,15 @@ mod tests { } #[test] - fn upsert_public_identity_updates_label_and_respects_selection_flag() { + fn upsert_public_identity_updates_label_and_respects_default_flag() { let manager = RadrootsNostrAccountsManager::new_in_memory(); - manager + let original_default = manager .generate_identity(Some("primary".into()), true) .expect("generate"); let existing = manager - .selected_public_identity() - .expect("selected public") + .default_public_identity() + .expect("default public") .expect("public"); manager .upsert_public_identity(existing.clone(), Some("renamed".into()), false) @@ -1173,12 +1295,19 @@ mod tests { let watch_only = RadrootsIdentity::generate().to_public(); let watch_id = watch_only.id.clone(); - let make_selected = manager.selected_account_id().expect("selected").is_some(); manager - .upsert_public_identity(watch_only, Some("watch".into()), make_selected) + .upsert_public_identity(watch_only.clone(), Some("watch".into()), false) .expect("upsert watch"); assert_eq!( - manager.selected_account_id().expect("selected"), + manager.default_account_id().expect("default"), + Some(original_default.clone()) + ); + + manager + .upsert_public_identity(watch_only, Some("watch".into()), true) + .expect("replace default"); + assert_eq!( + manager.default_account_id().expect("default"), Some(watch_id) ); } @@ -1197,23 +1326,94 @@ mod tests { } #[test] - fn remove_non_selected_account_keeps_current_selection() { + fn remove_non_default_account_keeps_current_default() { let manager = RadrootsNostrAccountsManager::new_in_memory(); - let selected_id = manager + let default_account_id = manager .generate_identity(Some("selected".into()), true) - .expect("selected"); + .expect("default"); let removable_id = manager .generate_identity(Some("removable".into()), false) .expect("removable"); manager.remove_account(&removable_id).expect("remove"); assert_eq!( - manager.selected_account_id().expect("selected"), - Some(selected_id) + manager.default_account_id().expect("default"), + Some(default_account_id) ); } #[test] + fn clear_default_account_clears_default_without_removing_accounts() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + manager + .generate_identity(Some("primary".into()), true) + .expect("primary"); + manager + .generate_identity(Some("secondary".into()), false) + .expect("secondary"); + + manager.clear_default_account().expect("clear default"); + + assert!(manager.default_account_id().expect("default").is_none()); + assert_eq!(manager.list_accounts().expect("accounts").len(), 2); + } + + #[test] + fn resolve_account_selector_matches_exact_id_npub_and_unique_label() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let account_id = manager + .generate_identity(Some("primary".into()), true) + .expect("primary"); + let default_account = manager + .default_account() + .expect("default account") + .expect("default record"); + let npub = default_account.public_identity.public_key_npub.clone(); + + let resolved_by_id = manager + .resolve_account_selector(account_id.as_str()) + .expect("resolve by id"); + assert_eq!(resolved_by_id.account_id, account_id); + + let resolved_by_npub = manager + .resolve_account_selector(&npub) + .expect("resolve by npub"); + assert_eq!(resolved_by_npub.account_id, account_id); + + let resolved_by_label = manager + .resolve_account_selector("primary") + .expect("resolve by label"); + assert_eq!(resolved_by_label.account_id, account_id); + } + + #[test] + fn resolve_account_selector_rejects_empty_and_ambiguous_labels() { + let manager = RadrootsNostrAccountsManager::new_in_memory(); + manager + .generate_identity(Some("shared".into()), true) + .expect("first"); + manager + .generate_identity(Some("shared".into()), false) + .expect("second"); + + let empty = manager + .resolve_account_selector(" ") + .expect_err("empty selector"); + assert!(matches!( + empty, + RadrootsNostrAccountsError::InvalidAccountSelector(_) + )); + + let ambiguous = manager + .resolve_account_selector("shared") + .expect_err("ambiguous selector"); + assert!(matches!( + ambiguous, + RadrootsNostrAccountsError::AmbiguousAccountSelector(_) + )); + } + + #[test] fn remove_account_propagates_vault_remove_error() { let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); let vault = Arc::new(VaultRemoveError); @@ -1260,7 +1460,7 @@ mod tests { .expect("vault store"); let mismatch = manager - .selected_signing_identity() + .default_signing_identity() .expect_err("public key mismatch"); assert!( mismatch @@ -1358,7 +1558,7 @@ mod tests { Some("watch".into()), 1, )); - load_error_state.selected_account_id = Some(load_error_public.id.clone()); + load_error_state.default_account_id = Some(load_error_public.id.clone()); let load_error_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); load_error_store .save(&load_error_state) @@ -1367,7 +1567,7 @@ mod tests { RadrootsNostrAccountsManager::new(load_error_store, Arc::new(VaultLoadError)) .expect("manager"); let vault_load_error = vault_load_error_manager - .selected_signing_identity() + .default_signing_identity() .expect_err("vault load error"); assert!(vault_load_error.to_string().starts_with("vault error:")); @@ -1380,7 +1580,7 @@ mod tests { Some("invalid".into()), 1, )); - invalid_secret_state.selected_account_id = Some(invalid_secret_public.id.clone()); + invalid_secret_state.default_account_id = Some(invalid_secret_public.id.clone()); let invalid_secret_store = Arc::new(RadrootsNostrMemoryAccountStore::new()); invalid_secret_store .save(&invalid_secret_state) @@ -1389,7 +1589,7 @@ mod tests { RadrootsNostrAccountsManager::new(invalid_secret_store, Arc::new(VaultInvalidSecret)) .expect("manager"); let invalid_secret = invalid_secret_manager - .selected_signing_identity() + .default_signing_identity() .expect_err("invalid secret"); assert!(invalid_secret.to_string().starts_with("identity error:")); } @@ -1412,24 +1612,24 @@ mod tests { let list_err = manager.list_accounts().expect_err("list poisoned"); assert!(list_err.to_string().starts_with("store error:")); - let selected_id_err = manager - .selected_account_id() - .expect_err("selected id poisoned"); - assert!(selected_id_err.to_string().starts_with("store error:")); - let selected_err = manager.selected_account().expect_err("selected poisoned"); - assert!(selected_err.to_string().starts_with("store error:")); - let selected_public_err = manager - .selected_public_identity() - .expect_err("selected public poisoned"); - assert!(selected_public_err.to_string().starts_with("store error:")); - let selected_signing_err = manager - .selected_signing_identity() - .expect_err("selected signing poisoned"); - assert!(selected_signing_err.to_string().starts_with("store error:")); - let selected_signer_err = manager - .selected_signer_capability() - .expect_err("selected signer poisoned"); - assert!(selected_signer_err.to_string().starts_with("store error:")); + let default_id_err = manager + .default_account_id() + .expect_err("default id poisoned"); + assert!(default_id_err.to_string().starts_with("store error:")); + let default_err = manager.default_account().expect_err("default poisoned"); + assert!(default_err.to_string().starts_with("store error:")); + let default_public_err = manager + .default_public_identity() + .expect_err("default public poisoned"); + assert!(default_public_err.to_string().starts_with("store error:")); + let default_signing_err = manager + .default_signing_identity() + .expect_err("default signing poisoned"); + assert!(default_signing_err.to_string().starts_with("store error:")); + let default_signer_err = manager + .default_signer_capability() + .expect_err("default signer poisoned"); + assert!(default_signer_err.to_string().starts_with("store error:")); let account_id = RadrootsIdentity::generate().id(); let signing_err = manager @@ -1440,10 +1640,10 @@ mod tests { .get_signer_capability(&account_id) .expect_err("signer poisoned"); assert!(signer_err.to_string().starts_with("store error:")); - let select_err = manager - .select_account(&account_id) - .expect_err("select poisoned"); - assert!(select_err.to_string().starts_with("store error:")); + let set_default_err = manager + .set_default_account(&account_id) + .expect_err("default poisoned"); + assert!(set_default_err.to_string().starts_with("store error:")); let remove_err = manager .remove_account(&account_id) .expect_err("remove poisoned"); diff --git a/crates/nostr_accounts/src/model.rs b/crates/nostr_accounts/src/model.rs @@ -1,7 +1,7 @@ use radroots_identity::{RadrootsIdentityId, RadrootsIdentityPublic}; use serde::{Deserialize, Serialize}; -pub const RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION: u32 = 1; +pub const RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrAccountRecord { @@ -16,12 +16,13 @@ pub struct RadrootsNostrAccountRecord { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RadrootsNostrAccountStoreState { pub version: u32, - pub selected_account_id: Option<RadrootsIdentityId>, + #[serde(alias = "selected_account_id")] + pub default_account_id: Option<RadrootsIdentityId>, pub accounts: Vec<RadrootsNostrAccountRecord>, } #[derive(Debug, Clone)] -pub enum RadrootsNostrSelectedAccountStatus { +pub enum RadrootsNostrAccountStatus { NotConfigured, PublicOnly { account: RadrootsNostrAccountRecord }, Ready { account: RadrootsNostrAccountRecord }, @@ -31,7 +32,7 @@ impl Default for RadrootsNostrAccountStoreState { fn default() -> Self { Self { version: RADROOTS_NOSTR_ACCOUNTS_STORE_VERSION, - selected_account_id: None, + default_account_id: None, accounts: Vec::new(), } } diff --git a/crates/nostr_accounts/src/ndb_bridge.rs b/crates/nostr_accounts/src/ndb_bridge.rs @@ -2,11 +2,11 @@ use crate::error::RadrootsNostrAccountsError; use crate::manager::RadrootsNostrAccountsManager; use radroots_nostr_ndb::prelude::RadrootsNostrNdb; -pub fn radroots_nostr_accounts_register_selected_secret_with_ndb( +pub fn radroots_nostr_accounts_register_default_secret_with_ndb( manager: &RadrootsNostrAccountsManager, ndb: &RadrootsNostrNdb, ) -> Result<bool, RadrootsNostrAccountsError> { - let Some(signer) = manager.selected_signer_capability()? else { + let Some(signer) = manager.default_signer_capability()? else { return Ok(false); }; let Some(identity) = manager.resolve_signing_identity_for_signer(&signer)? else { @@ -24,7 +24,7 @@ mod tests { use std::sync::Arc; #[test] - fn register_selected_secret_returns_true_for_signing_account() { + fn register_default_secret_returns_true_for_signing_account() { let temp = tempfile::tempdir().expect("tempdir"); let store = Arc::new(RadrootsNostrFileAccountStore::new( temp.path().join("accounts.json"), @@ -37,13 +37,13 @@ mod tests { let ndb = RadrootsNostrNdb::open(RadrootsNostrNdbConfig::new(temp.path().join("ndb"))) .expect("ndb"); - let added = radroots_nostr_accounts_register_selected_secret_with_ndb(&manager, &ndb) + let added = radroots_nostr_accounts_register_default_secret_with_ndb(&manager, &ndb) .expect("register"); assert!(added); } #[test] - fn register_selected_secret_returns_false_for_watch_only_account() { + fn register_default_secret_returns_false_for_watch_only_account() { let temp = tempfile::tempdir().expect("tempdir"); let store = Arc::new(RadrootsNostrFileAccountStore::new( temp.path().join("accounts.json"), @@ -60,7 +60,7 @@ mod tests { let ndb = RadrootsNostrNdb::open(RadrootsNostrNdbConfig::new(temp.path().join("ndb"))) .expect("ndb"); - let added = radroots_nostr_accounts_register_selected_secret_with_ndb(&manager, &ndb) + let added = radroots_nostr_accounts_register_default_secret_with_ndb(&manager, &ndb) .expect("register"); assert!(!added); } diff --git a/crates/nostr_accounts/src/store.rs b/crates/nostr_accounts/src/store.rs @@ -183,7 +183,7 @@ mod tests { let loaded = store.load().expect("load"); assert_eq!(loaded.version, state.version); - assert_eq!(loaded.selected_account_id, state.selected_account_id); + assert_eq!(loaded.default_account_id, state.default_account_id); } #[test]