sdk

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

commit aabb944db75811f33436084e98778729be8d844f
parent ec425bca2e9f23577f80056bd9b8230e44a0322a
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Jul 2026 00:42:38 +0000

sdk: validate radrootsd proxy responses

- validate typed proxy responses against the original publish request
- reject mismatched event identity, delivery policy, and target policy
- add Reticulum deferred and explicit-target mismatch coverage
- keep relay-bridge fixtures request-matched until bridge removal lands

Diffstat:
Mcrates/sdk/src/adapters/radrootsd.rs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 296++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 342 insertions(+), 13 deletions(-)

diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -95,13 +95,15 @@ impl RadrootsdProxyPublishAdapter { request .validate(SDK_RADROOTSD_PROXY_MAX_TARGETS) .map_err(RadrootsdError::from_protocol)?; - publish_event( + let response = publish_event( self.config.endpoint.as_str(), &self.config.auth, &request, self.config.timeout, ) - .await + .await?; + validate_transport_publish_response_for_request(&request, &response)?; + Ok(response) } } @@ -359,6 +361,59 @@ fn signed_event_wire(event: &RadrootsSignedNostrEvent) -> SignedNostrEventWire { } } +fn validate_transport_publish_response_for_request( + request: &TransportPublishEventRequest, + response: &TransportPublishEventResponse, +) -> Result<(), RadrootsdError> { + response.job.validate().map_err(|error| { + RadrootsdError::MalformedResponse(format!( + "radrootsd transport publish response invalid: {error}" + )) + })?; + if response.job.event_id != request.event.id { + return Err(response_mismatch("event_id")); + } + if response.job.pubkey != request.event.pubkey { + return Err(response_mismatch("pubkey")); + } + if response.job.event_kind != request.event.kind { + return Err(response_mismatch("event_kind")); + } + if response.job.delivery_policy != request.delivery_policy { + return Err(response_mismatch("delivery_policy")); + } + if response.job.target_policy != request.target_policy { + return Err(response_mismatch("target_policy")); + } + if let TransportPublishTargetPolicy::ExplicitTargets { targets } = &request.target_policy { + validate_explicit_response_targets(targets, response.job.targets.as_slice())?; + } + Ok(()) +} + +fn response_mismatch(field: &str) -> RadrootsdError { + RadrootsdError::MalformedResponse(format!( + "radrootsd transport publish response {field} does not match request" + )) +} + +fn validate_explicit_response_targets( + request_targets: &[TransportPublishTarget], + response_targets: &[TransportPublishTargetOutcome], +) -> Result<(), RadrootsdError> { + if request_targets.len() != response_targets.len() { + return Err(response_mismatch("explicit_targets")); + } + for (request_target, response_target) in request_targets.iter().zip(response_targets) { + if response_target.transport_kind != request_target.transport_kind + || response_target.endpoint_uri != request_target.endpoint_uri + { + return Err(response_mismatch("explicit_targets")); + } + } + Ok(()) +} + fn transport_publish_target( target: &RadrootsTransportTarget, ) -> Result<TransportPublishTarget, RadrootsRelayTransportError> { diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -123,12 +123,36 @@ fn publish_request() -> TransportPublishEventRequest { } } +fn job_status_for_outcome(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobStatus { + if outcome_kind.counts_toward_satisfaction() { + TransportPublishJobStatus::DeliverySatisfied + } else if outcome_kind.is_retryable() { + TransportPublishJobStatus::DeliveryUnsatisfiedRetryable + } else if outcome_kind.is_terminal_failure() { + TransportPublishJobStatus::DeliveryUnsatisfiedTerminal + } else if outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented { + TransportPublishJobStatus::DeliveryDeferred + } else if outcome_kind == TransportPublishOutcomeKind::PreviewUnavailable { + TransportPublishJobStatus::DeliveryPreviewUnavailable + } else { + TransportPublishJobStatus::DeliveryUnsatisfiedRetryable + } +} + fn job(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobView { + let status = job_status_for_outcome(outcome_kind); TransportPublishJobView { job_id: "job-1".to_owned(), - status: TransportPublishJobStatus::DeliverySatisfied, - terminal: true, - delivery_satisfied: true, + status, + terminal: matches!( + status, + TransportPublishJobStatus::DeliverySatisfied + | TransportPublishJobStatus::DeliveryUnsatisfiedTerminal + | TransportPublishJobStatus::DeliveryDeferred + | TransportPublishJobStatus::DeliveryPreviewUnavailable + | TransportPublishJobStatus::Rejected + ), + delivery_satisfied: status == TransportPublishJobStatus::DeliverySatisfied, event_id: "a".repeat(64), pubkey: "b".repeat(64), event_kind: 30_402, @@ -156,13 +180,112 @@ fn job(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobView { } } +fn explicit_nostr_job( + endpoints: Vec<String>, + delivery_policy: TransportPublishDeliveryPolicy, +) -> TransportPublishJobView { + let targets = endpoints + .iter() + .map(|endpoint| TransportPublishTargetOutcome { + transport_kind: "nostr".to_owned(), + endpoint_uri: endpoint.clone(), + source: TransportPublishTargetSource::Request, + attempted: true, + outcome_kind: TransportPublishOutcomeKind::Accepted, + message: Some("relay outcome".to_owned()), + latency_ms: Some(7), + }) + .collect::<Vec<_>>(); + TransportPublishJobView { + job_id: "job-explicit-nostr".to_owned(), + status: TransportPublishJobStatus::DeliverySatisfied, + terminal: true, + delivery_satisfied: true, + event_id: "a".repeat(64), + pubkey: "b".repeat(64), + event_kind: 30_402, + target_policy: TransportPublishTargetPolicy::explicit_targets( + endpoints + .iter() + .map(|endpoint| TransportPublishTarget::nostr(endpoint.as_str())) + .collect::<Vec<_>>(), + ), + delivery_policy, + target_count: targets.len(), + acknowledged_count: targets.len(), + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1_700_000_000_000, + completed_at_ms: Some(1_700_000_000_100), + last_error: None, + targets, + } +} + +fn reticulum_deferred_job() -> TransportPublishJobView { + TransportPublishJobView { + job_id: "job-reticulum".to_owned(), + status: TransportPublishJobStatus::DeliveryDeferred, + terminal: true, + delivery_satisfied: false, + event_id: "a".repeat(64), + pubkey: "b".repeat(64), + event_kind: 30_402, + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::reticulum_preview( + TransportPublishPreviewBehavior::DeferDeliveryPlans, + ), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + target_count: 1, + acknowledged_count: 0, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1_700_000_000_000, + completed_at_ms: Some(1_700_000_000_100), + last_error: Some("delivery_deferred_until_implemented".to_owned()), + targets: vec![TransportPublishTargetOutcome { + transport_kind: "reticulum".to_owned(), + endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(), + source: TransportPublishTargetSource::ReticulumPreview, + attempted: false, + outcome_kind: TransportPublishOutcomeKind::DeferredUntilImplemented, + message: Some("reticulum preview unavailable".to_owned()), + latency_ms: None, + }], + } +} + +fn explicit_nostr_response_json( + endpoints: Vec<String>, + delivery_policy: TransportPublishDeliveryPolicy, +) -> String { + publish_response_json_for_job(explicit_nostr_job(endpoints, delivery_policy)) +} + +fn publish_response_json_for_job(job: TransportPublishJobView) -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": SDK_RADROOTSD_PROXY_REQUEST_ID, + "result": { + "deduplicated": false, + "job": job + } + }) + .to_string() +} + fn publish_response_json() -> String { + publish_response_json_for_job(job(TransportPublishOutcomeKind::Accepted)) +} + +fn reticulum_deferred_response_json() -> String { serde_json::json!({ "jsonrpc": "2.0", "id": SDK_RADROOTSD_PROXY_REQUEST_ID, "result": { "deduplicated": false, - "job": job(TransportPublishOutcomeKind::Accepted) + "job": reticulum_deferred_job() } }) .to_string() @@ -449,7 +572,11 @@ async fn publish_event_posts_transport_publish_jsonrpc() { #[tokio::test] async fn publish_signed_event_posts_typed_proxy_request() { - let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str()); + let response_json = explicit_nostr_response_json( + vec!["wss://relay.example.com".to_owned()], + TransportPublishDeliveryPolicy::All, + ); + let (endpoint, handle) = spawn_http_server("200 OK", response_json.as_str()); let adapter = RadrootsdProxyPublishAdapter::new( RadrootsdProxyConfig::new(endpoint) .with_auth(RadrootsdAuth::BearerToken("sdk-token".into())) @@ -490,10 +617,11 @@ 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 response_json = reticulum_deferred_response_json(); + let (endpoint, handle) = spawn_http_server("200 OK", response_json.as_str()); let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); - adapter + let response = adapter .publish_signed_event(RadrootsdProxyPublishRequest { signed_event: signed_event(), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ @@ -507,6 +635,11 @@ async fn publish_signed_event_preserves_typed_reticulum_preview_behavior() { }) .await .expect("typed Reticulum publish request"); + assert_eq!( + response.job.status, + TransportPublishJobStatus::DeliveryDeferred + ); + assert!(!response.job.delivery_satisfied); let recorded = handle.join().expect("server thread"); let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); @@ -526,6 +659,147 @@ async fn publish_signed_event_preserves_typed_reticulum_preview_behavior() { } #[tokio::test] +async fn publish_signed_event_rejects_mismatched_daemon_event_identity() { + let mut response_job = job(TransportPublishOutcomeKind::Accepted); + response_job.event_id = "0".repeat(64); + let response_json = publish_response_json_for_job(response_job); + let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let error = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, + vec!["wss://relay.example.com".to_owned()], + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-mismatch".to_owned()), + timeout_ms: None, + }) + .await + .expect_err("mismatched response"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "event_id"); +} + +#[tokio::test] +async fn publish_signed_event_rejects_mismatched_daemon_pubkey_and_kind() { + for (field, response_job) in [ + { + let mut response_job = job(TransportPublishOutcomeKind::Accepted); + response_job.pubkey = "0".repeat(64); + ("pubkey", response_job) + }, + { + let mut response_job = job(TransportPublishOutcomeKind::Accepted); + response_job.event_kind = 30_403; + ("event_kind", response_job) + }, + ] { + let response_json = publish_response_json_for_job(response_job); + let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let error = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, + vec!["wss://relay.example.com".to_owned()], + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some(format!("idem-mismatch-{field}")), + timeout_ms: None, + }) + .await + .expect_err("mismatched response"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, field); + } +} + +#[tokio::test] +async fn publish_signed_event_rejects_mismatched_daemon_delivery_policy() { + let mut response_job = job(TransportPublishOutcomeKind::Accepted); + response_job.delivery_policy = TransportPublishDeliveryPolicy::All; + let response_json = publish_response_json_for_job(response_job); + let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let error = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, + vec!["wss://relay.example.com".to_owned()], + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-delivery-policy-mismatch".to_owned()), + timeout_ms: None, + }) + .await + .expect_err("delivery policy mismatch"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "delivery_policy"); +} + +#[tokio::test] +async fn publish_signed_event_rejects_mismatched_explicit_target_response() { + let (endpoint, _handle) = spawn_http_server("200 OK", publish_response_json().as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let error = 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-target-mismatch".to_owned()), + timeout_ms: None, + }) + .await + .expect_err("target mismatch"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "target_policy"); +} + +#[tokio::test] +async fn publish_signed_event_rejects_mismatched_explicit_target_outcomes() { + let mut response_job = explicit_nostr_job( + vec!["wss://relay.example.com".to_owned()], + TransportPublishDeliveryPolicy::Any, + ); + response_job.targets[0].endpoint_uri = "wss://relay-other.example.com".to_owned(); + let response_json = publish_response_json_for_job(response_job); + let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let error = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com"), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-explicit-outcome-mismatch".to_owned()), + timeout_ms: None, + }) + .await + .expect_err("explicit target outcome mismatch"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "explicit_targets"); +} + +#[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); @@ -581,14 +855,14 @@ async fn relay_publish_adapter_derives_delivery_policy_and_timeout() { TransportPublishDeliveryPolicy::Quorum { quorum: 2 }, ), ] { - let response_body = publish_response_json(); + let relays = (0..target_count) + .map(|index| format!("wss://relay-{index}.example.com")) + .collect::<Vec<_>>(); + let response_body = explicit_nostr_response_json(relays.clone(), expected_policy.clone()); let (endpoint, handle) = spawn_http_server("200 OK", response_body.as_str()); let adapter = RadrootsdProxyPublishAdapter::new( RadrootsdProxyConfig::new(endpoint).with_request_timeout_ms(4_000), ); - let relays = (0..target_count) - .map(|index| format!("wss://relay-{index}.example.com")) - .collect::<Vec<_>>(); let targets = RadrootsRelayTargetSet::new(&relays, RadrootsRelayUrlPolicy::Public).expect("targets");