app

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

commit 74a3b1f6523eaa7388eadc3814cf3204d8323ba8
parent 0678e3748194843d941ac52fda442a6c8f4ffe62
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:
Mapp/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt | 11+++++++----
Mcore/crates/ffi/src/commands.rs | 19+++++++++++++++++--
Mcore/crates/storage/src/runtime_actor.rs | 40+++++++++++++++++++++++++++++++++++++---
3 files changed, 61 insertions(+), 9 deletions(-)

diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/application/StudioAppStoreTest.kt @@ -121,7 +121,10 @@ class StudioAppStoreTest { assertNull(store.state.value.generatedKeyBackup) assertTrue(gateway.lastGeneratedRecoveryTicket?.closed == true) assertEquals("fake-generated-request", store.state.value.lastCommandRequestId) - assertEquals("The generated account could not be saved.", store.state.value.problem) + assertEquals( + "The generated account could not be saved. Import the recovery key you saved to try again.", + store.state.value.problem, + ) store.close() } @@ -414,10 +417,10 @@ private class FakeGeneratedRecoveryTicket( StudioCommandFailure( WireErrorCode.KEYRING_UNAVAILABLE, WireErrorCategory.CREDENTIAL, - retryable = true, - WireRecoveryAction.RETRY, + retryable = false, + WireRecoveryAction.NONE, requestId, - "The generated account could not be saved.", + "The generated account could not be saved. Import the recovery key you saved to try again.", ), ) } diff --git a/core/crates/ffi/src/commands.rs b/core/crates/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/core/crates/storage/src/runtime_actor.rs b/core/crates/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(