app

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

commit 7edee87e9b8c2d8fd7a99ef136258e9c5f7ea680
parent 0fe5cd201d4ee99006b6098bd7934cc046b08d6b
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 23:33:06 +0000

ffi: export idempotent request contexts

- accept caller-owned request identifiers revisions and deadlines
- route imports through durable actor requests
- replay completed requests with the original committed snapshot
- correlate validation and runtime failures to caller requests

Diffstat:
Mcore/crates/application/src/accounts.rs | 13+++++++++++++
Mcore/crates/ffi/src/commands.rs | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcore/crates/ffi/src/lib.rs | 5++++-
Mcore/crates/storage/src/runtime_actor.rs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
4 files changed, 204 insertions(+), 19 deletions(-)

diff --git a/core/crates/application/src/accounts.rs b/core/crates/application/src/accounts.rs @@ -142,6 +142,19 @@ impl AppCore { operations: &(impl DurableOperationRepository + ?Sized), clock: &(impl Clock + ?Sized), ) -> Result<ImportAccountReceipt, SafeError> { + if let Some(existing) = operations.load_durable_operation(request_id)? { + return if existing + .terminal() + .is_some_and(|receipt| receipt.outcome() == DurableTerminalOutcome::Completed) + { + accounts + .find_account(existing.account())? + .map(|account| ImportAccountReceipt { account }) + .ok_or_else(recovery_required) + } else { + Err(recovery_required()) + }; + } self.require_revision(expected_revision)?; let imported = import_secret(input)?; let (public_key, npub, secret) = imported.into_parts(); diff --git a/core/crates/ffi/src/commands.rs b/core/crates/ffi/src/commands.rs @@ -8,7 +8,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use radroots_studio_application::{ - Clock, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, + Clock, DurableRequestId, RelayRuntimeMode, RemovalConfirmationToken, SdkNostrClient, relay_configuration_from_environment, }; use radroots_studio_domain::{PublicKey, SafeError, SecretKeyInput, UnixTimestamp}; @@ -27,6 +27,21 @@ pub(crate) const ACTOR_MAILBOX_CAPACITY: usize = 64; pub const FFI_CONTRACT_MAJOR: u16 = 2; pub const FFI_CONTRACT_MINOR: u16 = 0; pub const FFI_CONTRACT_HASH: &str = "radroots-studio-native-v2-2026-08-03"; +const MAX_COMMAND_DEADLINE_MILLIS: u64 = 30_000; + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct RequestContextDto { + pub request_id: String, + pub expected_revision: u64, + pub deadline_millis: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] +pub struct AccountCommandReceiptDto { + pub request_id: String, + pub committed_revision: u64, + pub snapshot: AppSnapshotDto, +} #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct CompatibilityDescriptor { @@ -93,6 +108,20 @@ impl From<SafeError> for StudioError { } } +impl StudioError { + fn correlated(error: SafeError, correlation_id: &str) -> Self { + let (category, retryable, recovery_action) = error_policy(error.code()); + Self::Failure { + code: error.code().into(), + category, + retryable, + recovery_action, + correlation_id: Some(correlation_id.to_owned()), + safe_message: error.message().as_str().to_owned(), + } + } +} + #[derive(Clone, Debug, Eq, PartialEq, uniffi::Record)] pub struct GeneratedAccountDto { pub account: AccountDto, @@ -211,6 +240,43 @@ impl StudioAppCore { .map_err(StudioError::from) } + /// Imports or repairs an account using a caller-owned idempotency key. + /// + /// # Errors + /// + /// Returns a correlated validation, conflict, timeout, credential, or storage error. + pub async fn import_account_v2( + &self, + context: RequestContextDto, + secret_key: Vec<u8>, + ) -> Result<AccountCommandReceiptDto, StudioError> { + let request_id = DurableRequestId::parse(context.request_id.clone()) + .map_err(|error| StudioError::correlated(error, &context.request_id))?; + let timeout = command_timeout(context.deadline_millis, &context.request_id)?; + let input = SecretKeyInput::parse_bytes(secret_key) + .map_err(|error| StudioError::correlated(error, &context.request_id))?; + self.inner + .actor + .import_secret_key_request( + request_id, + radroots_studio_application::SnapshotRevision::from_value( + context.expected_revision, + ), + input, + timeout, + ) + .await + .map(|_| { + let snapshot = AppSnapshotDto::from(&self.inner.actor.snapshot()); + AccountCommandReceiptDto { + request_id: context.request_id.clone(), + committed_revision: snapshot.revision, + snapshot, + } + }) + .map_err(|error| StudioError::correlated(error, &context.request_id)) + } + /// Selects one saved account without activating it. /// /// # Errors @@ -401,6 +467,20 @@ fn parse_public_key(value: &str) -> Result<PublicKey, StudioError> { PublicKey::from_hex(value).map_err(StudioError::from) } +fn command_timeout(millis: u64, correlation_id: &str) -> Result<Duration, StudioError> { + if millis == 0 || millis > MAX_COMMAND_DEADLINE_MILLIS { + return Err(StudioError::Failure { + code: WireErrorCode::InvalidApplicationState, + category: WireErrorCategory::Input, + retryable: false, + recovery_action: WireRecoveryAction::None, + correlation_id: Some(correlation_id.to_owned()), + safe_message: "The command deadline is invalid.".to_owned(), + }); + } + Ok(Duration::from_millis(millis)) +} + pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new(); RUNTIME.get_or_init(|| { @@ -458,8 +538,8 @@ 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, RuntimeCore, StudioAppCore, SystemClock, compatibility_descriptor, - runtime, verify_compatibility, + FFI_CONTRACT_MINOR, RequestContextDto, RuntimeCore, StudioAppCore, SystemClock, + compatibility_descriptor, runtime, verify_compatibility, }; fn in_memory_core() -> Arc<StudioAppCore> { @@ -491,6 +571,30 @@ mod tests { assert_eq!(current.revision, 1); } + #[tokio::test] + async fn request_context_import_replays_one_committed_receipt() { + let core = in_memory_core(); + let initial = core.snapshot(); + let context = RequestContextDto { + request_id: "ffi-test-import-1".to_owned(), + expected_revision: initial.revision, + deadline_millis: 5_000, + }; + let secret = b"7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + let first = core + .import_account_v2(context.clone(), secret.to_vec()) + .await + .expect("first import"); + let replay = core + .import_account_v2(context, secret.to_vec()) + .await + .expect("replayed import"); + + assert_eq!(first, replay); + assert_eq!(first.snapshot.accounts.len(), 1); + assert_eq!(first.request_id, "ffi-test-import-1"); + } + #[test] fn compatibility_matrix_rejects_before_storage_mutation() { let actual = compatibility_descriptor(); diff --git a/core/crates/ffi/src/lib.rs b/core/crates/ffi/src/lib.rs @@ -4,7 +4,10 @@ mod commands; mod dto; mod observer; -pub use commands::{GeneratedAccountDto, RemovalRequest, StudioAppCore, StudioError}; +pub use commands::{ + AccountCommandReceiptDto, GeneratedAccountDto, RemovalRequest, RequestContextDto, + StudioAppCore, StudioError, +}; pub use dto::{ AccountDto, ActiveAccountDto, AppLifecycleDto, AppSnapshotDto, KeyAvailabilityDto, ProfileDto, ProfileLoadStateDto, RelayConnectionStateDto, SafeErrorDto, SessionStateDto, SignerKindDto, diff --git a/core/crates/storage/src/runtime_actor.rs b/core/crates/storage/src/runtime_actor.rs @@ -32,7 +32,11 @@ enum RuntimeCommand { BeginGeneratedKeyStage, AcknowledgeGeneratedKeyStage(RecoveryStageId), CancelGeneratedKeyStage, - ImportSecretKey(SecretKeyInput), + ImportSecretKey { + input: SecretKeyInput, + durable_request: Option<radroots_studio_application::DurableRequestId>, + durable_expected_revision: Option<u64>, + }, SelectAccount(PublicKey), ActivateAccount(PublicKey), SignOut, @@ -65,7 +69,7 @@ impl RuntimeCommand { Self::GenerateAccount | Self::BeginGeneratedKeyStage | Self::AcknowledgeGeneratedKeyStage(_) - | Self::ImportSecretKey(_) + | Self::ImportSecretKey { .. } | Self::ActivateAccount(_) | Self::ConfirmAccountRemoval(_) => RuntimeCommandClass::UseCredential, Self::SelectAccount(_) @@ -338,7 +342,46 @@ impl RuntimeActorHandle { input: SecretKeyInput, ) -> Result<ImportAccountReceipt, SafeError> { match self - .dispatch(RuntimeCommand::ImportSecretKey(input), None) + .dispatch( + RuntimeCommand::ImportSecretKey { + input, + durable_request: None, + durable_expected_revision: None, + }, + None, + ) + .await? + { + RuntimeCommandValue::Imported(receipt) => Ok(receipt), + _ => Err(invalid_actor_response()), + } + } + + /// Imports or repairs with a caller-owned durable request and deadline. + /// + /// # Errors + /// + /// Returns a safe validation, conflict, timeout, persistence, or actor error. + pub async fn import_secret_key_request( + &self, + request: radroots_studio_application::DurableRequestId, + expected_revision: SnapshotRevision, + input: SecretKeyInput, + timeout: Duration, + ) -> Result<ImportAccountReceipt, SafeError> { + let raw_request = self.next_request.fetch_add(1, Ordering::Relaxed); + let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; + match self + .dispatch_with_deadline( + RuntimeCommand::ImportSecretKey { + input, + durable_request: Some(request), + durable_expected_revision: Some(expected_revision.value()), + }, + None, + request_id, + Instant::now() + timeout, + ) .await? { RuntimeCommandValue::Imported(receipt) => Ok(receipt), @@ -544,7 +587,11 @@ impl RuntimeActorHandle { let request_id = RequestId::new(raw_request).ok_or_else(request_space_exhausted)?; match self .dispatch_with_deadline( - RuntimeCommand::ImportSecretKey(input), + RuntimeCommand::ImportSecretKey { + input, + durable_request: None, + durable_expected_revision: None, + }, None, request_id, Instant::now() + timeout, @@ -702,17 +749,16 @@ impl RuntimeActor { RuntimeCommand::CancelGeneratedKeyStage => Ok( RuntimeCommandValue::GeneratedKeyStageCancelled(self.generated_key_stage.cancel()), ), - RuntimeCommand::ImportSecretKey(input) => durable_request.and_then(|request| { - self.adapter - .import_secret_key_durable( - &request, - expected_revision, - input, - self.secrets.as_ref(), - self.clock.as_ref(), - ) - .map(RuntimeCommandValue::Imported) - }), + RuntimeCommand::ImportSecretKey { + input, + durable_request: caller_request, + durable_expected_revision, + } => self.import_secret_key_command( + input, + caller_request, + durable_request, + durable_expected_revision.unwrap_or(expected_revision), + ), RuntimeCommand::SelectAccount(public_key) => self .adapter .select_account(public_key) @@ -777,6 +823,25 @@ impl RuntimeActor { ))) } + fn import_secret_key_command( + &self, + input: SecretKeyInput, + caller_request: Option<radroots_studio_application::DurableRequestId>, + fallback_request: Result<radroots_studio_application::DurableRequestId, SafeError>, + expected_revision: u64, + ) -> Result<RuntimeCommandValue, SafeError> { + let request = caller_request.map_or(fallback_request, Ok)?; + self.adapter + .import_secret_key_durable( + &request, + expected_revision, + input, + self.secrets.as_ref(), + self.clock.as_ref(), + ) + .map(RuntimeCommandValue::Imported) + } + fn start_profile_task( &mut self, context: CommandContext,