lib

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

commit 7d6a2a51131babfcf658ebf201abccc03af1ba8e
parent f02ee0da6ea3b91ae6749eec6abc5ed0ba21fd6e
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 19:03:25 +0000

core(accounts): add recoverable account write transactions

- journal credential and metadata phases before publication
- compensate credential writes when account persistence fails
- retain a non-secret recovery entry when compensation fails
- keep public snapshots unchanged across every failed boundary

Diffstat:
Mcrates/studio_application/src/accounts.rs | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/studio_application/src/lib.rs | 5++++-
2 files changed, 357 insertions(+), 18 deletions(-)

diff --git a/crates/studio_application/src/accounts.rs b/crates/studio_application/src/accounts.rs @@ -6,7 +6,11 @@ use radroots_studio_domain::{ }; use radroots_studio_nostr::{generate_local_keypair, import_secret}; -use crate::{AccountRepository, AppCore, AppStateRepository, Clock, SecretStore, StateTransition}; +use crate::{ + AccountOperationKind, AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, + Clock, OperationDiagnostic, OperationId, OperationJournal, PendingAccountOperation, + SecretStore, StateTransition, +}; pub struct GenerateAccountReceipt { account: AccountSummary, @@ -48,6 +52,7 @@ impl AppCore { accounts: &(impl AccountRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<GenerateAccountReceipt, SafeError> { let generated = generate_local_keypair()?; @@ -61,8 +66,16 @@ impl AppCore { AccountCreatedAt::new(clock.now()), None, ); - secrets.put(public_key, secret)?; - accounts.insert_account(&account)?; + Self::persist_account_transaction( + AccountOperationKind::Add, + &account, + secret, + None, + accounts, + secrets, + journal, + clock, + )?; app_state.save_selected_account(Some(public_key))?; let registry = accounts.list_accounts()?; self.apply_transition(StateTransition::ReplaceRegistry { @@ -86,6 +99,7 @@ impl AppCore { accounts: &(impl AccountRepository + ?Sized), app_state: &(impl AppStateRepository + ?Sized), secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<ImportAccountReceipt, SafeError> { let imported = import_secret(input)?; @@ -96,9 +110,17 @@ impl AppCore { { return Err(account_exists()); } - secrets.put(public_key, secret)?; let repaired = existing.with_key_availability(KeyAvailability::Available); - accounts.update_account(&repaired)?; + Self::persist_account_transaction( + AccountOperationKind::Import, + &repaired, + secret, + Some(&existing), + accounts, + secrets, + journal, + clock, + )?; app_state.save_selected_account(Some(public_key))?; self.apply_transition(StateTransition::ReplaceRegistry { accounts: accounts.list_accounts()?, @@ -118,8 +140,16 @@ impl AppCore { AccountCreatedAt::new(clock.now()), None, ); - secrets.put(public_key, secret)?; - accounts.insert_account(&account)?; + Self::persist_account_transaction( + AccountOperationKind::Import, + &account, + secret, + None, + accounts, + secrets, + journal, + clock, + )?; app_state.save_selected_account(Some(public_key))?; self.apply_transition(StateTransition::ReplaceRegistry { accounts: accounts.list_accounts()?, @@ -127,6 +157,161 @@ impl AppCore { })?; Ok(ImportAccountReceipt { account }) } + + #[allow(clippy::too_many_arguments)] + fn persist_account_transaction( + kind: AccountOperationKind, + account: &AccountSummary, + secret: SecretKeyInput, + previous: Option<&AccountSummary>, + accounts: &(impl AccountRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), + ) -> Result<(), SafeError> { + let public_key = account.public_key(); + let operation = journal.begin_operation(kind, public_key, clock.now())?; + if let Err(error) = secrets.put(public_key, secret) { + let _ = journal.finalize_operation(operation); + return Err(error); + } + if let Err(error) = journal.update_operation( + operation, + AccountOperationPhase::CredentialWritten, + clock.now(), + None, + ) { + return compensate_account_write( + operation, public_key, error, None, accounts, secrets, journal, clock, + ); + } + let metadata_result = previous.map_or_else( + || accounts.insert_account(account), + |_| accounts.update_account(account), + ); + if let Err(error) = metadata_result { + return compensate_account_write( + operation, public_key, error, previous, accounts, secrets, journal, clock, + ); + } + journal.update_operation( + operation, + AccountOperationPhase::MetadataCommitted, + clock.now(), + None, + )?; + journal.finalize_operation(operation) + } +} + +#[allow(clippy::too_many_arguments)] +fn compensate_account_write( + operation: OperationId, + public_key: PublicKey, + original_error: SafeError, + previous: Option<&AccountSummary>, + accounts: &(impl AccountRepository + ?Sized), + secrets: &(impl SecretStore + ?Sized), + journal: &(impl OperationJournal + ?Sized), + clock: &(impl Clock + ?Sized), +) -> Result<(), SafeError> { + if let Some(previous) = previous { + let _ = accounts.update_account(previous); + } else { + let _ = accounts.remove_account(public_key); + } + if secrets.delete(public_key).is_err() { + let _ = journal.update_operation( + operation, + AccountOperationPhase::CompensationPending, + clock.now(), + Some(OperationDiagnostic::CompensationFailed), + ); + return Err(recovery_required()); + } + let _ = journal.finalize_operation(operation); + Err(original_error) +} + +#[derive(Default)] +pub struct InMemoryOperationJournal { + state: Mutex<InMemoryJournalState>, +} + +#[derive(Default)] +struct InMemoryJournalState { + next_id: u64, + pending: Vec<PendingAccountOperation>, +} + +impl OperationJournal for InMemoryOperationJournal { + fn begin_operation( + &self, + kind: AccountOperationKind, + subject: PublicKey, + updated_at: radroots_studio_domain::UnixTimestamp, + ) -> Result<OperationId, SafeError> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.next_id = state.next_id.checked_add(1).ok_or_else(recovery_required)?; + let id = OperationId::from_raw(state.next_id); + state.pending.push(PendingAccountOperation::new( + id, + kind, + subject, + AccountOperationPhase::IntentRecorded, + updated_at, + None, + )); + Ok(id) + } + + fn update_operation( + &self, + id: OperationId, + phase: AccountOperationPhase, + updated_at: radroots_studio_domain::UnixTimestamp, + diagnostic: Option<OperationDiagnostic>, + ) -> Result<(), SafeError> { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let operation = state + .pending + .iter_mut() + .find(|operation| operation.id() == id) + .ok_or_else(recovery_required)?; + *operation = PendingAccountOperation::new( + id, + operation.kind(), + operation.subject(), + phase, + updated_at, + diagnostic, + ); + Ok(()) + } + + fn list_pending_operations(&self) -> Result<Vec<PendingAccountOperation>, SafeError> { + Ok(self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .clone()) + } + + fn finalize_operation(&self, id: OperationId) -> Result<(), SafeError> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .pending + .retain(|operation| operation.id() != id); + Ok(()) + } } #[derive(Default)] @@ -235,17 +420,25 @@ const fn account_not_found() -> SafeError { ) } +const fn recovery_required() -> SafeError { + SafeError::new( + SafeErrorCode::PendingOperationRecoveryRequired, + SafeMessage::new("Account recovery is required before this operation can continue."), + ) +} + #[cfg(test)] mod tests { use radroots_studio_domain::{ - AccountCreatedAt, AccountSummary, KeyAvailability, SafeErrorCode, SecretKeyInput, - SignerKind, UnixTimestamp, + AccountCreatedAt, AccountSummary, KeyAvailability, PublicKey, SafeError, SafeErrorCode, + SafeMessage, SecretKeyInput, SignerKind, UnixTimestamp, }; use super::InMemoryAccountRepository; use crate::{ - AccountRepository, AppCore, AppStateRepository, Clock, InMemorySecretStore, - RelayConfiguration, SecretStore, SessionState, StateTransition, + AccountOperationPhase, AccountRepository, AppCore, AppStateRepository, Clock, + FailureSecretStore, InMemoryOperationJournal, InMemorySecretStore, OperationJournal, + RelayConfiguration, SecretStore, SecretStoreOperation, SessionState, StateTransition, }; struct FixedClock; @@ -256,15 +449,56 @@ mod tests { } } + #[derive(Default)] + struct FailingInsertRepository { + inner: InMemoryAccountRepository, + } + + impl AccountRepository for FailingInsertRepository { + fn list_accounts(&self) -> Result<Vec<AccountSummary>, SafeError> { + self.inner.list_accounts() + } + + fn find_account(&self, public_key: PublicKey) -> Result<Option<AccountSummary>, SafeError> { + self.inner.find_account(public_key) + } + + fn insert_account(&self, _account: &AccountSummary) -> Result<(), SafeError> { + Err(SafeError::new( + SafeErrorCode::StorageUnavailable, + SafeMessage::new("The test account repository is unavailable."), + )) + } + + fn update_account(&self, account: &AccountSummary) -> Result<(), SafeError> { + self.inner.update_account(account) + } + + fn remove_account(&self, public_key: PublicKey) -> Result<(), SafeError> { + self.inner.remove_account(public_key) + } + } + + impl AppStateRepository for FailingInsertRepository { + fn load_selected_account(&self) -> Result<Option<PublicKey>, SafeError> { + self.inner.load_selected_account() + } + + fn save_selected_account(&self, public_key: Option<PublicKey>) -> Result<(), SafeError> { + self.inner.save_selected_account(public_key) + } + } + #[test] fn generate_account_stores_selects_and_returns_one_time_nsec_without_activation() { let core = AppCore::in_memory(RelayConfiguration::default()); let accounts = InMemoryAccountRepository::default(); let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); let receipt = core - .generate_account(&accounts, &accounts, &secrets, &FixedClock) + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) .expect("generate"); let public_key = receipt.account().public_key(); assert_eq!(public_key.to_hex().len(), 64); @@ -289,6 +523,7 @@ mod tests { let core = AppCore::in_memory(RelayConfiguration::default()); let accounts = InMemoryAccountRepository::default(); let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); let receipt = core .import_secret_key( @@ -296,6 +531,7 @@ mod tests { &accounts, &accounts, &secrets, + &journal, &FixedClock, ) .expect("import"); @@ -312,13 +548,14 @@ mod tests { let core = AppCore::in_memory(RelayConfiguration::default()); let accounts = InMemoryAccountRepository::default(); let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); let input = SecretKeyInput::parse( "nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_owned(), ) .expect("domain shape"); let error = core - .import_secret_key(input, &accounts, &accounts, &secrets, &FixedClock) + .import_secret_key(input, &accounts, &accounts, &secrets, &journal, &FixedClock) .expect_err("invalid import"); assert_eq!(error.code(), SafeErrorCode::InvalidSecretKey); assert!(core.snapshot().accounts().is_empty()); @@ -329,6 +566,7 @@ mod tests { let core = AppCore::in_memory(RelayConfiguration::default()); let accounts = InMemoryAccountRepository::default(); let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); let import = || { SecretKeyInput::parse( @@ -336,11 +574,25 @@ mod tests { ) .expect("input") }; - core.import_secret_key(import(), &accounts, &accounts, &secrets, &FixedClock) - .expect("first import"); + core.import_secret_key( + import(), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) + .expect("first import"); let before = core.snapshot(); let error = core - .import_secret_key(import(), &accounts, &accounts, &secrets, &FixedClock) + .import_secret_key( + import(), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) .expect_err("duplicate"); assert_eq!(error.code(), SafeErrorCode::AccountAlreadyExists); assert_eq!(core.snapshot(), before); @@ -352,6 +604,7 @@ mod tests { let core = AppCore::in_memory(RelayConfiguration::default()); let accounts = InMemoryAccountRepository::default(); let secrets = InMemorySecretStore::default(); + let journal = InMemoryOperationJournal::default(); core.bootstrap().expect("bootstrap"); let input = || { SecretKeyInput::parse( @@ -381,7 +634,14 @@ mod tests { .expect("registry"); let receipt = core - .import_secret_key(input(), &accounts, &accounts, &secrets, &FixedClock) + .import_secret_key( + input(), + &accounts, + &accounts, + &secrets, + &journal, + &FixedClock, + ) .expect("repair"); assert_eq!( receipt.account().key_availability(), @@ -390,4 +650,80 @@ mod tests { assert!(secrets.contains(public_key).expect("credential")); assert_eq!(core.snapshot().accounts().len(), 1); } + + #[test] + fn account_transaction_publishes_nothing_when_credential_write_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = InMemoryAccountRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + secrets.fail_next(SecretStoreOperation::Put); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("credential failure"); + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(core.snapshot().accounts().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn account_transaction_removes_written_credential_when_metadata_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = FailingInsertRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("metadata failure"); + assert_eq!(error.code(), SafeErrorCode::StorageUnavailable); + let calls = secrets.calls(); + assert_eq!(calls[0].operation(), SecretStoreOperation::Put); + assert_eq!(calls[1].operation(), SecretStoreOperation::Delete); + assert_eq!(calls[0].public_key(), calls[1].public_key()); + assert!(core.snapshot().accounts().is_empty()); + assert!( + journal + .list_pending_operations() + .expect("journal") + .is_empty() + ); + } + + #[test] + fn account_transaction_retains_non_secret_journal_when_compensation_fails() { + let core = AppCore::in_memory(RelayConfiguration::default()); + let accounts = FailingInsertRepository::default(); + let secrets = FailureSecretStore::default(); + let journal = InMemoryOperationJournal::default(); + core.bootstrap().expect("bootstrap"); + secrets.fail_next(SecretStoreOperation::Delete); + + let error = core + .generate_account(&accounts, &accounts, &secrets, &journal, &FixedClock) + .err() + .expect("recovery required"); + assert_eq!( + error.code(), + SafeErrorCode::PendingOperationRecoveryRequired + ); + let pending = journal.list_pending_operations().expect("journal"); + assert_eq!(pending.len(), 1); + assert_eq!( + pending[0].phase(), + AccountOperationPhase::CompensationPending + ); + assert!(!format!("{pending:?}").contains("nsec1")); + assert!(core.snapshot().accounts().is_empty()); + } } diff --git a/crates/studio_application/src/lib.rs b/crates/studio_application/src/lib.rs @@ -7,7 +7,10 @@ pub mod secrets; pub mod snapshot; pub mod state_machine; -pub use accounts::{GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository}; +pub use accounts::{ + GenerateAccountReceipt, ImportAccountReceipt, InMemoryAccountRepository, + InMemoryOperationJournal, +}; pub use app_core::{AppCore, AppObserver, ObserverHandle}; pub use ports::{ AccountNamespaceRepository, AccountOperationKind, AccountOperationPhase, AccountPreferenceKey,