radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 70fcd1a9a364f17cea4eb6de1cce50bed6ea32e3
parent de1db110066995cf8824b8e2419ec2854aae8031
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Jul 2026 08:09:36 +0000

transport: enforce required target delivery policy

- validate required target membership against resolved publish targets before job creation
- map daemon relay publish requests to exact required Nostr target fingerprints
- compute delivery status from required target outcomes instead of total acknowledgements
- cover optional success, optional retry, and stale required target daemon behavior

Diffstat:
MCargo.lock | 1+
Msrc/core/transport_publish.rs | 367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 335 insertions(+), 33 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1896,6 +1896,7 @@ version = "0.1.0-alpha.2" name = "radroots_transport" version = "0.1.0-alpha.2" dependencies = [ + "serde", "sha2", ] diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -17,8 +17,9 @@ use radroots_nostr::prelude::{ RadrootsNostrPublicKey, radroots_nostr_verify_event, }; use radroots_transport::{ - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportKind, - RadrootsTransportSatisfactionPolicy, + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, + RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, }; use radroots_transport_nostr::{ RadrootsNostrClientPublishAdapter, RadrootsRelayOutcome, RadrootsRelayOutcomeKind, @@ -255,6 +256,7 @@ impl TransportPublish { let resolution = self .resolve_targets_for_request(signed_event.pubkey.as_str(), &request) .await?; + validate_delivery_policy_for_resolution(&request.delivery_policy, &resolution)?; let target_snapshots = target_snapshots_from_resolution(&resolution); let response = self.store.record_publish_job(PublishJobInsert { principal_id: principal.principal_id.clone(), @@ -600,7 +602,11 @@ impl TransportPublish { ) -> Result<TransportPublishJobView, TransportPublishError> { let target_count = resolution.target_count(); if resolution.targets.is_empty() { - let status = no_publishable_target_status(&resolution.outcomes); + let status = if resolution.outcomes.is_empty() { + TransportPublishJobStatus::Rejected + } else { + delivery_status(&delivery_policy, target_count, &resolution.outcomes) + }; let last_error = last_error_for_status(status); self.store.complete_publish_job( job_id, @@ -630,8 +636,8 @@ impl TransportPublish { let satisfaction_policy = satisfaction_policy_from_delivery_policy( &delivery_policy, target_count, - resolution.targets.len(), - ); + resolution.targets.as_slice(), + )?; let publish_relays = target_set.relays().to_vec(); let publish_request = RadrootsRelayPublishRequest::new(signed_event, target_set, current_unix_millis()) @@ -854,6 +860,44 @@ impl PublishRelayResolution { fn target_count(&self) -> usize { self.targets.len() + self.outcomes.len() } + + fn target_fingerprints( + &self, + ) -> Result<Vec<RadrootsTransportTargetFingerprint>, TransportPublishError> { + let mut fingerprints = Vec::with_capacity(self.target_count()); + for target in &self.targets { + fingerprints.push(target.fingerprint()?); + } + for (index, outcome) in self.outcomes.iter().enumerate() { + fingerprints.push(target_outcome_fingerprint(outcome, index)?); + } + Ok(fingerprints) + } +} + +impl ResolvedPublishRelay { + fn fingerprint(&self) -> Result<RadrootsTransportTargetFingerprint, TransportPublishError> { + let scope = self + .target_scope + .as_deref() + .map(RadrootsTransportMeshScopeId::parse) + .transpose() + .map_err(|error| TransportPublishError::Transport(error.to_string()))?; + let label = self + .target_label + .as_deref() + .map(RadrootsTransportTargetLabel::parse) + .transpose() + .map_err(|error| TransportPublishError::Transport(error.to_string()))?; + let target = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Nostr, + self.url.as_str(), + scope, + label, + ) + .map_err(|error| TransportPublishError::Transport(error.to_string()))?; + Ok(target.fingerprint) + } } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -2679,19 +2723,126 @@ fn publish_outcome_kind(kind: RadrootsRelayOutcomeKind) -> TransportPublishOutco } } +fn target_outcome_fingerprint( + target: &TransportPublishTargetOutcome, + index: usize, +) -> Result<RadrootsTransportTargetFingerprint, TransportPublishError> { + let transport_kind = RadrootsTransportKind::parse_canonical(target.transport_kind.as_str()) + .map_err(|error| { + TransportPublishError::InvalidPublishJobState(format!( + "target outcome {index} has invalid transport kind: {error}" + )) + })?; + let scope = target + .target_scope + .as_deref() + .map(RadrootsTransportMeshScopeId::parse) + .transpose() + .map_err(|error| { + TransportPublishError::InvalidPublishJobState(format!( + "target outcome {index} has invalid target scope: {error}" + )) + })?; + let label = target + .target_label + .as_deref() + .map(RadrootsTransportTargetLabel::parse) + .transpose() + .map_err(|error| { + TransportPublishError::InvalidPublishJobState(format!( + "target outcome {index} has invalid target label: {error}" + )) + })?; + let target = RadrootsTransportTarget::new_with_metadata( + transport_kind, + target.endpoint_uri.as_str(), + scope, + label, + ) + .map_err(|error| { + TransportPublishError::InvalidPublishJobState(format!( + "target outcome {index} fingerprint failed: {error}" + )) + })?; + Ok(target.fingerprint) +} + +fn validate_delivery_policy_for_resolution( + delivery_policy: &TransportPublishDeliveryPolicy, + resolution: &PublishRelayResolution, +) -> Result<(), TransportPublishError> { + if !matches!( + delivery_policy, + TransportPublishDeliveryPolicy::RequiredTargets { .. } + ) { + return Ok(()); + } + let target_fingerprints = resolution.target_fingerprints()?; + delivery_policy + .validate_target_membership(target_fingerprints.as_slice()) + .map_err(|error| { + TransportPublishError::InvalidSignedEvent(format!( + "publish request delivery policy validation failed: {error}" + )) + }) +} + +fn required_outcomes_for_policy<'a>( + required_targets: &[RadrootsTransportTargetFingerprint], + outcomes: &'a [TransportPublishTargetOutcome], +) -> Vec<&'a TransportPublishTargetOutcome> { + required_targets + .iter() + .filter_map(|required| { + outcomes.iter().enumerate().find_map(|(index, outcome)| { + target_outcome_fingerprint(outcome, index) + .ok() + .filter(|fingerprint| fingerprint == required) + .map(|_| outcome) + }) + }) + .collect() +} + fn satisfaction_policy_from_delivery_policy( delivery_policy: &TransportPublishDeliveryPolicy, target_count: usize, - nostr_target_count: usize, -) -> RadrootsTransportSatisfactionPolicy { + nostr_targets: &[ResolvedPublishRelay], +) -> Result<RadrootsTransportSatisfactionPolicy, TransportPublishError> { match delivery_policy { - TransportPublishDeliveryPolicy::Any => RadrootsTransportSatisfactionPolicy::any_accepted(), - TransportPublishDeliveryPolicy::All => RadrootsTransportSatisfactionPolicy::all_accepted(), + TransportPublishDeliveryPolicy::Any => { + Ok(RadrootsTransportSatisfactionPolicy::any_accepted()) + } + TransportPublishDeliveryPolicy::All => { + Ok(RadrootsTransportSatisfactionPolicy::all_accepted()) + } TransportPublishDeliveryPolicy::Quorum { quorum } => { - let required = (*quorum).min(target_count).min(nostr_target_count).max(1); - RadrootsTransportSatisfactionPolicy::quorum_accepted( + let required = (*quorum).min(target_count).min(nostr_targets.len()).max(1); + Ok(RadrootsTransportSatisfactionPolicy::quorum_accepted( u16::try_from(required).unwrap_or(u16::MAX), - ) + )) + } + TransportPublishDeliveryPolicy::RequiredTargets { targets } => { + let nostr_required_targets = targets + .iter() + .filter_map(|required| { + nostr_targets.iter().find_map(|target| { + target + .fingerprint() + .ok() + .filter(|fingerprint| fingerprint == required) + }) + }) + .collect::<Vec<_>>(); + if nostr_required_targets.is_empty() { + Ok(RadrootsTransportSatisfactionPolicy::no_wait()) + } else { + Ok(RadrootsTransportSatisfactionPolicy::required_targets( + RadrootsTransportSatisfactionClass::Accepted, + nostr_required_targets, + ) + .map_err(|error| TransportPublishError::Transport(error.to_string()))?) + } } } } @@ -2701,30 +2852,48 @@ fn delivery_status( target_count: usize, outcomes: &[TransportPublishTargetOutcome], ) -> TransportPublishJobStatus { - let required = delivery_policy.required_target_count(target_count); - let acknowledged = outcomes - .iter() - .filter(|outcome| outcome.outcome_kind.counts_toward_satisfaction()) - .count(); - if acknowledged >= required { + let (satisfied, status_outcomes) = match delivery_policy { + TransportPublishDeliveryPolicy::RequiredTargets { targets } => { + let required_outcomes = required_outcomes_for_policy(targets, outcomes); + let satisfied = required_outcomes.len() == targets.len() + && required_outcomes + .iter() + .all(|outcome| outcome.outcome_kind.counts_toward_satisfaction()); + (satisfied, required_outcomes) + } + TransportPublishDeliveryPolicy::Any + | TransportPublishDeliveryPolicy::All + | TransportPublishDeliveryPolicy::Quorum { .. } => { + let required = delivery_policy.required_target_count(target_count); + let acknowledged = outcomes + .iter() + .filter(|outcome| outcome.outcome_kind.counts_toward_satisfaction()) + .count(); + ( + acknowledged >= required, + outcomes.iter().collect::<Vec<_>>(), + ) + } + }; + if satisfied { return TransportPublishJobStatus::DeliverySatisfied; } - if outcomes + if status_outcomes .iter() .any(|outcome| outcome.outcome_kind.is_retryable()) { TransportPublishJobStatus::DeliveryUnsatisfiedRetryable - } else if outcomes.iter().any(|outcome| { + } else if status_outcomes.iter().any(|outcome| { outcome.outcome_kind == TransportPublishOutcomeKind::DeferredUntilImplemented - }) && outcomes + }) && status_outcomes .iter() .all(|outcome| !outcome.outcome_kind.is_terminal_failure()) { TransportPublishJobStatus::DeliveryDeferred - } else if outcomes + } else if status_outcomes .iter() .any(|outcome| outcome.outcome_kind == TransportPublishOutcomeKind::PreviewUnavailable) - && outcomes + && status_outcomes .iter() .all(|outcome| !outcome.outcome_kind.is_terminal_failure()) { @@ -2734,15 +2903,6 @@ fn delivery_status( } } -fn no_publishable_target_status( - outcomes: &[TransportPublishTargetOutcome], -) -> TransportPublishJobStatus { - if outcomes.is_empty() { - return TransportPublishJobStatus::Rejected; - } - delivery_status(&TransportPublishDeliveryPolicy::Any, 1, outcomes) -} - fn last_error_for_status(status: TransportPublishJobStatus) -> Option<&'static str> { match status { TransportPublishJobStatus::DeliverySatisfied => None, @@ -2964,7 +3124,9 @@ mod tests { use radroots_nostr::prelude::{ RadrootsNostrEventVerification, RadrootsNostrTimestamp, radroots_nostr_build_event, }; - use radroots_transport::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; + use radroots_transport::{ + RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportKind, RadrootsTransportTarget, + }; use radroots_transport_nostr::{RadrootsMockRelayPublishAdapter, RadrootsRelayOutcome}; use radroots_transport_publish_protocol::{ NostrPublishTargetSourcePolicy, SignedNostrEventWire, TransportPublishDeliveryPolicy, @@ -5139,6 +5301,145 @@ mod tests { } #[tokio::test] + async fn publish_event_required_targets_do_not_count_optional_success() { + let identity = RadrootsIdentity::generate(); + let adapter = RadrootsMockRelayPublishAdapter::new() + .with_outcome( + RELAY_PRIMARY, + RadrootsRelayOutcome::classify("restricted: required relay rejected"), + ) + .with_outcome(RELAY_SECONDARY, RadrootsRelayOutcome::accepted()); + let (proxy, _) = transport_publish(config_with_defaults(vec![RELAY_PRIMARY])); + let proxy = proxy.with_publisher(Arc::new(adapter.clone())); + let principal = + explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); + let required_target = + RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, RELAY_PRIMARY) + .expect("required target"); + let request = TransportPublishEventRequest { + event: signed_event(&identity, "{}"), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr(RELAY_PRIMARY), + TransportPublishTarget::nostr(RELAY_SECONDARY), + ]), + delivery_policy: TransportPublishDeliveryPolicy::required_targets(vec![ + required_target.fingerprint, + ]) + .expect("required targets"), + idempotency_key: None, + timeout_ms: Some(5_000), + }; + + let response = proxy + .publish_event(&principal, request) + .await + .expect("publish"); + + assert_eq!( + response.job.status, + TransportPublishJobStatus::DeliveryUnsatisfiedTerminal + ); + assert!(!response.job.delivery_satisfied); + assert_eq!(response.job.acknowledged_count, 1); + assert_eq!(adapter.captured_raw_events().len(), 1); + response + .job + .validate() + .expect("valid required target terminal job"); + } + + #[tokio::test] + async fn publish_event_required_targets_ignore_optional_retryable_failures() { + let identity = RadrootsIdentity::generate(); + let adapter = RadrootsMockRelayPublishAdapter::new() + .with_outcome(RELAY_PRIMARY, RadrootsRelayOutcome::accepted()) + .with_outcome( + RELAY_SECONDARY, + RadrootsRelayOutcome::timeout("optional timeout"), + ); + let (proxy, _) = transport_publish(config_with_defaults(vec![RELAY_PRIMARY])); + let proxy = proxy.with_publisher(Arc::new(adapter.clone())); + let principal = + explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); + let required_target = + RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, RELAY_PRIMARY) + .expect("required target"); + let request = TransportPublishEventRequest { + event: signed_event(&identity, "{}"), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr(RELAY_PRIMARY), + TransportPublishTarget::nostr(RELAY_SECONDARY), + ]), + delivery_policy: TransportPublishDeliveryPolicy::required_targets(vec![ + required_target.fingerprint, + ]) + .expect("required targets"), + idempotency_key: None, + timeout_ms: Some(5_000), + }; + + let response = proxy + .publish_event(&principal, request) + .await + .expect("publish"); + + assert_eq!( + response.job.status, + TransportPublishJobStatus::DeliverySatisfied + ); + assert!(response.job.delivery_satisfied); + assert_eq!(response.job.acknowledged_count, 1); + assert_eq!(response.job.retryable_count, 1); + assert_eq!(adapter.captured_raw_events().len(), 1); + response + .job + .validate() + .expect("valid required target satisfied job"); + } + + #[tokio::test] + async fn publish_event_rejects_required_target_not_in_resolved_set() { + let identity = RadrootsIdentity::generate(); + let (proxy, adapter) = transport_publish(config_with_defaults(vec![RELAY_PRIMARY])); + let principal = + explicit_target_principal(&proxy, identity.public_key_hex(), PublishJobVisibility::Own); + let stale_target = + RadrootsTransportTarget::new(RadrootsTransportKind::Nostr, RELAY_SECONDARY) + .expect("stale target"); + let request = TransportPublishEventRequest { + event: signed_event(&identity, "{}"), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr(RELAY_PRIMARY), + ]), + delivery_policy: TransportPublishDeliveryPolicy::required_targets(vec![ + stale_target.fingerprint, + ]) + .expect("required targets"), + idempotency_key: None, + timeout_ms: Some(5_000), + }; + + let err = proxy + .publish_event(&principal, request) + .await + .expect_err("stale required target"); + + assert!(matches!( + err, + TransportPublishError::InvalidSignedEvent(ref message) + if message.contains("required target") + )); + assert!(adapter.captured_raw_events().is_empty()); + assert!( + proxy + .store + .list_jobs_for_principal(&principal, 10) + .expect("jobs") + .is_empty() + ); + } + + #[tokio::test] async fn publish_event_rejects_duplicate_explicit_targets_before_recording_job() { let identity = RadrootsIdentity::generate(); let (proxy, adapter) = transport_publish(config_with_defaults(vec![RELAY_PRIMARY]));