sdk

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

commit 0a33a01d2ba94c67d19e2677819b1f29882ec679
parent a5518058061e5d4d7d04467df1fae4211f817dfe
Author: triesap <tyson@radroots.org>
Date:   Mon, 15 Jun 2026 16:41:07 -0700

sdk: add runtime relay and idempotency primitives

- add typed relay target policy and normalized target set contracts
- add bounded redacted SDK idempotency keys with SHA-256 derivation
- validate builder relays through the runtime target policy
- derive listing enqueue keys for deterministic outbox idempotency

Diffstat:
MCargo.lock | 2++
MCargo.toml | 2++
Mcrates/sdk/Cargo.toml | 4++++
Mcrates/sdk/examples/runtime_local.rs | 3++-
Mcrates/sdk/src/lib.rs | 7+++++++
Mcrates/sdk/src/listings_runtime.rs | 81++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcrates/sdk/src/runtime.rs | 14++++++++++++--
Acrates/sdk/src/runtime_targets.rs | 207+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/listings_runtime.rs | 45+++++++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/tests/runtime_foundation.rs | 116++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/sync_runtime.rs | 6++++--
11 files changed, 449 insertions(+), 38 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1976,6 +1976,7 @@ name = "radroots_sdk" version = "0.1.0" dependencies = [ "futures", + "hex", "nostr", "radroots_authority", "radroots_core", @@ -1996,6 +1997,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "sha2", "sqlx", "tempfile", "tokio", diff --git a/Cargo.toml b/Cargo.toml @@ -53,6 +53,7 @@ radroots_types = { path = "../lib/crates/types", version = "0.1.0-alpha.2", defa base64 = { version = "0.22", default-features = false, features = ["alloc"] } futures = { version = "0.3" } +hex = { version = "0.4", default-features = false, features = ["alloc"] } js-sys = { version = "0.3" } nostr = { version = "0.44.2" } reqwest = { version = "0.12", default-features = false, features = [ @@ -65,6 +66,7 @@ serde = { version = "1", default-features = false, features = [ ] } serde_json = { version = "1", default-features = false, features = ["alloc"] } serde-wasm-bindgen = { version = "0.6" } +sha2 = { version = "0.10", default-features = false } sqlx = { version = "0.8.6", default-features = false } tempfile = { version = "3" } tokio = { version = "1" } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -43,10 +43,12 @@ signer-adapters = [ runtime = [ "std", "serde_json", + "dep:hex", "dep:radroots_authority", "dep:radroots_event_store", "dep:radroots_outbox", "dep:radroots_relay_transport", + "dep:sha2", "radroots_authority/std", "radroots_event_store/sqlite", "radroots_event_store/runtime-tokio", @@ -76,6 +78,7 @@ reqwest = { workspace = true, optional = true, default-features = false, feature "json", "rustls-tls", ] } +hex = { workspace = true, optional = true } serde = { workspace = true, optional = true, default-features = false, features = [ "derive", "alloc", @@ -83,6 +86,7 @@ serde = { workspace = true, optional = true, default-features = false, features serde_json = { workspace = true, optional = true, default-features = false, features = [ "alloc", ] } +sha2 = { workspace = true, optional = true } [dev-dependencies] futures = { workspace = true } diff --git a/crates/sdk/examples/runtime_local.rs b/crates/sdk/examples/runtime_local.rs @@ -79,7 +79,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> { .build() .await?; let actor = RadrootsActorContext::test(SELLER, [RadrootsActorRole::Seller])?; - let request = ListingPublishRequest::new(sample_listing()).with_idempotency_key("example-1"); + let request = + ListingPublishRequest::new(sample_listing()).try_with_idempotency_key("example-1")?; let prepared = sdk.listings().prepare_publish(&actor, request.clone())?; let enqueue = sdk diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -45,6 +45,8 @@ mod receipt; #[cfg(feature = "runtime")] mod runtime; #[cfg(feature = "runtime")] +mod runtime_targets; +#[cfg(feature = "runtime")] mod sync_runtime; #[cfg(feature = "runtime")] @@ -71,6 +73,11 @@ pub use crate::runtime::{ RadrootsSdkStoragePaths, RadrootsSdkTimestamp, }; #[cfg(feature = "runtime")] +pub use crate::runtime_targets::{ + SDK_IDEMPOTENCY_KEY_MAX_LEN, SDK_RELAY_TARGET_MAX_COUNT, SdkIdempotencyKey, + SdkRelayTargetPolicy, SdkRelayTargetSet, +}; +#[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/listings_runtime.rs b/crates/sdk/src/listings_runtime.rs @@ -1,7 +1,8 @@ #[cfg(feature = "runtime")] use crate::{ ListingsClient, RadrootsSdkError, RadrootsSdkEventReference, RadrootsSdkLocalMutationReceipt, - RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, + RadrootsSdkRecoveryAction, RadrootsSdkTimestamp, SdkIdempotencyKey, SdkRelayTargetPolicy, + SdkRelayTargetSet, }; #[cfg(feature = "runtime")] use radroots_authority::{RadrootsActorContext, RadrootsEventSigner, sign_authorized_draft}; @@ -22,11 +23,14 @@ use radroots_trade::listing::{ }; #[cfg(feature = "runtime")] +const LISTING_PUBLISH_OPERATION_KIND: &str = "listing.publish.v1"; + +#[cfg(feature = "runtime")] #[derive(Clone, Debug)] pub struct ListingPublishRequest { pub listing: RadrootsListing, - pub target_relays: Option<Vec<String>>, - pub idempotency_key: Option<String>, + pub target_relays: Option<SdkRelayTargetSet>, + pub idempotency_key: Option<SdkIdempotencyKey>, } #[cfg(feature = "runtime")] @@ -39,19 +43,36 @@ impl ListingPublishRequest { } } - pub fn with_target_relays<I, S>(mut self, target_relays: I) -> Self + pub fn with_target_relays(mut self, target_relays: SdkRelayTargetSet) -> Self { + self.target_relays = Some(target_relays); + self + } + + pub fn try_with_target_relays<I, S>( + mut self, + target_relays: I, + policy: SdkRelayTargetPolicy, + ) -> Result<Self, RadrootsSdkError> where I: IntoIterator<Item = S>, - S: Into<String>, + S: AsRef<str>, { - self.target_relays = Some(target_relays.into_iter().map(Into::into).collect()); - self + self.target_relays = Some(SdkRelayTargetSet::new(target_relays, policy)?); + Ok(self) } - pub fn with_idempotency_key(mut self, idempotency_key: impl Into<String>) -> Self { + pub fn with_idempotency_key(mut self, idempotency_key: SdkIdempotencyKey) -> Self { self.idempotency_key = Some(idempotency_key.into()); self } + + pub fn try_with_idempotency_key( + mut self, + idempotency_key: impl AsRef<str>, + ) -> Result<Self, RadrootsSdkError> { + self.idempotency_key = Some(SdkIdempotencyKey::new(idempotency_key)?); + Ok(self) + } } #[cfg(feature = "runtime")] @@ -98,15 +119,19 @@ impl<'sdk> ListingsClient<'sdk> { where S: RadrootsEventSigner + ?Sized, { - let target_relays = self.resolved_target_relays(&request); - if target_relays.is_empty() { - return Err(RadrootsSdkError::Outbox { - message: "listing enqueue requires at least one target relay".to_owned(), - }); - } + let target_relays = self.resolved_target_relays(&request)?; let idempotency_key = request.idempotency_key.clone(); let prepared = self.prepare_publish(actor, request)?; let signed_event = sign_authorized_draft(actor, signer, &prepared.draft)?; + let idempotency_key = match idempotency_key { + Some(idempotency_key) => idempotency_key, + None => SdkIdempotencyKey::derive( + LISTING_PUBLISH_OPERATION_KIND, + prepared.draft.expected_event_id.as_str(), + prepared.draft.expected_pubkey.as_str(), + target_relays.relays(), + )?, + }; let observed_at_ms = i64::from(prepared.draft.created_at) * 1_000; let event = event_from_signed(&signed_event); let ingest = RadrootsEventIngest::new(event, observed_at_ms) @@ -115,7 +140,7 @@ impl<'sdk> ListingsClient<'sdk> { let outbox_input = signed_outbox_input( &prepared, signed_event.clone(), - target_relays, + target_relays.into_vec(), idempotency_key, ingest_receipt.inserted, observed_at_ms, @@ -151,11 +176,14 @@ impl<'sdk> ListingsClient<'sdk> { }) } - fn resolved_target_relays(&self, request: &ListingPublishRequest) -> Vec<String> { - request - .target_relays - .clone() - .unwrap_or_else(|| self.sdk.relay_urls().to_vec()) + fn resolved_target_relays( + &self, + request: &ListingPublishRequest, + ) -> Result<SdkRelayTargetSet, RadrootsSdkError> { + match request.target_relays.as_ref() { + Some(target_relays) => Ok(target_relays.clone()), + None => SdkRelayTargetSet::from_normalized_relays(self.sdk.relay_urls().to_vec()), + } } } @@ -173,23 +201,20 @@ fn signed_outbox_input( prepared: &PreparedListingPublish, signed_event: RadrootsSignedNostrEvent, target_relays: Vec<String>, - idempotency_key: Option<String>, + idempotency_key: SdkIdempotencyKey, event_store_inserted: bool, observed_at_ms: i64, ) -> RadrootsOutboxSignedOperationInput { - let input = RadrootsOutboxSignedOperationInput::new( - "listing.publish.v1", + RadrootsOutboxSignedOperationInput::new( + LISTING_PUBLISH_OPERATION_KIND, prepared.draft.clone(), signed_event, target_relays, event_store_inserted, observed_at_ms, observed_at_ms, - ); - match idempotency_key { - Some(idempotency_key) => input.with_idempotency_key(idempotency_key), - None => input, - } + ) + .with_idempotency_key(idempotency_key.into_string()) } #[cfg(feature = "runtime")] diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -1,6 +1,7 @@ #[cfg(feature = "runtime")] use crate::{ - ListingsClient, OrdersClient, RadrootsSdkError, SyncClient, error::RadrootsSdkRecoveryAction, + ListingsClient, OrdersClient, RadrootsSdkError, SdkRelayTargetPolicy, SdkRelayTargetSet, + SyncClient, error::RadrootsSdkRecoveryAction, }; #[cfg(feature = "runtime")] use radroots_event_store::RadrootsEventStore; @@ -88,6 +89,7 @@ pub struct RadrootsSdkBuilder { storage: RadrootsSdkStorageConfig, clock: RadrootsSdkClock, relay_urls: Vec<String>, + relay_target_policy: SdkRelayTargetPolicy, } #[cfg(feature = "runtime")] @@ -97,6 +99,7 @@ impl Default for RadrootsSdkBuilder { storage: RadrootsSdkStorageConfig::Memory, clock: RadrootsSdkClock::System, relay_urls: Vec::new(), + relay_target_policy: SdkRelayTargetPolicy::Public, } } } @@ -128,14 +131,21 @@ impl RadrootsSdkBuilder { self } + pub fn relay_target_policy(mut self, policy: SdkRelayTargetPolicy) -> Self { + self.relay_target_policy = policy; + self + } + pub async fn build(self) -> Result<RadrootsSdk, RadrootsSdkError> { let storage = open_storage(&self.storage).await?; + let relay_urls = + SdkRelayTargetSet::from_configured_relays(&self.relay_urls, self.relay_target_policy)?; Ok(RadrootsSdk { _event_store: storage.event_store, _outbox: storage.outbox, storage_paths: storage.paths, clock: self.clock, - relay_urls: self.relay_urls, + relay_urls, }) } } diff --git a/crates/sdk/src/runtime_targets.rs b/crates/sdk/src/runtime_targets.rs @@ -0,0 +1,207 @@ +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 SdkRelayTargetPolicy { + Public, + Localhost, +} + +impl SdkRelayTargetPolicy { + fn relay_transport_policy(self) -> RadrootsRelayUrlPolicy { + match self { + Self::Public => RadrootsRelayUrlPolicy::Public, + Self::Localhost => RadrootsRelayUrlPolicy::LocalDev, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SdkRelayTargetSet { + relays: Vec<String>, +} + +impl SdkRelayTargetSet { + pub fn new<I, S>(relays: I, policy: SdkRelayTargetPolicy) -> 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: SdkRelayTargetPolicy, + ) -> 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(invalid_request("relay target set must not be empty")); + } + if normalized.len() > SDK_RELAY_TARGET_MAX_COUNT { + return Err(invalid_request(format!( + "relay target set must contain at most {SDK_RELAY_TARGET_MAX_COUNT} relays" + ))); + } + 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: SdkRelayTargetPolicy, +) -> Result<String, RadrootsSdkError> { + let relay = RadrootsRelayUrl::parse(value, policy.relay_transport_policy()) + .map_err(|error| invalid_request(format!("invalid relay target: {error}")))?; + let normalized = relay.into_string(); + if normalized.starts_with("ws://") && !is_local_ws_relay(normalized.as_str()) { + return Err(invalid_request( + "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(), + } +} diff --git a/crates/sdk/tests/listings_runtime.rs b/crates/sdk/tests/listings_runtime.rs @@ -19,7 +19,7 @@ use radroots_events::{ use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState}; use radroots_sdk::{ ListingPublishRequest, RadrootsSdk, RadrootsSdkError, RadrootsSdkRecoveryAction, - RadrootsSdkTimestamp, + RadrootsSdkTimestamp, SdkRelayTargetPolicy, SdkRelayTargetSet, }; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -30,7 +30,9 @@ const LISTING_B_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAg"; const LISTING_C_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAw"; const LISTING_D_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABA"; const LISTING_E_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABQ"; +const LISTING_F_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAABg"; const RELAY: &str = "wss://relay.example.com"; +const RELAY_B: &str = "wss://relay-b.example.com"; #[derive(Clone)] struct FixtureSigner { @@ -194,7 +196,8 @@ async fn prepare_publish_is_side_effect_free() { async fn enqueue_publish_stores_event_and_queues_signed_outbox_without_publish() { let (_tempdir, sdk) = directory_sdk().await; let request = ListingPublishRequest::new(listing(LISTING_B_D_TAG, "Coffee")) - .with_idempotency_key("idem-b"); + .try_with_idempotency_key("idem-b") + .expect("idempotency key"); let prepared = sdk .listings() .prepare_publish(&actor(), request.clone()) @@ -258,14 +261,16 @@ async fn enqueue_publish_returns_sanitized_signer_errors() { async fn enqueue_publish_reports_partial_local_mutation_after_outbox_conflict() { let (_tempdir, sdk) = directory_sdk().await; let first = ListingPublishRequest::new(listing(LISTING_D_D_TAG, "Coffee")) - .with_idempotency_key("idem-d"); + .try_with_idempotency_key("idem-d") + .expect("idempotency key"); sdk.listings() .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), first) .await .expect("first enqueue"); let second = ListingPublishRequest::new(listing(LISTING_E_D_TAG, "Changed")) - .with_idempotency_key("idem-d"); + .try_with_idempotency_key("idem-d") + .expect("idempotency key"); let error = sdk .listings() .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), second) @@ -281,3 +286,35 @@ async fn enqueue_publish_reports_partial_local_mutation_after_outbox_conflict() )); assert!(!error.to_string().contains("idem-d")); } + +#[tokio::test] +async fn enqueue_publish_derives_order_independent_idempotency_key() { + let (_tempdir, sdk) = directory_sdk().await; + let first = ListingPublishRequest::new(listing(LISTING_F_D_TAG, "Coffee")) + .try_with_target_relays([RELAY_B, RELAY, RELAY], SdkRelayTargetPolicy::Public) + .expect("first target relays"); + let second = ListingPublishRequest::new(listing(LISTING_F_D_TAG, "Coffee")).with_target_relays( + SdkRelayTargetSet::new([RELAY, RELAY_B], SdkRelayTargetPolicy::Public) + .expect("second target relays"), + ); + + let first_receipt = sdk + .listings() + .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), first) + .await + .expect("first enqueue"); + let second_receipt = sdk + .listings() + .enqueue_publish(&actor(), &FixtureSigner::new(SELLER), second) + .await + .expect("second enqueue"); + + assert_eq!( + first_receipt.local.outbox_event_id, + second_receipt.local.outbox_event_id + ); + assert_eq!( + first_receipt.local.idempotency_key_digest_prefix, + second_receipt.local.idempotency_key_digest_prefix + ); +} diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -2,7 +2,8 @@ use radroots_sdk::{ RadrootsSdk, RadrootsSdkClock, RadrootsSdkError, RadrootsSdkRecoveryAction, - RadrootsSdkStorageConfig, RadrootsSdkTimestamp, + RadrootsSdkStorageConfig, RadrootsSdkTimestamp, SDK_IDEMPOTENCY_KEY_MAX_LEN, + SDK_RELAY_TARGET_MAX_COUNT, SdkIdempotencyKey, SdkRelayTargetPolicy, SdkRelayTargetSet, }; #[tokio::test] @@ -17,6 +18,63 @@ async fn sdk_builder_defaults_to_memory_storage_and_no_relays() { } #[tokio::test] +async fn sdk_builder_validates_configured_relay_targets() { + let sdk = RadrootsSdk::builder() + .relay_url(" wss://relay-b.example.com/ ") + .relay_url("wss://relay-a.example.com") + .relay_url("wss://relay-a.example.com") + .build() + .await + .expect("sdk"); + + assert_eq!( + sdk.relay_urls(), + &[ + "wss://relay-a.example.com".to_owned(), + "wss://relay-b.example.com".to_owned() + ] + ); +} + +#[tokio::test] +async fn sdk_builder_rejects_ws_relay_without_localhost_policy() { + let result = RadrootsSdk::builder() + .relay_url("ws://127.0.0.1:8080") + .build() + .await; + + assert!(matches!( + result, + Err(RadrootsSdkError::InvalidRequest { .. }) + )); +} + +#[tokio::test] +async fn sdk_builder_allows_only_local_ws_targets_with_localhost_policy() { + let sdk = RadrootsSdk::builder() + .relay_target_policy(SdkRelayTargetPolicy::Localhost) + .relay_url("ws://localhost:8080") + .relay_url("ws://127.0.0.1:8081") + .relay_url("ws://[::1]:8082") + .build() + .await + .expect("sdk"); + + assert_eq!(sdk.relay_urls().len(), 3); + + let result = RadrootsSdk::builder() + .relay_target_policy(SdkRelayTargetPolicy::Localhost) + .relay_url("ws://relay.example.com") + .build() + .await; + + assert!(matches!( + result, + Err(RadrootsSdkError::InvalidRequest { .. }) + )); +} + +#[tokio::test] async fn sdk_directory_storage_creates_deterministic_sqlite_files() { let tempdir = tempfile::tempdir().expect("tempdir"); let sdk = RadrootsSdk::builder() @@ -82,3 +140,59 @@ fn sdk_partial_local_mutation_error_is_sanitized() { assert!(!message.contains("raw")); assert!(!message.contains("idempotency-key")); } + +#[test] +fn relay_target_set_validates_normalizes_dedupes_sorts_and_caps() { + let targets = SdkRelayTargetSet::new( + [ + " wss://relay-b.example.com/ ", + "wss://relay-a.example.com", + "wss://relay-a.example.com", + ], + SdkRelayTargetPolicy::Public, + ) + .expect("targets"); + + assert_eq!( + targets.relays(), + &[ + "wss://relay-a.example.com".to_owned(), + "wss://relay-b.example.com".to_owned() + ] + ); + + assert!(matches!( + SdkRelayTargetSet::new(Vec::<String>::new(), SdkRelayTargetPolicy::Public), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + + let too_many = (0..=SDK_RELAY_TARGET_MAX_COUNT) + .map(|index| format!("wss://relay-{index}.example.com")) + .collect::<Vec<_>>(); + assert!(matches!( + SdkRelayTargetSet::new(too_many, SdkRelayTargetPolicy::Public), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); +} + +#[test] +fn idempotency_key_validation_is_bounded_and_debug_redacted() { + let key = SdkIdempotencyKey::new(" idem-a ").expect("key"); + assert_eq!(key.as_str(), "idem-a"); + let debug = format!("{key:?}"); + assert!(debug.contains("<redacted>")); + assert!(!debug.contains("idem-a")); + + assert!(matches!( + SdkIdempotencyKey::new(" "), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + SdkIdempotencyKey::new("idem\nbad"), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); + assert!(matches!( + SdkIdempotencyKey::new("x".repeat(SDK_IDEMPOTENCY_KEY_MAX_LEN + 1)), + Err(RadrootsSdkError::InvalidRequest { .. }) + )); +} diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -19,7 +19,7 @@ use radroots_relay_transport::{RadrootsMockRelayPublishAdapter, RadrootsRelayOut use radroots_sdk::{ ListingPublishRequest, PUSH_OUTBOX_DEFAULT_LIMIT, PUSH_OUTBOX_MAX_LIMIT, PushOutboxEventState, PushOutboxRelayOutcomeKind, PushOutboxRequest, RadrootsSdk, RadrootsSdkError, - RadrootsSdkTimestamp, + RadrootsSdkTimestamp, SdkRelayTargetPolicy, }; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -159,7 +159,9 @@ async fn enqueue_listing(sdk: &RadrootsSdk, d_tag: &str, title: &str, relays: &[ .enqueue_publish( &actor(), &FixtureSigner::new(SELLER), - ListingPublishRequest::new(listing(d_tag, title)).with_target_relays(relays.to_vec()), + ListingPublishRequest::new(listing(d_tag, title)) + .try_with_target_relays(relays, SdkRelayTargetPolicy::Public) + .expect("relay targets"), ) .await .expect("enqueue")