lib

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

commit 326b21c850fb83215a66ccee3b4385181743a71e
parent 519afbb6c25f02aa33b9f05f39639fb3459445ee
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 08:04:31 +0000

accounts: enforce exclusive generated recovery

- gate unrelated actor mutations while generated recovery is active
- cancel in-flight profile work when the recovery route begins
- preserve observation and shutdown access during exclusive custody
- return generated-recovery-specific expiration diagnostics

Diffstat:
Mcrates/studio_ffi/src/commands.rs | 32++++++++++++++++++++++++--------
Mcrates/studio_storage/src/runtime_actor.rs | 27+++++++++++++++++++++++++++
2 files changed, 51 insertions(+), 8 deletions(-)

diff --git a/crates/studio_ffi/src/commands.rs b/crates/studio_ffi/src/commands.rs @@ -284,7 +284,7 @@ impl StudioAppCore { request: Arc<GeneratedRecoveryRequest>, ) -> Result<AppSnapshotDto, StudioError> { if request.resolved.swap(true, Ordering::AcqRel) { - return Err(confirmation_expired()); + return Err(generated_recovery_expired()); } self.inner .actor @@ -606,6 +606,17 @@ fn confirmation_expired() -> StudioError { } } +fn generated_recovery_expired() -> StudioError { + StudioError::Failure { + code: WireErrorCode::InvalidApplicationState, + category: WireErrorCategory::Lifecycle, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: None, + safe_message: "The generated-key recovery step is no longer valid.".to_owned(), + } +} + fn compatibility_mismatch() -> StudioError { StudioError::Failure { code: WireErrorCode::CompatibilityMismatch, @@ -631,8 +642,9 @@ mod tests { use super::{ ACTOR_MAILBOX_CAPACITY, CompatibilityExpectation, DATABASE_APPLICATION, DATABASE_FILENAME, DATABASE_ORGANIZATION, DATABASE_QUALIFIER, FFI_CONTRACT_HASH, FFI_CONTRACT_MAJOR, - FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, SystemClock, - compatibility_descriptor, local_first_relay_configuration, runtime, verify_compatibility, + FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, StudioError, + SystemClock, compatibility_descriptor, local_first_relay_configuration, runtime, + verify_compatibility, }; fn in_memory_core() -> Arc<StudioAppCore> { @@ -707,11 +719,15 @@ mod tests { .await .expect("acknowledge"); assert_eq!(committed.accounts.len(), 1); - assert!( - core.acknowledge_generated_account_v2(recovery) - .await - .is_err() - ); + let repeated = core + .acknowledge_generated_account_v2(recovery) + .await + .expect_err("repeated acknowledgement"); + assert!(matches!( + repeated, + StudioError::Failure { safe_message, .. } + if safe_message == "The generated-key recovery step is no longer valid." + )); } #[test] diff --git a/crates/studio_storage/src/runtime_actor.rs b/crates/studio_storage/src/runtime_actor.rs @@ -669,7 +669,12 @@ impl RuntimeActor { | RuntimeCommand::SignOut | RuntimeCommand::ConfirmAccountRemoval(_) ); + let begins_generated_recovery = matches!(&command, RuntimeCommand::BeginGeneratedKeyStage); let result = self.execute_sync(context, command); + if begins_generated_recovery && matches!(&result, CommandResult::Completed(_)) { + let snapshot = self.adapter.core().snapshot(); + self.cancel_profile_tasks(Some(&snapshot)); + } if changes_session && matches!(result, CommandResult::Completed(_)) { self.advance_session_generation(); self.synchronize_foreground_session(); @@ -700,6 +705,19 @@ impl RuntimeActor { if !lifecycle.allows(command.class()) { return Some(CommandResult::Failed(command_unavailable())); } + if self.generated_key_stage.pending().is_some() + && !matches!( + command, + RuntimeCommand::Snapshot + | RuntimeCommand::AcknowledgeGeneratedKeyStage(_) + | RuntimeCommand::CancelGeneratedKeyStage + | RuntimeCommand::SubscribeChanges(_) + | RuntimeCommand::UnsubscribeChanges(_) + | RuntimeCommand::Close + ) + { + return Some(CommandResult::Failed(generated_recovery_route_active())); + } let current_revision = self.adapter.core().snapshot().revision(); if context .expected_revision() @@ -1092,6 +1110,13 @@ const fn command_unavailable() -> SafeError { ) } +const fn generated_recovery_route_active() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidApplicationState, + SafeMessage::new("Complete or cancel generated-key recovery before another action."), + ) +} + const fn invalid_actor_response() -> SafeError { SafeError::new( SafeErrorCode::InvalidApplicationState, @@ -1305,6 +1330,8 @@ mod tests { .contains(stage.view().account().public_key()) .expect("keyring") ); + assert!(actor.sign_out().await.is_err()); + assert_eq!(actor.snapshot(), initial); assert!(actor.cancel_generated_key_stage().await.expect("cancel")); assert!( !actor