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