lib

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

commit 43cc93cc17683c794934b7fcb98619b2c1345b6a
parent 326b21c850fb83215a66ccee3b4385181743a71e
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 08:07:21 +0000

accounts: make failed recovery commits terminal

- classify acknowledgement as the irreversible persistence handoff
- direct failed commits to import the previously saved recovery key
- prevent unsafe retry guidance after native stage consumption
- prove failed keyring writes do not poison later recovery attempts

Diffstat:
Mcrates/studio_ffi/src/commands.rs | 19+++++++++++++++++--
Mcrates/studio_storage/src/runtime_actor.rs | 40+++++++++++++++++++++++++++++++++++++---
2 files changed, 54 insertions(+), 5 deletions(-)

diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -278,7 +278,8 @@ impl StudioAppCore { /// /// # Errors /// - /// Returns a safe recovery, credential, persistence, timeout, or lifecycle error. + /// Returns a terminal safe recovery, credential, persistence, timeout, or lifecycle error. + /// A failed commit must be recovered by importing the already-saved recovery key. pub async fn acknowledge_generated_account_v2( &self, request: Arc<GeneratedRecoveryRequest>, @@ -291,7 +292,7 @@ impl StudioAppCore { .acknowledge_generated_key_stage(request.handle.id()) .await .map(|snapshot| self.inner.dto_for(&snapshot)) - .map_err(StudioError::from) + .map_err(generated_commit_failed) } /// Cancels the exclusive generated-account recovery flow. @@ -617,6 +618,20 @@ fn generated_recovery_expired() -> StudioError { } } +fn generated_commit_failed(error: SafeError) -> StudioError { + let (category, _, _) = error_policy(error.code()); + StudioError::Failure { + code: error.code().into(), + category, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: None, + safe_message: + "The generated account could not be saved. Import the recovery key you saved to try again." + .to_owned(), + } +} + fn compatibility_mismatch() -> StudioError { StudioError::Failure { code: WireErrorCode::CompatibilityMismatch, diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs @@ -1139,11 +1139,12 @@ mod tests { use std::time::Duration; use radroots_studio_application::{ - BoxFuture, Clock, InMemorySecretStore, NostrClient, RelayConfiguration, RuntimeLifecycle, - SecretStore, SessionState, + BoxFuture, Clock, FailureSecretStore, InMemorySecretStore, NostrClient, RelayConfiguration, + RuntimeLifecycle, SecretStore, SecretStoreOperation, SessionState, }; use radroots_studio_domain::{ - Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SecretKeyInput, UnixTimestamp, + Kind0ProfileCandidate, PublicKey, RelayUrl, SafeError, SafeErrorCode, SecretKeyInput, + UnixTimestamp, }; use super::RuntimeActorHandle; @@ -1380,6 +1381,39 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] + async fn failed_generated_commit_consumes_the_stage_without_poisoning_the_actor() { + let secrets = Arc::new(FailureSecretStore::default()); + secrets.fail_next(SecretStoreOperation::Put); + let secret_port: Arc<dyn SecretStore> = secrets.clone(); + let actor = RuntimeActorHandle::in_memory( + RelayConfiguration::default(), + secret_port, + Arc::new(FixedClock), + Arc::new(OfflineNostr), + NonZeroUsize::new(8).expect("capacity"), + &tokio::runtime::Handle::current(), + ) + .expect("actor"); + let handle = actor + .begin_generated_key_stage() + .await + .expect("generated key stage"); + + let error = actor + .acknowledge_generated_key_stage(handle.id()) + .await + .expect_err("injected keyring failure"); + + assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); + assert!(actor.snapshot().accounts().is_empty()); + actor + .begin_generated_key_stage() + .await + .expect("fresh recovery after terminal failure"); + assert!(actor.cancel_generated_key_stage().await.expect("cancel")); + } + + #[tokio::test(flavor = "multi_thread")] async fn session_generation_cancels_correlated_profile_work_on_sign_out() { let client = Arc::new(BlockingNostr::new()); let actor = RuntimeActorHandle::in_memory(