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:
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.