sdk

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

commit 0455d7211ac01f0e8a6d15908285a90202bebc17
parent a97cf153fdfd4374945c0aa83aa7026d3be4e425
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 19:40:19 +0000

sync: harden radrootsd proxy target boundaries

- make proxy relay target conversion explicitly Nostr-only
- reject Reticulum outbox proxy targets before behavior can be rewritten
- preserve typed direct Reticulum proxy requests with explicit preview behavior
- add source-boundary and unit coverage for proxy behavior loss guards

Diffstat:
Mcrates/sdk/src/adapters/radrootsd.rs | 29++++++++++++++++++-----------
Mcrates/sdk/src/sync_runtime.rs | 36+++++++++++++++++++-----------------
Mcrates/sdk/tests/source_boundary.rs | 23+++++++++++++++++++++++
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 42++++++++++++++++++++++++++++++++++++++++--
5 files changed, 159 insertions(+), 34 deletions(-)

diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -13,8 +13,8 @@ use radroots_transport_nostr::{ use radroots_transport_publish_protocol::{ METHOD_EVENT, SignedNostrEventWire, TransportPublishDeliveryPolicy, TransportPublishEventRequest, TransportPublishEventResponse, TransportPublishOutcomeKind, - TransportPublishPreviewBehavior, TransportPublishProtocolError, TransportPublishTarget, - TransportPublishTargetOutcome, TransportPublishTargetPolicy, + TransportPublishProtocolError, TransportPublishTarget, TransportPublishTargetOutcome, + TransportPublishTargetPolicy, }; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; @@ -127,7 +127,10 @@ impl RadrootsRelayPublishAdapter for RadrootsdProxyPublishAdapter { )?, signed_event: request.signed_event, target_policy: TransportPublishTargetPolicy::explicit_targets( - targets.iter().map(transport_publish_target).collect(), + targets + .iter() + .map(transport_publish_target) + .collect::<Result<Vec<_>, _>>()?, ), idempotency_key: None, timeout_ms: self.config.request_timeout_ms, @@ -356,16 +359,20 @@ fn signed_event_wire(event: &RadrootsSignedNostrEvent) -> SignedNostrEventWire { } } -fn transport_publish_target(target: &RadrootsTransportTarget) -> TransportPublishTarget { - TransportPublishTarget { +fn transport_publish_target( + target: &RadrootsTransportTarget, +) -> Result<TransportPublishTarget, RadrootsRelayTransportError> { + if target.kind != RadrootsTransportKind::Nostr { + return Err(RadrootsRelayTransportError::Transport(format!( + "radrootsd proxy relay adapter is Nostr-only and cannot publish {} targets", + target.kind.canonical_label() + ))); + } + Ok(TransportPublishTarget { transport_kind: target.kind.canonical_label(), endpoint_uri: target.uri.as_str().to_owned(), - preview_behavior: if target.kind == RadrootsTransportKind::Reticulum { - Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts) - } else { - None - }, - } + preview_behavior: None, + }) } fn delivery_policy_from_relay_request( diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -762,7 +762,7 @@ async fn push_proxy_claimed_outbox_event( now_ms, ) .await?; - let target_policy = proxy_transport_publish_target_policy(claimed); + let target_policy = proxy_transport_publish_target_policy(claimed)?; let request = RadrootsdProxyPublishRequest { signed_event: signed_event.clone(), delivery_policy: proxy_delivery_policy(sync, claimed, &target_policy).await?, @@ -899,42 +899,44 @@ async fn complete_proxy_publish_attempt( #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] fn proxy_transport_publish_target_policy( claimed: &RadrootsOutboxClaimedEvent, -) -> TransportPublishTargetPolicy { +) -> Result<TransportPublishTargetPolicy, RadrootsSdkError> { let ready_targets = claimed .delivery_targets .iter() .filter(|target| target.status.is_ready_for_attempt()) .collect::<Vec<_>>(); if ready_targets.len() == 1 && is_proxy_delegate_target(ready_targets[0]) { - TransportPublishTargetPolicy::nostr( + Ok(TransportPublishTargetPolicy::nostr( NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, Vec::new(), - ) + )) } else { - TransportPublishTargetPolicy::explicit_targets( + Ok(TransportPublishTargetPolicy::explicit_targets( ready_targets .into_iter() .map(transport_publish_target_from_outbox_target) - .collect(), - ) + .collect::<Result<Vec<_>, _>>()?, + )) } } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] fn transport_publish_target_from_outbox_target( target: &RadrootsOutboxDeliveryTargetRecord, -) -> TransportPublishTarget { - TransportPublishTarget { +) -> Result<TransportPublishTarget, RadrootsSdkError> { + if target.transport_kind == RadrootsTransportKind::Reticulum { + return Err(RadrootsSdkError::InvalidRequest { + message: format!( + "radrootsd proxy outbox publish does not accept Reticulum target {}", + target.endpoint_uri.as_str() + ), + }); + } + Ok(TransportPublishTarget { transport_kind: target.transport_kind.canonical_label(), endpoint_uri: target.endpoint_uri.as_str().to_owned(), - preview_behavior: if target.transport_kind == RadrootsTransportKind::Reticulum { - Some( - radroots_transport_publish_protocol::TransportPublishPreviewBehavior::RejectDeliveryAttempts, - ) - } else { - None - }, - } + preview_behavior: None, + }) } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -1322,6 +1322,29 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() { sync_runtime_source.contains("target.transport_kind == RadrootsTransportKind::Proxy"), "src/sync_runtime.rs must identify proxy delegate targets with RadrootsTransportKind::Proxy" ); + 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" + ); + assert!( + !sync_runtime_source.contains("TransportPublishPreviewBehavior::RejectDeliveryAttempts"), + "src/sync_runtime.rs must not rewrite Reticulum proxy outbox targets to reject attempts" + ); + + let adapter_source = read_source(manifest_dir.join("src/adapters/radrootsd.rs").as_path()); + assert!( + adapter_source.contains("target.kind != RadrootsTransportKind::Nostr"), + "src/adapters/radrootsd.rs must keep the relay proxy adapter Nostr-only" + ); + assert!( + adapter_source.contains("radrootsd proxy relay adapter is Nostr-only"), + "src/adapters/radrootsd.rs must return a typed Nostr-only proxy adapter error" + ); + assert!( + !adapter_source.contains("TransportPublishPreviewBehavior::RejectDeliveryAttempts"), + "src/adapters/radrootsd.rs must not rewrite Reticulum relay targets to reject attempts" + ); for required in [ "TransportPublishOutcomeKind::DeferredUntilImplemented", diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -3,10 +3,11 @@ use radroots_transport_nostr::{ RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayUrlPolicy, }; use radroots_transport_publish_protocol::{ - NostrPublishTargetSourcePolicy, TransportPublishDeliveryPolicy, TransportPublishEventRequest, - TransportPublishEventResponse, TransportPublishJobStatus, TransportPublishJobView, - TransportPublishOutcomeKind, TransportPublishTarget, TransportPublishTargetOutcome, - TransportPublishTargetPolicy, TransportPublishTargetSource, + NostrPublishTargetSourcePolicy, RETICULUM_PREVIEW_ENDPOINT_URI, TransportPublishDeliveryPolicy, + TransportPublishEventRequest, TransportPublishEventResponse, TransportPublishJobStatus, + TransportPublishJobView, TransportPublishOutcomeKind, TransportPublishPreviewBehavior, + TransportPublishTarget, TransportPublishTargetOutcome, TransportPublishTargetPolicy, + TransportPublishTargetSource, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -488,6 +489,43 @@ async fn publish_signed_event_posts_typed_proxy_request() { } #[tokio::test] +async fn publish_signed_event_preserves_typed_reticulum_preview_behavior() { + let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::reticulum_preview( + TransportPublishPreviewBehavior::DeferDeliveryPlans, + ), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-reticulum".to_owned()), + timeout_ms: None, + }) + .await + .expect("typed Reticulum publish request"); + + let recorded = handle.join().expect("server thread"); + let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); + assert_eq!(body["params"]["target_policy"]["kind"], "explicit_targets"); + assert_eq!( + body["params"]["target_policy"]["targets"][0]["transport_kind"], + "reticulum" + ); + assert_eq!( + body["params"]["target_policy"]["targets"][0]["endpoint_uri"], + RETICULUM_PREVIEW_ENDPOINT_URI + ); + assert_eq!( + body["params"]["target_policy"]["targets"][0]["preview_behavior"], + "defer_delivery_plans" + ); +} + +#[tokio::test] async fn publish_event_http_errors_omit_body_and_token_material() { let body = "{\"error\":\"token-secret content carrots\"}"; let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body); @@ -604,6 +642,23 @@ async fn relay_publish_adapter_maps_proxy_errors_to_transport_errors() { )); } +#[test] +fn relay_proxy_target_conversion_rejects_reticulum_targets_before_behavior_loss() { + let target = radroots_transport::RadrootsTransportTarget::new( + radroots_transport::RadrootsTransportKind::Reticulum, + RETICULUM_PREVIEW_ENDPOINT_URI, + ) + .expect("Reticulum target"); + + let error = transport_publish_target(&target).expect_err("Reticulum rejected"); + + assert!(matches!( + error, + radroots_transport_nostr::RadrootsRelayTransportError::Transport(message) + if message.contains("Nostr-only") && message.contains("reticulum") + )); +} + #[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 @@ -3,6 +3,7 @@ use super::{ CLAIM_OWNER, complete_proxy_publish_attempt, proxy_delivery_policy_from_satisfaction, 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, }; use super::{ PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxTargetOutcomeKind, @@ -36,10 +37,15 @@ use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft, }; #[cfg(feature = "radrootsd-proxy")] -use radroots_outbox::{RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetStatus}; +use radroots_outbox::{ + RadrootsOutboxClaimedEvent, RadrootsOutboxDeliveryTargetRecord, + RadrootsOutboxDeliveryTargetStatus, +}; use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary}; #[cfg(feature = "radrootsd-proxy")] -use radroots_transport::RadrootsTransportSatisfactionPolicy; +use radroots_transport::{ + RadrootsTransportKind, RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, +}; use radroots_transport_nostr::{ RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError, @@ -611,6 +617,38 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() { } #[cfg(feature = "radrootsd-proxy")] +#[test] +fn proxy_outbox_target_conversion_rejects_reticulum_targets_before_behavior_loss() { + let target = RadrootsTransportTarget::new( + RadrootsTransportKind::Reticulum, + "reticulum:preview-unavailable", + ) + .expect("Reticulum 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("Reticulum rejected"); + + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { message } + if message.contains("radrootsd proxy outbox publish") + && message.contains("Reticulum target") + )); +} + +#[cfg(feature = "radrootsd-proxy")] #[tokio::test] async fn proxy_push_entrypoints_report_request_clock_and_claim_errors() { let adapter =