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:
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")