sdk

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

commit 34281da7f4d67aae7c6cc8508ce784c5ad8e04ed
parent 9b45e557252f9db93d454a9f39bf3dd01b4f9a93
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 22:18:15 +0000

sync: scope push outbox publishes to active plans

- Map preview and deferred outbox states through SDK push receipts.
- Require active delivery plans for proxy policy and idempotency derivation.
- Reject mixed proxy target sets and non-Nostr daemon explicit targets.
- Add SDK regressions for sibling plans, proxy idempotency, and source guards.

Diffstat:
Mcrates/sdk/src/sync_runtime.rs | 102++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mcrates/sdk/src/transport.rs | 10++++++++++
Mcrates/sdk/tests/source_boundary.rs | 12++++++++++--
Mcrates/sdk/tests/sync_runtime.rs | 218++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 17+++++++++++++++++
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 56+++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/tests/unit/transport_tests.rs | 23+++++++++++++++++++++++
7 files changed, 401 insertions(+), 37 deletions(-)

diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -412,6 +412,8 @@ impl From<RadrootsOutboxEventState> for PushOutboxEventState { RadrootsOutboxEventState::Published => Self::Published, RadrootsOutboxEventState::SignRetryable => Self::SignRetryable, RadrootsOutboxEventState::PublishRetryable => Self::PublishRetryable, + RadrootsOutboxEventState::DeferredUntilImplemented => Self::DeferredUntilImplemented, + RadrootsOutboxEventState::PreviewUnavailable => Self::PreviewUnavailable, RadrootsOutboxEventState::FailedTerminal => Self::FailedTerminal, RadrootsOutboxEventState::Cancelled => Self::Cancelled, } @@ -759,7 +761,7 @@ async fn push_proxy_claimed_outbox_event( return fail_proxy_local_validation(sync, claimed, error, now_ms).await; } }; - let delivery_policy = match proxy_delivery_policy(sync, claimed, &target_policy).await { + let delivery_policy = match proxy_delivery_policy(sync, claimed).await { Ok(delivery_policy) => delivery_policy, Err(error) => { return fail_proxy_local_validation(sync, claimed, error, now_ms).await; @@ -782,6 +784,7 @@ async fn push_proxy_claimed_outbox_event( claimed.outbox_event_id, claimed.attempt_count, signed_event.id.as_str(), + active_delivery_plan_id(claimed, "radrootsd proxy publish")?, )), timeout_ms: adapter.config().request_timeout_ms, }; @@ -830,8 +833,8 @@ async fn fail_proxy_local_validation( async fn proxy_delivery_policy( sync: &SyncClient<'_>, claimed: &RadrootsOutboxClaimedEvent, - target_policy: &TransportPublishTargetPolicy, ) -> Result<TransportPublishDeliveryPolicy, RadrootsSdkError> { + let active_delivery_plan_id = active_delivery_plan_id(claimed, "radrootsd proxy publish")?; let plans = sync .sdk ._outbox @@ -839,39 +842,64 @@ async fn proxy_delivery_policy( .await?; let plan = plans .iter() - .find(|plan| { - claimed - .delivery_targets - .iter() - .any(|target| target.delivery_plan_id == plan.delivery_plan_id) - }) - .or_else(|| plans.first()) + .find(|plan| plan.delivery_plan_id == active_delivery_plan_id) .ok_or_else(|| RadrootsSdkError::InvalidRequest { message: format!( - "outbox event {} has no delivery plan for proxy publish", - claimed.outbox_event_id + "outbox event {} active delivery plan {} was not found for proxy publish", + claimed.outbox_event_id, active_delivery_plan_id ), })?; - proxy_delivery_policy_from_satisfaction( - target_policy.request_target_count(), + let targets = sync + .sdk + ._outbox + .delivery_targets(claimed.outbox_event_id) + .await?; + let active_targets = targets + .iter() + .filter(|target| target.delivery_plan_id == active_delivery_plan_id) + .collect::<Vec<_>>(); + let satisfied_count = active_targets + .iter() + .filter(|target| { + target + .status + .counts_as_transport_satisfaction(plan.satisfaction_policy.class()) + }) + .count(); + let ready_target_count = active_targets + .iter() + .filter(|target| target.status.is_ready_for_attempt()) + .count(); + let required_remaining = (plan.required_success_count as usize).saturating_sub(satisfied_count); + proxy_delivery_policy_from_remaining( + ready_target_count, + required_remaining, &plan.satisfaction_policy, ) } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] -fn proxy_delivery_policy_from_satisfaction( - target_count: usize, +fn proxy_delivery_policy_from_remaining( + ready_target_count: usize, + required_remaining: usize, satisfaction_policy: &RadrootsTransportSatisfactionPolicy, ) -> Result<TransportPublishDeliveryPolicy, RadrootsSdkError> { - if target_count == 0 { + if ready_target_count == 0 || required_remaining == 0 { return Ok(TransportPublishDeliveryPolicy::Any); } - let required = satisfaction_policy.required_target_count(target_count)?; Ok(match satisfaction_policy { RadrootsTransportSatisfactionPolicy::Any { .. } => TransportPublishDeliveryPolicy::Any, RadrootsTransportSatisfactionPolicy::All { .. } => TransportPublishDeliveryPolicy::All, RadrootsTransportSatisfactionPolicy::Quorum { .. } => { - TransportPublishDeliveryPolicy::Quorum { quorum: required } + if required_remaining >= ready_target_count { + TransportPublishDeliveryPolicy::All + } else if required_remaining == 1 { + TransportPublishDeliveryPolicy::Any + } else { + TransportPublishDeliveryPolicy::Quorum { + quorum: required_remaining, + } + } } }) } @@ -881,8 +909,26 @@ fn proxy_outbox_idempotency_key( outbox_event_id: i64, attempt_count: i64, event_id: &str, + active_delivery_plan_id: i64, ) -> String { - format!("radroots-sdk-outbox-{outbox_event_id}-{attempt_count}-{event_id}") + format!( + "radroots-sdk-outbox-{outbox_event_id}-{attempt_count}-{event_id}-{active_delivery_plan_id}" + ) +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn active_delivery_plan_id( + claimed: &RadrootsOutboxClaimedEvent, + operation: &'static str, +) -> Result<i64, RadrootsSdkError> { + claimed + .active_delivery_plan_id + .ok_or_else(|| RadrootsSdkError::InvalidRequest { + message: format!( + "outbox event {} has no active delivery plan for {operation}", + claimed.outbox_event_id + ), + }) } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] @@ -936,7 +982,18 @@ fn proxy_transport_publish_target_policy( .iter() .filter(|target| target.status.is_ready_for_attempt()) .collect::<Vec<_>>(); - if ready_targets.len() == 1 && is_proxy_delegate_target(ready_targets[0]) { + if ready_targets + .iter() + .any(|target| is_proxy_delegate_target(target)) + { + if ready_targets.len() != 1 || !is_proxy_delegate_target(ready_targets[0]) { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "radrootsd proxy outbox publish does not accept mixed proxy delegate targets for outbox event {}", + claimed.outbox_event_id + ), + }); + } Ok(TransportPublishTargetPolicy::nostr( NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, Vec::new(), @@ -955,10 +1012,11 @@ fn proxy_transport_publish_target_policy( fn transport_publish_target_from_outbox_target( target: &RadrootsOutboxDeliveryTargetRecord, ) -> Result<TransportPublishTarget, RadrootsSdkError> { - if target.transport_kind == RadrootsTransportKind::Reticulum { + if target.transport_kind != RadrootsTransportKind::Nostr { return Err(RadrootsSdkError::InvalidRequest { message: format!( - "radrootsd proxy outbox publish does not accept Reticulum target {}", + "radrootsd proxy outbox publish explicit targets are Nostr-only and cannot publish {} target {}", + target.transport_kind.canonical_label(), target.endpoint_uri.as_str() ), }); diff --git a/crates/sdk/src/transport.rs b/crates/sdk/src/transport.rs @@ -234,6 +234,16 @@ impl TargetSet { targets.len(), )); } + if targets + .iter() + .any(|target| target.kind == RadrootsTransportKind::Proxy) + && (targets.len() != 1 || targets[0].kind != RadrootsTransportKind::Proxy) + { + return Err(RadrootsSdkError::InvalidRequest { + message: "proxy transport targets must be the only target in a target set" + .to_owned(), + }); + } for target in &targets { if target.kind == RadrootsTransportKind::Reticulum && target.uri.as_str() != RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -1324,8 +1324,16 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() { ); assert!( sync_runtime_source - .contains("radrootsd proxy outbox publish does not accept Reticulum target"), - "src/sync_runtime.rs must reject Reticulum proxy outbox targets before behavior is lost" + .contains("radrootsd proxy outbox publish explicit targets are Nostr-only"), + "src/sync_runtime.rs must reject non-Nostr proxy outbox explicit targets before behavior is lost" + ); + assert!( + sync_runtime_source.contains("active_delivery_plan_id(claimed"), + "src/sync_runtime.rs must derive proxy publish behavior from the claimed active delivery plan" + ); + assert!( + sync_runtime_source.contains("mixed proxy delegate targets"), + "src/sync_runtime.rs must fail closed if proxy delegate targets are mixed in a claimed publish set" ); assert!( !sync_runtime_source.contains("TransportPublishPreviewBehavior::RejectDeliveryAttempts"), diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -16,7 +16,10 @@ use radroots_events::{ ids::{RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId}, listing::{RadrootsListing, RadrootsListingBin, RadrootsListingProduct}, }; -use radroots_outbox::{RadrootsOutbox, RadrootsOutboxEventState, RadrootsOutboxOperationInput}; +use radroots_outbox::{ + RadrootsOutbox, RadrootsOutboxDeliveryTargetStatus, RadrootsOutboxEventState, + RadrootsOutboxOperationInput, RadrootsOutboxSignedOperationInput, +}; #[cfg(feature = "radrootsd-proxy")] use radroots_sdk::ProxyProfile; use radroots_sdk::{ @@ -83,6 +86,8 @@ struct RecordingPublishAdapter { delay: Duration, raw_events: Arc<Mutex<Vec<String>>>, request_times_ms: Arc<Mutex<Vec<i64>>>, + idempotency_keys: Arc<Mutex<Vec<Option<String>>>>, + relay_batches: Arc<Mutex<Vec<Vec<String>>>>, } #[cfg(feature = "radrootsd-proxy")] @@ -287,6 +292,8 @@ impl RecordingPublishAdapter { delay, raw_events: Arc::new(Mutex::new(Vec::new())), request_times_ms: Arc::new(Mutex::new(Vec::new())), + idempotency_keys: Arc::new(Mutex::new(Vec::new())), + relay_batches: Arc::new(Mutex::new(Vec::new())), } } @@ -300,6 +307,17 @@ impl RecordingPublishAdapter { .expect("request time lock") .clone() } + + fn idempotency_keys(&self) -> Vec<Option<String>> { + self.idempotency_keys + .lock() + .expect("idempotency key lock") + .clone() + } + + fn relay_batches(&self) -> Vec<Vec<String>> { + self.relay_batches.lock().expect("relay batch lock").clone() + } } impl RadrootsRelayPublishAdapter for RecordingPublishAdapter { @@ -320,6 +338,14 @@ impl RadrootsRelayPublishAdapter for RecordingPublishAdapter { .lock() .expect("request time lock") .push(request.now_ms); + self.idempotency_keys + .lock() + .expect("idempotency key lock") + .push(request.idempotency_key.clone()); + self.relay_batches + .lock() + .expect("relay batch lock") + .push(request.targets.relay_strings()); Ok(request .targets .relays() @@ -1530,7 +1556,7 @@ async fn product_push_outbox_uses_radrootsd_proxy_transport_with_daemon_resolved body["params"]["target_policy"]["source_policy"], "request_then_author_write_then_daemon_default" ); - assert_eq!(body["params"]["delivery_policy"]["mode"], "any"); + assert_eq!(body["params"]["delivery_policy"]["mode"], "all"); assert!(body["params"]["event"]["sig"].as_str().is_some()); assert!(!recorded.body.contains("bridge.")); assert!(!recorded.body.contains("signer_session_id")); @@ -1574,6 +1600,15 @@ async fn product_push_outbox_radrootsd_proxy_idempotency_is_attempt_scoped() { ) .await .expect("enqueue"); + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + let plans = outbox + .delivery_plans(enqueue.outbox_event_id) + .await + .expect("plans"); + assert_eq!(plans.len(), 1); + let active_plan_id = plans[0].delivery_plan_id; let first = sdk .sync() @@ -1640,6 +1675,8 @@ async fn product_push_outbox_radrootsd_proxy_idempotency_is_attempt_scoped() { second_key .starts_with(format!("radroots-sdk-outbox-{}-2-", enqueue.outbox_event_id).as_str()) ); + assert!(first_key.ends_with(format!("-{active_plan_id}").as_str())); + assert!(second_key.ends_with(format!("-{active_plan_id}").as_str())); } #[cfg(feature = "radrootsd-proxy")] @@ -2044,6 +2081,183 @@ async fn push_outbox_with_adapter_uses_queued_targets_without_builder_relays() { } #[tokio::test] +async fn push_outbox_with_adapter_scopes_duplicate_endpoint_sibling_plans() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let storage = tempdir.path().join("sdk"); + let sdk = RadrootsClient::builder() + .directory_storage(storage.clone()) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .build() + .await + .expect("sdk"); + let first = sdk + .listings() + .enqueue_publish_with_explicit_signer( + ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Duplicate Plan Coffee"), + TargetPolicy::UseConfiguredProfile, + ) + .try_with_nostr_targets([RELAY_A], NostrRelayUrlPolicy::Public) + .expect("first targets") + .try_with_idempotency_key("sdk-duplicate-endpoint-plan") + .expect("first idempotency"), + &FixtureSigner::new(SELLER), + ) + .await + .expect("first enqueue"); + let outbox = RadrootsOutbox::open_file(&sdk.storage_paths().expect("paths").outbox_path) + .await + .expect("outbox"); + let stored_event = outbox + .get_event(first.outbox_event_id) + .await + .expect("stored event") + .expect("stored event"); + let signed_event = stored_event.signed_event.clone().expect("signed event"); + let second = outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + LISTING_PUBLISH_OPERATION_KIND, + stored_event.draft.clone(), + signed_event, + radroots_outbox::RadrootsOutboxDeliveryPlanInput::new( + "explicit.secondary", + 1, + radroots_transport::RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![ + radroots_transport::RadrootsTransportTarget::new( + radroots_transport::RadrootsTransportKind::Nostr, + RELAY_A, + ) + .expect("second target"), + ], + ), + true, + 1_700_000_000_000, + 1_700_000_000_000, + ) + .with_idempotency_key("sdk-duplicate-endpoint-plan"), + ) + .await + .expect("second plan"); + assert_eq!(second.outbox_event_id, first.outbox_event_id); + let plans = outbox + .delivery_plans(first.outbox_event_id) + .await + .expect("plans"); + assert_eq!(plans.len(), 2); + let first_plan_id = plans[0].delivery_plan_id; + let second_plan_id = plans[1].delivery_plan_id; + assert_ne!(first_plan_id, second_plan_id); + let event_before_push = outbox + .get_event(first.outbox_event_id) + .await + .expect("event before push") + .expect("event before push"); + assert_eq!(event_before_push.state, RadrootsOutboxEventState::Signed); + let targets_before_push = outbox + .delivery_targets(first.outbox_event_id) + .await + .expect("targets before push"); + assert_eq!( + targets_before_push + .iter() + .filter(|target| target.status == RadrootsOutboxDeliveryTargetStatus::Pending) + .count(), + 2 + ); + let adapter = RecordingPublishAdapter::new(Duration::ZERO); + + let first_receipt = sdk + .sync() + .push_outbox_with_adapter( + &adapter, + PushOutboxRequest::new() + .with_limit(1) + .with_next_attempt_delay_ms(1), + ) + .await + .expect("first push"); + + assert_eq!(first_receipt.attempted_events, 1); + assert_eq!( + first_receipt.events[0].final_state, + PushOutboxEventState::Published + ); + let targets_after_first = outbox + .delivery_targets(first.outbox_event_id) + .await + .expect("targets after first"); + assert_eq!( + targets_after_first + .iter() + .find(|target| target.delivery_plan_id == first_plan_id) + .expect("first target") + .status, + RadrootsOutboxDeliveryTargetStatus::Accepted + ); + assert_eq!( + targets_after_first + .iter() + .find(|target| target.delivery_plan_id == second_plan_id) + .expect("second target") + .status, + RadrootsOutboxDeliveryTargetStatus::Pending + ); + drop(sdk); + let sdk = RadrootsClient::builder() + .directory_storage(storage) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_001)) + .build() + .await + .expect("reopened sdk"); + + let second_receipt = sdk + .sync() + .push_outbox_with_adapter(&adapter, PushOutboxRequest::new().with_limit(1)) + .await + .expect("second push"); + + assert_eq!(second_receipt.attempted_events, 1); + assert_eq!( + second_receipt.events[0].final_state, + PushOutboxEventState::Published + ); + assert_eq!( + adapter.relay_batches(), + vec![vec![RELAY_A.to_owned()], vec![RELAY_A.to_owned()]] + ); + let keys = adapter.idempotency_keys(); + assert_eq!(keys.len(), 2); + let first_key = keys[0].as_deref().expect("first key"); + let second_key = keys[1].as_deref().expect("second key"); + assert_ne!(first_key, second_key); + assert!( + first_key.starts_with( + format!( + "radroots-nostr-outbox-{}-1-{}-", + first.outbox_event_id, + first.signed_event_id.as_str() + ) + .as_str() + ) + ); + assert!( + second_key.starts_with( + format!( + "radroots-nostr-outbox-{}-2-{}-", + first.outbox_event_id, + first.signed_event_id.as_str() + ) + .as_str() + ) + ); + assert!(first_key.ends_with(format!("-{first_plan_id}").as_str())); + assert!(second_key.ends_with(format!("-{second_plan_id}").as_str())); +} + +#[tokio::test] async fn push_outbox_with_adapter_can_publish_targeted_ready_event() { let (_tempdir, sdk) = directory_sdk(&[]).await; let older_outbox_event_id = diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -659,6 +659,23 @@ fn relay_proxy_target_conversion_rejects_reticulum_targets_before_behavior_loss( )); } +#[test] +fn relay_proxy_target_conversion_rejects_proxy_targets_before_daemon_explicit_target() { + let target = radroots_transport::RadrootsTransportTarget::new( + radroots_transport::RadrootsTransportKind::Proxy, + "http://127.0.0.1:8080/rpc", + ) + .expect("proxy target"); + + let error = transport_publish_target(&target).expect_err("proxy rejected"); + + assert!(matches!( + error, + radroots_transport_nostr::RadrootsRelayTransportError::Transport(message) + if message.contains("Nostr-only") && message.contains("proxy") + )); +} + #[tokio::test] async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() { let adapter = diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -1,6 +1,6 @@ #[cfg(feature = "radrootsd-proxy")] use super::{ - CLAIM_OWNER, complete_proxy_publish_attempt, proxy_delivery_policy_from_satisfaction, + CLAIM_OWNER, complete_proxy_publish_attempt, proxy_delivery_policy_from_remaining, proxy_error_message, proxy_outbox_idempotency_key, proxy_transport_error_job, push_proxy_claimed_outbox_event, push_proxy_event_receipt, transport_publish_target_from_outbox_target, @@ -574,7 +574,8 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() { assert_eq!(receipt.attempted_events, 0); assert_eq!( - proxy_delivery_policy_from_satisfaction( + proxy_delivery_policy_from_remaining( + 0, 0, &RadrootsTransportSatisfactionPolicy::all_accepted() ) @@ -582,7 +583,8 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() { TransportPublishDeliveryPolicy::Any ); assert_eq!( - proxy_delivery_policy_from_satisfaction( + proxy_delivery_policy_from_remaining( + 2, 2, &RadrootsTransportSatisfactionPolicy::all_accepted() ) @@ -590,16 +592,17 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() { TransportPublishDeliveryPolicy::All ); assert_eq!( - proxy_delivery_policy_from_satisfaction( + proxy_delivery_policy_from_remaining( 2, + 1, &RadrootsTransportSatisfactionPolicy::any_accepted() ) .expect("any-target proxy policy"), TransportPublishDeliveryPolicy::Any ); assert_eq!( - proxy_outbox_idempotency_key(7, 3, "event-id"), - "radroots-sdk-outbox-7-3-event-id" + proxy_outbox_idempotency_key(7, 3, "event-id", 5), + "radroots-sdk-outbox-7-3-event-id-5" ); let signed_event = ProxyFixtureSigner::new() @@ -645,7 +648,36 @@ fn proxy_outbox_target_conversion_rejects_reticulum_targets_before_behavior_loss error, RadrootsSdkError::InvalidRequest { message } if message.contains("radrootsd proxy outbox publish") - && message.contains("Reticulum target") + && message.contains("Nostr-only") + && message.contains("reticulum target") + )); +} + +#[cfg(feature = "radrootsd-proxy")] +#[test] +fn proxy_outbox_target_conversion_rejects_proxy_targets_before_daemon_explicit_target() { + let target = + RadrootsTransportTarget::new(RadrootsTransportKind::Proxy, "http://127.0.0.1:8080/rpc") + .expect("proxy target"); + let record = RadrootsOutboxDeliveryTargetRecord { + delivery_target_id: 1, + delivery_plan_id: 1, + transport_kind: target.kind.clone(), + endpoint_uri: target.uri.clone(), + endpoint_fingerprint: target.fingerprint.clone(), + status: RadrootsOutboxDeliveryTargetStatus::Pending, + attempt_count: 0, + last_attempt_at_ms: None, + completed_at_ms: None, + last_error: None, + }; + + let error = transport_publish_target_from_outbox_target(&record).expect_err("proxy rejected"); + + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { message } + if message.contains("Nostr-only") && message.contains("proxy target") )); } @@ -703,6 +735,7 @@ async fn proxy_push_reports_missing_signed_claim_before_daemon_publish() { attempt_count: 3, state: RadrootsOutboxEventState::Signed, claim_token: "claim-token".to_owned(), + active_delivery_plan_id: Some(1), draft: RadrootsFrozenEventDraft { contract_id: "radroots.test".to_owned(), contract_registry_version: 1, @@ -772,7 +805,8 @@ async fn proxy_local_validation_errors_release_claim_before_daemon_publish() { error, RadrootsSdkError::InvalidRequest { message } if message.contains("radrootsd proxy outbox publish") - && message.contains("Reticulum target") + && message.contains("Nostr-only") + && message.contains("reticulum target") )); let stored = sdk ._outbox @@ -788,7 +822,7 @@ async fn proxy_local_validation_errors_release_claim_before_daemon_publish() { .last_error .as_deref() .expect("last error") - .contains("Reticulum target") + .contains("reticulum target") ); } @@ -820,14 +854,14 @@ async fn proxy_completion_updates_outbox_for_success_retryable_and_terminal_rece ( "proxy-complete-deferred", PushOutboxEventState::DeferredUntilImplemented, - PushOutboxEventState::Signed, + PushOutboxEventState::DeferredUntilImplemented, RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented, TransportPublishOutcomeKind::DeferredUntilImplemented, ), ( "proxy-complete-preview-unavailable", PushOutboxEventState::PreviewUnavailable, - PushOutboxEventState::Signed, + PushOutboxEventState::PreviewUnavailable, RadrootsOutboxDeliveryTargetStatus::PreviewUnavailable, TransportPublishOutcomeKind::PreviewUnavailable, ), diff --git a/crates/sdk/tests/unit/transport_tests.rs b/crates/sdk/tests/unit/transport_tests.rs @@ -236,6 +236,29 @@ fn explicit_target_sets_reject_noncanonical_reticulum_preview_endpoints() { } #[test] +fn explicit_target_sets_reject_mixed_proxy_delegate_targets() { + let proxy = + RadrootsTransportTarget::new(RadrootsTransportKind::Proxy, "http://127.0.0.1:8080/rpc") + .expect("proxy target"); + let nostr = + RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, "wss://relay.example.com") + .expect("Nostr target"); + + let error = TargetSet::transport_targets(vec![proxy.clone(), nostr]) + .expect_err("mixed proxy target set"); + + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { ref message } + if message.contains("proxy transport targets must be the only target") + )); + + let proxy_only = TargetSet::transport_targets(vec![proxy]).expect("proxy-only target set"); + assert_eq!(proxy_only.len(), 1); + assert_eq!(proxy_only.targets()[0].kind, RadrootsTransportKind::Proxy); +} + +#[test] fn normalized_relays_reject_empty_and_over_limit_sets() { assert!(matches!( TargetSet::from_normalized_nostr_relays(Vec::new()),