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