sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 9b7e4a4ef5fa07f6c2f4fe7c759a63800e503a69
parent 140ee41c9956bc1121f6d424342e4643e2af4c3e
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 22:37:58 -0700

sdk: split runtime target modules

Diffstat:
Acrates/sdk/src/idempotency.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/lib.rs | 17++++++++++-------
Acrates/sdk/src/relay_targets.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dcrates/sdk/src/runtime_targets.rs | 233-------------------------------------------------------------------------------
4 files changed, 245 insertions(+), 240 deletions(-)

diff --git a/crates/sdk/src/idempotency.rs b/crates/sdk/src/idempotency.rs @@ -0,0 +1,79 @@ +use crate::RadrootsSdkError; +use core::fmt; +use sha2::{Digest, Sha256}; + +pub const SDK_IDEMPOTENCY_KEY_MAX_LEN: usize = 256; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct SdkIdempotencyKey(String); + +impl SdkIdempotencyKey { + pub fn new(value: impl AsRef<str>) -> Result<Self, RadrootsSdkError> { + let value = value.as_ref().trim(); + if value.is_empty() { + return Err(invalid_request("idempotency key must not be empty")); + } + if value.len() > SDK_IDEMPOTENCY_KEY_MAX_LEN { + return Err(invalid_request(format!( + "idempotency key must be at most {SDK_IDEMPOTENCY_KEY_MAX_LEN} bytes" + ))); + } + if value.chars().any(char::is_control) { + return Err(invalid_request( + "idempotency key must not contain control characters", + )); + } + Ok(Self(value.to_owned())) + } + + pub fn as_str(&self) -> &str { + self.0.as_str() + } + + pub fn into_string(self) -> String { + self.0 + } + + pub(crate) fn derive( + operation_kind: &'static str, + expected_event_id: &str, + expected_pubkey: &str, + target_relays: &[String], + ) -> Result<Self, RadrootsSdkError> { + let input = SdkIdempotencyDerivationInput { + operation_kind, + expected_event_id, + expected_pubkey, + target_relays, + }; + let bytes = + serde_json::to_vec(&input).map_err(|error| RadrootsSdkError::InvalidRequest { + message: format!("idempotency derivation failed: {error}"), + })?; + let digest = hex::encode(Sha256::digest(bytes)); + Self::new(format!("{operation_kind}:{digest}")) + } +} + +impl fmt::Debug for SdkIdempotencyKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SdkIdempotencyKey") + .field("value", &"<redacted>") + .field("len", &self.0.len()) + .finish() + } +} + +#[derive(serde::Serialize)] +struct SdkIdempotencyDerivationInput<'a> { + operation_kind: &'static str, + expected_event_id: &'a str, + expected_pubkey: &'a str, + target_relays: &'a [String], +} + +fn invalid_request(message: impl Into<String>) -> RadrootsSdkError { + RadrootsSdkError::InvalidRequest { + message: message.into(), + } +} diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -16,6 +16,8 @@ pub mod config; #[cfg(feature = "runtime")] mod error; mod farm; +#[cfg(feature = "runtime")] +mod idempotency; #[cfg(feature = "identity-models")] mod identity; mod listing; @@ -29,9 +31,9 @@ mod product_clients; mod profile; pub mod protocol; #[cfg(feature = "runtime")] -mod runtime; +mod relay_targets; #[cfg(feature = "runtime")] -mod runtime_targets; +mod runtime; #[cfg(feature = "runtime")] mod sync_runtime; @@ -41,6 +43,8 @@ pub use crate::error::{ RadrootsSdkRecoveryAction, }; #[cfg(feature = "runtime")] +pub use crate::idempotency::{SDK_IDEMPOTENCY_KEY_MAX_LEN, SdkIdempotencyKey}; +#[cfg(feature = "runtime")] pub use crate::listings_runtime::{ ListingEnqueuePublishRequest, ListingEnqueueReceipt, ListingPreparePublishRequest, ListingPublishPlan, SdkMutationState, @@ -53,16 +57,15 @@ pub use crate::orders_runtime::{ }; #[cfg(feature = "runtime")] pub use crate::product_clients::{ListingsClient, OrdersClient, SyncClient}; +#[cfg(feature = "runtime")] +pub use crate::relay_targets::{ + SDK_RELAY_TARGET_MAX_COUNT, SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, +}; pub use crate::runtime::{ RadrootsSdk, RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, RadrootsSdkTimestamp, }; #[cfg(feature = "runtime")] -pub use crate::runtime_targets::{ - SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkIdempotencyKey, - SdkRelayTargetPolicy, SdkRelayTargetSet, SdkRelayUrlPolicy, -}; -#[cfg(feature = "runtime")] pub use crate::sync_runtime::{ PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind, PushOutboxRelayReceipt, PushOutboxRequest, diff --git a/crates/sdk/src/relay_targets.rs b/crates/sdk/src/relay_targets.rs @@ -0,0 +1,156 @@ +use crate::RadrootsSdkError; +use radroots_relay_transport::{RadrootsRelayUrl, RadrootsRelayUrlPolicy}; +use std::collections::BTreeSet; + +pub const SDK_RELAY_TARGET_MAX_COUNT: usize = 20; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SdkRelayUrlPolicy { + Public, + Localhost, +} + +impl SdkRelayUrlPolicy { + fn relay_transport_policy(self) -> RadrootsRelayUrlPolicy { + match self { + Self::Public => RadrootsRelayUrlPolicy::Public, + Self::Localhost => RadrootsRelayUrlPolicy::LocalDev, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SdkRelayTargetPolicy { + Explicit(SdkRelayTargetSet), + UseConfiguredRelays, +} + +impl SdkRelayTargetPolicy { + pub fn explicit(targets: SdkRelayTargetSet) -> Self { + Self::Explicit(targets) + } + + pub fn try_explicit<I, S>( + relays: I, + url_policy: SdkRelayUrlPolicy, + ) -> Result<Self, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + Ok(Self::Explicit(SdkRelayTargetSet::new(relays, url_policy)?)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRelayTargetSet { + relays: Vec<String>, +} + +impl SdkRelayTargetSet { + pub fn new<I, S>(relays: I, policy: SdkRelayUrlPolicy) -> Result<Self, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let mut normalized = BTreeSet::new(); + for relay in relays { + normalized.insert(normalized_relay_url(relay.as_ref(), policy)?); + } + Self::from_normalized_set(normalized) + } + + pub fn relays(&self) -> &[String] { + self.relays.as_slice() + } + + pub fn into_vec(self) -> Vec<String> { + self.relays + } + + pub fn len(&self) -> usize { + self.relays.len() + } + + pub fn is_empty(&self) -> bool { + self.relays.is_empty() + } + + pub(crate) fn from_configured_relays<I, S>( + relays: I, + policy: SdkRelayUrlPolicy, + ) -> Result<Vec<String>, RadrootsSdkError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + let relays = relays.into_iter().collect::<Vec<_>>(); + if relays.is_empty() { + return Ok(Vec::new()); + } + Ok(Self::new(relays, policy)?.into_vec()) + } + + pub(crate) fn from_normalized_relays(relays: Vec<String>) -> Result<Self, RadrootsSdkError> { + let normalized = relays.into_iter().collect::<BTreeSet<_>>(); + Self::from_normalized_set(normalized) + } + + fn from_normalized_set(normalized: BTreeSet<String>) -> Result<Self, RadrootsSdkError> { + if normalized.is_empty() { + return Err(RadrootsSdkError::empty_target_relays( + "sdk relay target set", + )); + } + if normalized.len() > SDK_RELAY_TARGET_MAX_COUNT { + return Err(RadrootsSdkError::relay_target_limit_exceeded( + SDK_RELAY_TARGET_MAX_COUNT, + normalized.len(), + )); + } + Ok(Self { + relays: normalized.into_iter().collect(), + }) + } +} + +fn normalized_relay_url( + value: &str, + policy: SdkRelayUrlPolicy, +) -> Result<String, RadrootsSdkError> { + let relay = RadrootsRelayUrl::parse(value, policy.relay_transport_policy())?; + let normalized = relay.into_string(); + if normalized.starts_with("ws://") && !is_local_ws_relay(normalized.as_str()) { + return Err(RadrootsSdkError::invalid_relay_url( + normalized, + "ws relay targets are limited to localhost, 127.0.0.1, or [::1]", + )); + } + Ok(normalized) +} + +fn is_local_ws_relay(value: &str) -> bool { + let Some(rest) = value.strip_prefix("ws://") else { + return false; + }; + let authority = rest + .split_once('/') + .map(|(authority, _)| authority) + .unwrap_or(rest); + let host = relay_authority_host(authority); + matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]")) +} + +fn relay_authority_host(authority: &str) -> Option<String> { + if let Some(after_open) = authority.strip_prefix('[') { + let close_index = after_open.find(']')?; + return Some(format!("[{}]", &after_open[..close_index])); + } + Some( + authority + .split_once(':') + .map(|(host, _)| host) + .unwrap_or(authority) + .to_owned(), + ) +} diff --git a/crates/sdk/src/runtime_targets.rs b/crates/sdk/src/runtime_targets.rs @@ -1,233 +0,0 @@ -use crate::RadrootsSdkError; -use core::fmt; -use radroots_relay_transport::{RadrootsRelayUrl, RadrootsRelayUrlPolicy}; -use sha2::{Digest, Sha256}; -use std::collections::BTreeSet; - -pub const SDK_RELAY_TARGET_MAX_COUNT: usize = 20; -pub const SDK_IDEMPOTENCY_KEY_MAX_LEN: usize = 256; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SdkRelayUrlPolicy { - Public, - Localhost, -} - -impl SdkRelayUrlPolicy { - fn relay_transport_policy(self) -> RadrootsRelayUrlPolicy { - match self { - Self::Public => RadrootsRelayUrlPolicy::Public, - Self::Localhost => RadrootsRelayUrlPolicy::LocalDev, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum SdkRelayTargetPolicy { - Explicit(SdkRelayTargetSet), - UseConfiguredRelays, -} - -impl SdkRelayTargetPolicy { - pub fn explicit(targets: SdkRelayTargetSet) -> Self { - Self::Explicit(targets) - } - - pub fn try_explicit<I, S>( - relays: I, - url_policy: SdkRelayUrlPolicy, - ) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - Ok(Self::Explicit(SdkRelayTargetSet::new(relays, url_policy)?)) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SdkRelayTargetSet { - relays: Vec<String>, -} - -impl SdkRelayTargetSet { - pub fn new<I, S>(relays: I, policy: SdkRelayUrlPolicy) -> Result<Self, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - let mut normalized = BTreeSet::new(); - for relay in relays { - normalized.insert(normalized_relay_url(relay.as_ref(), policy)?); - } - Self::from_normalized_set(normalized) - } - - pub fn relays(&self) -> &[String] { - self.relays.as_slice() - } - - pub fn into_vec(self) -> Vec<String> { - self.relays - } - - pub fn len(&self) -> usize { - self.relays.len() - } - - pub fn is_empty(&self) -> bool { - self.relays.is_empty() - } - - pub(crate) fn from_configured_relays<I, S>( - relays: I, - policy: SdkRelayUrlPolicy, - ) -> Result<Vec<String>, RadrootsSdkError> - where - I: IntoIterator<Item = S>, - S: AsRef<str>, - { - let relays = relays.into_iter().collect::<Vec<_>>(); - if relays.is_empty() { - return Ok(Vec::new()); - } - Ok(Self::new(relays, policy)?.into_vec()) - } - - pub(crate) fn from_normalized_relays(relays: Vec<String>) -> Result<Self, RadrootsSdkError> { - let normalized = relays.into_iter().collect::<BTreeSet<_>>(); - Self::from_normalized_set(normalized) - } - - fn from_normalized_set(normalized: BTreeSet<String>) -> Result<Self, RadrootsSdkError> { - if normalized.is_empty() { - return Err(RadrootsSdkError::empty_target_relays( - "sdk relay target set", - )); - } - if normalized.len() > SDK_RELAY_TARGET_MAX_COUNT { - return Err(RadrootsSdkError::relay_target_limit_exceeded( - SDK_RELAY_TARGET_MAX_COUNT, - normalized.len(), - )); - } - Ok(Self { - relays: normalized.into_iter().collect(), - }) - } -} - -#[derive(Clone, PartialEq, Eq, Hash)] -pub struct SdkIdempotencyKey(String); - -impl SdkIdempotencyKey { - pub fn new(value: impl AsRef<str>) -> Result<Self, RadrootsSdkError> { - let value = value.as_ref().trim(); - if value.is_empty() { - return Err(invalid_request("idempotency key must not be empty")); - } - if value.len() > SDK_IDEMPOTENCY_KEY_MAX_LEN { - return Err(invalid_request(format!( - "idempotency key must be at most {SDK_IDEMPOTENCY_KEY_MAX_LEN} bytes" - ))); - } - if value.chars().any(char::is_control) { - return Err(invalid_request( - "idempotency key must not contain control characters", - )); - } - Ok(Self(value.to_owned())) - } - - pub fn as_str(&self) -> &str { - self.0.as_str() - } - - pub fn into_string(self) -> String { - self.0 - } - - pub(crate) fn derive( - operation_kind: &'static str, - expected_event_id: &str, - expected_pubkey: &str, - target_relays: &[String], - ) -> Result<Self, RadrootsSdkError> { - let input = SdkIdempotencyDerivationInput { - operation_kind, - expected_event_id, - expected_pubkey, - target_relays, - }; - let bytes = - serde_json::to_vec(&input).map_err(|error| RadrootsSdkError::InvalidRequest { - message: format!("idempotency derivation failed: {error}"), - })?; - let digest = hex::encode(Sha256::digest(bytes)); - Self::new(format!("{operation_kind}:{digest}")) - } -} - -impl fmt::Debug for SdkIdempotencyKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("SdkIdempotencyKey") - .field("value", &"<redacted>") - .field("len", &self.0.len()) - .finish() - } -} - -#[derive(serde::Serialize)] -struct SdkIdempotencyDerivationInput<'a> { - operation_kind: &'static str, - expected_event_id: &'a str, - expected_pubkey: &'a str, - target_relays: &'a [String], -} - -fn normalized_relay_url( - value: &str, - policy: SdkRelayUrlPolicy, -) -> Result<String, RadrootsSdkError> { - let relay = RadrootsRelayUrl::parse(value, policy.relay_transport_policy())?; - let normalized = relay.into_string(); - if normalized.starts_with("ws://") && !is_local_ws_relay(normalized.as_str()) { - return Err(RadrootsSdkError::invalid_relay_url( - normalized, - "ws relay targets are limited to localhost, 127.0.0.1, or [::1]", - )); - } - Ok(normalized) -} - -fn is_local_ws_relay(value: &str) -> bool { - let Some(rest) = value.strip_prefix("ws://") else { - return false; - }; - let authority = rest - .split_once('/') - .map(|(authority, _)| authority) - .unwrap_or(rest); - let host = relay_authority_host(authority); - matches!(host.as_deref(), Some("localhost" | "127.0.0.1" | "[::1]")) -} - -fn relay_authority_host(authority: &str) -> Option<String> { - if let Some(after_open) = authority.strip_prefix('[') { - let close_index = after_open.find(']')?; - return Some(format!("[{}]", &after_open[..close_index])); - } - Some( - authority - .split_once(':') - .map(|(host, _)| host) - .unwrap_or(authority) - .to_owned(), - ) -} - -fn invalid_request(message: impl Into<String>) -> RadrootsSdkError { - RadrootsSdkError::InvalidRequest { - message: message.into(), - } -}