commit d73d4647f35616987581e5bd9181cc5a766ec5c4
parent c3b2de702fd1a4893ea5e85175e2e0b78c4d10fe
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:
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()),