app

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

commit 0820f16cb66e083ebd342e02746076b9b084d635
parent c7634aa92986f2f7218acc6e153305fe0b5ea801
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:59:28 +0000

core(accounts): reject duplicate imported accounts

- reject existing account credentials without overwriting state
- preserve the current snapshot when duplicate import fails
- support only explicit credential-missing metadata repair
- retain original public metadata while restoring availability

Diffstat:
Mcore/crates/application/src/accounts.rs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/domain/src/account.rs | 13+++++++++++++
Mdocs/architecture/nostr-accounts.md | 6++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 112 insertions(+), 4 deletions(-)

diff --git a/core/crates/application/src/accounts.rs b/core/crates/application/src/accounts.rs @@ -90,6 +90,25 @@ impl AppCore { ) -> Result<ImportAccountReceipt, SafeError> { let imported = import_secret(input)?; let (public_key, npub, secret) = imported.into_parts(); + if let Some(existing) = accounts.find_account(public_key)? { + if existing.key_availability() != KeyAvailability::CredentialMissing + || secrets.contains(public_key)? + { + return Err(account_exists()); + } + secrets.put(public_key, secret)?; + let repaired = existing.with_key_availability(KeyAvailability::Available); + accounts.update_account(&repaired)?; + app_state.save_selected_account(Some(public_key))?; + self.apply_transition(StateTransition::ReplaceRegistry { + accounts: accounts.list_accounts()?, + selected: Some(public_key), + })?; + return Ok(ImportAccountReceipt { account: repaired }); + } + if secrets.contains(public_key)? { + return Err(account_exists()); + } let account = AccountSummary::new( public_key, npub, @@ -218,12 +237,15 @@ const fn account_not_found() -> SafeError { #[cfg(test)] mod tests { - use radroots_studio_domain::{SafeErrorCode, SecretKeyInput, UnixTimestamp}; + use radroots_studio_domain::{ + AccountCreatedAt, AccountSummary, KeyAvailability, SafeErrorCode, SecretKeyInput, + SignerKind, UnixTimestamp, + }; use super::InMemoryAccountRepository; use crate::{ - AppCore, AppStateRepository, Clock, InMemorySecretStore, RelayConfiguration, SecretStore, - SessionState, + AccountRepository, AppCore, AppStateRepository, Clock, InMemorySecretStore, + RelayConfiguration, SecretStore, SessionState, StateTransition, }; struct FixedClock; @@ -301,4 +323,71 @@ mod tests { assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); assert!(core.snapshot().accounts().is_empty()); } + + #[test] + fn duplicate_import_preserves_existing_credential_and_snapshot() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + core.bootstrap().expect("bootstrap"); + let import = || { + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input") + }; + core.import_secret_key(import(), &accounts, &accounts, &secrets, &FixedClock) + .expect("first import"); + let before = core.snapshot(); + let error = core + .import_secret_key(import(), &accounts, &accounts, &secrets, &FixedClock) + .expect_err("duplicate"); + assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists); + assert_eq!(core.snapshot(), before); + assert_eq!(core.snapshot().accounts().len(), 1); + } + + #[test] + fn duplicate_import_repairs_only_explicit_missing_credential_account() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = InMemorySecretStore::default(); + core.bootstrap().expect("bootstrap"); + let input = || { + SecretKeyInput::parse( + "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7".to_owned(), + ) + .expect("input") + }; + let imported = radroots_studio_nostr::import_secret(input()).expect("derive"); + let (public_key, npub, _) = imported.into_parts(); + let missing = AccountSummary::new( + public_key, + npub, + SignerKind::LocalSecret, + KeyAvailability::CredentialMissing, + None, + AccountCreatedAt::new(FixedClock.now()), + None, + ); + accounts.insert_account(&missing).expect("missing metadata"); + accounts + .save_selected_account(Some(public_key)) + .expect("selection"); + core.apply_transition(StateTransition::ReplaceRegistry { + accounts: vec![missing], + selected: Some(public_key), + }) + .expect("registry"); + + let receipt = core + .import_secret_key(input(), &accounts, &accounts, &secrets, &FixedClock) + .expect("repair"); + assert_eq!( + receipt.account().key_availability(), + KeyAvailability::Available + ); + assert!(secrets.contains(public_key).expect("credential")); + assert_eq!(core.snapshot().accounts().len(), 1); + } } diff --git a/core/crates/domain/src/account.rs b/core/crates/domain/src/account.rs @@ -131,6 +131,19 @@ impl AccountSummary { } #[must_use] + pub fn with_key_availability(&self, key_availability: KeyAvailability) -> Self { + Self { + public_key: self.public_key, + npub: self.npub.clone(), + signer_kind: self.signer_kind, + key_availability, + label: self.label.clone(), + created_at: self.created_at, + last_used_at: self.last_used_at, + } + } + + #[must_use] pub fn display_label(&self) -> String { self.label .as_ref() diff --git a/docs/architecture/nostr-accounts.md b/docs/architecture/nostr-accounts.md @@ -1,5 +1,11 @@ # Nostr accounts architecture +An import that resolves to an account with an existing credential returns +`AccountAlreadyExists` and does not overwrite either resource. The sole repair +path is an existing account explicitly marked `CredentialMissing` with no +credential present; matching import restores that credential, changes the +public availability state to `Available`, and retains the original metadata. + ## Status Initial architecture contract. Update this document as each implemented diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -499,7 +499,7 @@ handoff commit sequence. - [x] 25. Pin Nostr dependency and implement key generation/derivation adapter. - [x] 26. Implement generate account command with in-memory storage. - [x] 27. Implement import secret key command. -- [ ] 28. Define and test duplicate import and credential-repair handling. +- [x] 28. Define and test duplicate import and credential-repair handling. - [ ] 29. Implement add/import transaction rollback across keyring and DB. - [ ] 30. Implement persisted generate/import using SQLite adapter.