commit 136dc78342556c2b61a5fa23f69714901076e5b6
parent 6fe7f07b6e6c11b5a1520f0c651decac5a09030e
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Jul 2026 00:26:42 +0000
transport_publish_protocol: add scoped target metadata
- bump the daemon publish protocol contract to v4
- carry target scope and label on request targets and target outcomes
- validate scope and label syntax through shared transport metadata types
- match explicit outcomes by transport kind, endpoint URI, and scope
Diffstat:
1 file changed, 288 insertions(+), 13 deletions(-)
diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs
@@ -12,11 +12,11 @@ use std::{string::String, vec::Vec};
use core::fmt;
use radroots_transport::{
RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE,
- RadrootsTransportError, RadrootsTransportKind, RadrootsTransportTarget,
- RadrootsTransportTargetFingerprint,
+ RadrootsTransportError, RadrootsTransportKind, RadrootsTransportMeshScopeId,
+ RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel,
};
-pub const API_VERSION: &str = "radrootsd.transport_publish.v3";
+pub const API_VERSION: &str = "radrootsd.transport_publish.v4";
pub const DAEMON_NAME: &str = "radrootsd";
pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities";
pub const METHOD_EVENT: &str = "transport.publish.event";
@@ -46,6 +46,18 @@ pub enum TransportPublishProtocolError {
InvalidEndpointUri {
index: usize,
},
+ EmptyTargetScope {
+ index: usize,
+ },
+ InvalidTargetScope {
+ index: usize,
+ },
+ EmptyTargetLabel {
+ index: usize,
+ },
+ InvalidTargetLabel {
+ index: usize,
+ },
InvalidPreviewBehavior {
index: usize,
},
@@ -128,6 +140,18 @@ impl fmt::Display for TransportPublishProtocolError {
Self::InvalidEndpointUri { index } => {
write!(f, "transport target {index} endpoint_uri is invalid")
}
+ Self::EmptyTargetScope { index } => {
+ write!(f, "transport target {index} target_scope must not be empty")
+ }
+ Self::InvalidTargetScope { index } => {
+ write!(f, "transport target {index} target_scope must be canonical")
+ }
+ Self::EmptyTargetLabel { index } => {
+ write!(f, "transport target {index} target_label must not be empty")
+ }
+ Self::InvalidTargetLabel { index } => {
+ write!(f, "transport target {index} target_label is invalid")
+ }
Self::InvalidPreviewBehavior { index } => write!(
f,
"transport target {index} preview_behavior is only valid for Reticulum targets"
@@ -256,6 +280,16 @@ pub struct TransportPublishTarget {
feature = "serde",
serde(default, skip_serializing_if = "Option::is_none")
)]
+ pub target_scope: Option<String>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ pub target_label: Option<String>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
pub preview_behavior: Option<TransportPublishPreviewBehavior>,
}
@@ -264,6 +298,8 @@ impl TransportPublishTarget {
Self {
transport_kind: "nostr".to_owned(),
endpoint_uri: endpoint_uri.into(),
+ target_scope: None,
+ target_label: None,
preview_behavior: None,
}
}
@@ -272,10 +308,22 @@ impl TransportPublishTarget {
Self {
transport_kind: "reticulum".to_owned(),
endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: Some(behavior),
}
}
+ pub fn with_scope(mut self, target_scope: impl Into<String>) -> Self {
+ self.target_scope = Some(target_scope.into());
+ self
+ }
+
+ pub fn with_label(mut self, target_label: impl Into<String>) -> Self {
+ self.target_label = Some(target_label.into());
+ self
+ }
+
fn validate(&self, index: usize) -> Result<(), TransportPublishProtocolError> {
if self.transport_kind.trim().is_empty() {
return Err(TransportPublishProtocolError::EmptyTransportKind { index });
@@ -296,6 +344,11 @@ impl TransportPublishTarget {
{
return Err(TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index });
}
+ validate_target_metadata(
+ self.target_scope.as_deref(),
+ self.target_label.as_deref(),
+ index,
+ )?;
Ok(())
}
@@ -305,10 +358,37 @@ impl TransportPublishTarget {
) -> Result<RadrootsTransportTargetFingerprint, TransportPublishProtocolError> {
let transport_kind = RadrootsTransportKind::parse_canonical(self.transport_kind.as_str())
.map_err(|error| transport_kind_error(error, index))?;
+ validate_target_metadata(
+ self.target_scope.as_deref(),
+ self.target_label.as_deref(),
+ index,
+ )?;
let target = RadrootsTransportTarget::new(transport_kind, self.endpoint_uri.as_str())
.map_err(|error| target_fingerprint_error(error, index))?;
Ok(target.fingerprint)
}
+
+ fn identity_eq(&self, outcome: &TransportPublishTargetOutcome) -> bool {
+ self.transport_kind == outcome.transport_kind
+ && self.endpoint_uri == outcome.endpoint_uri
+ && self.target_scope == outcome.target_scope
+ }
+}
+
+fn validate_target_metadata(
+ target_scope: Option<&str>,
+ target_label: Option<&str>,
+ index: usize,
+) -> Result<(), TransportPublishProtocolError> {
+ if let Some(scope) = target_scope {
+ RadrootsTransportMeshScopeId::parse(scope)
+ .map_err(|error| target_metadata_error(error, index))?;
+ }
+ if let Some(label) = target_label {
+ RadrootsTransportTargetLabel::parse(label)
+ .map_err(|error| target_metadata_error(error, index))?;
+ }
+ Ok(())
}
fn transport_kind_error(
@@ -335,6 +415,27 @@ fn target_fingerprint_error(
}
}
+fn target_metadata_error(
+ error: RadrootsTransportError,
+ index: usize,
+) -> TransportPublishProtocolError {
+ match error {
+ RadrootsTransportError::EmptyTargetScope => {
+ TransportPublishProtocolError::EmptyTargetScope { index }
+ }
+ RadrootsTransportError::InvalidTargetScope => {
+ TransportPublishProtocolError::InvalidTargetScope { index }
+ }
+ RadrootsTransportError::EmptyTargetLabel => {
+ TransportPublishProtocolError::EmptyTargetLabel { index }
+ }
+ RadrootsTransportError::InvalidTargetLabel => {
+ TransportPublishProtocolError::InvalidTargetLabel { index }
+ }
+ _ => TransportPublishProtocolError::InvalidEndpointUri { index },
+ }
+}
+
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -406,13 +507,19 @@ impl TransportPublishTargetPolicy {
fn validate_explicit_target_uniqueness(
targets: &[TransportPublishTarget],
) -> Result<(), TransportPublishProtocolError> {
- let mut fingerprints = Vec::new();
+ let mut identities = Vec::new();
for (index, target) in targets.iter().enumerate() {
let fingerprint = target.fingerprint(index)?;
- if fingerprints.iter().any(|existing| existing == &fingerprint) {
+ let target_scope = target.target_scope.as_deref();
+ if identities
+ .iter()
+ .any(|(existing_fingerprint, existing_scope)| {
+ existing_fingerprint == &fingerprint && *existing_scope == target_scope
+ })
+ {
return Err(TransportPublishProtocolError::DuplicateTarget { index });
}
- fingerprints.push(fingerprint);
+ identities.push((fingerprint, target_scope));
}
Ok(())
}
@@ -579,6 +686,16 @@ pub enum TransportPublishTargetSource {
pub struct TransportPublishTargetOutcome {
pub transport_kind: String,
pub endpoint_uri: String,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ pub target_scope: Option<String>,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ pub target_label: Option<String>,
pub source: TransportPublishTargetSource,
pub attempted: bool,
pub outcome_kind: TransportPublishOutcomeKind,
@@ -726,7 +843,7 @@ pub struct TransportPublishCapabilities {
}
impl TransportPublishCapabilities {
- pub fn v3(max_event_bytes: usize, max_targets_per_request: usize) -> Self {
+ pub fn v4(max_event_bytes: usize, max_targets_per_request: usize) -> Self {
Self {
daemon: DAEMON_NAME.to_owned(),
api_version: API_VERSION.to_owned(),
@@ -905,6 +1022,11 @@ fn validate_target_outcome(
if target.endpoint_uri.trim().is_empty() {
return Err(TransportPublishProtocolError::EmptyEndpointUri { index });
}
+ validate_target_metadata(
+ target.target_scope.as_deref(),
+ target.target_label.as_deref(),
+ index,
+ )?;
if transport_kind == RadrootsTransportKind::Reticulum {
if target.endpoint_uri != RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI {
return Err(TransportPublishProtocolError::InvalidReticulumPreviewEndpoint { index });
@@ -947,9 +1069,7 @@ fn validate_job_target_policy_outcomes(
matched_targets.resize(targets.len(), false);
for (outcome_index, outcome) in outcomes.iter().enumerate() {
let Some((target_index, _)) = targets.iter().enumerate().find(|(target_index, target)| {
- !matched_targets[*target_index]
- && target.transport_kind == outcome.transport_kind
- && target.endpoint_uri == outcome.endpoint_uri
+ !matched_targets[*target_index] && target.identity_eq(outcome)
}) else {
return Err(
TransportPublishProtocolError::InvalidExplicitTargetOutcome {
@@ -1053,6 +1173,8 @@ mod tests {
TransportPublishTargetOutcome {
transport_kind: "nostr".to_owned(),
endpoint_uri: endpoint_uri.into(),
+ target_scope: None,
+ target_label: None,
source: TransportPublishTargetSource::Request,
attempted: true,
outcome_kind,
@@ -1061,12 +1183,24 @@ mod tests {
}
}
+ fn nostr_outcome_for_scope(
+ endpoint_uri: impl Into<String>,
+ target_scope: impl Into<String>,
+ outcome_kind: TransportPublishOutcomeKind,
+ ) -> TransportPublishTargetOutcome {
+ let mut outcome = nostr_outcome_for(endpoint_uri, outcome_kind);
+ outcome.target_scope = Some(target_scope.into());
+ outcome
+ }
+
fn reticulum_outcome(
outcome_kind: TransportPublishOutcomeKind,
) -> TransportPublishTargetOutcome {
TransportPublishTargetOutcome {
transport_kind: "reticulum".to_owned(),
endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
+ target_scope: None,
+ target_label: None,
source: TransportPublishTargetSource::ReticulumPreview,
attempted: false,
outcome_kind,
@@ -1146,10 +1280,10 @@ mod tests {
}
#[test]
- fn transport_publish_capabilities_match_v3_surface() {
- let capabilities = TransportPublishCapabilities::v3(1024, 10);
+ fn transport_publish_capabilities_match_v4_surface() {
+ let capabilities = TransportPublishCapabilities::v4(1024, 10);
- assert_eq!(capabilities.api_version, "radrootsd.transport_publish.v3");
+ assert_eq!(capabilities.api_version, "radrootsd.transport_publish.v4");
assert_eq!(
capabilities.methods,
vec![
@@ -1246,6 +1380,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: "reticulum".to_owned(),
endpoint_uri: "reticulum:preview-unavailable-alt".to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
}]);
assert_eq!(
@@ -1258,6 +1394,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: "Reticulum".to_owned(),
endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
}]);
assert_eq!(
@@ -1270,6 +1408,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: "nostr".to_owned(),
endpoint_uri: "wss://relay.example.com".to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
}]);
assert_eq!(
@@ -1290,6 +1430,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: "reticulum".to_owned(),
endpoint_uri: invalid.to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts),
}]);
assert_eq!(
@@ -1303,6 +1445,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: removed_proxy_kind_string(),
endpoint_uri: "radrootsd-proxy:publish".to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: None,
}]);
assert_eq!(
@@ -1315,6 +1459,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: "proxy".to_owned(),
endpoint_uri: "radrootsd-proxy:publish".to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: None,
}]);
assert_eq!(
@@ -1332,6 +1478,52 @@ mod tests {
Err(TransportPublishProtocolError::DuplicateTarget { index: 1 })
);
+ let mut scoped_targets = request.clone();
+ scoped_targets.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com/a")
+ .with_scope("farm.local")
+ .with_label("Farm relay"),
+ TransportPublishTarget::nostr("WSS://RELAY.EXAMPLE.COM/a")
+ .with_scope("farm.remote")
+ .with_label("Farm relay"),
+ ]);
+ scoped_targets
+ .validate(2)
+ .expect("scope participates in target identity");
+
+ let mut relabeled_duplicate_targets = request.clone();
+ relabeled_duplicate_targets.target_policy =
+ TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com/a")
+ .with_scope("farm.local")
+ .with_label("Primary"),
+ TransportPublishTarget::nostr("WSS://RELAY.EXAMPLE.COM/a")
+ .with_scope("farm.local")
+ .with_label("Secondary"),
+ ]);
+ assert_eq!(
+ relabeled_duplicate_targets.validate(2),
+ Err(TransportPublishProtocolError::DuplicateTarget { index: 1 })
+ );
+
+ let mut invalid_scope = request.clone();
+ invalid_scope.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com").with_scope("bad scope"),
+ ]);
+ assert_eq!(
+ invalid_scope.validate(1),
+ Err(TransportPublishProtocolError::InvalidTargetScope { index: 0 })
+ );
+
+ let mut invalid_label = request.clone();
+ invalid_label.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com").with_label("bad\nlabel"),
+ ]);
+ assert_eq!(
+ invalid_label.validate(1),
+ Err(TransportPublishProtocolError::InvalidTargetLabel { index: 0 })
+ );
+
let mut empty_key = request.clone();
empty_key.idempotency_key = Some(" ".to_owned());
assert_eq!(
@@ -1467,6 +1659,48 @@ mod tests {
mismatched_count.validate(),
Err(TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 1 })
);
+
+ job_from_targets(
+ TransportPublishJobStatus::DeliverySatisfied,
+ TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com")
+ .with_scope("farm.a")
+ .with_label("Farm A"),
+ TransportPublishTarget::nostr("wss://relay.example.com")
+ .with_scope("farm.b")
+ .with_label("Farm B"),
+ ]),
+ vec![
+ nostr_outcome_for_scope(
+ "wss://relay.example.com",
+ "farm.b",
+ TransportPublishOutcomeKind::Accepted,
+ ),
+ nostr_outcome_for_scope(
+ "wss://relay.example.com",
+ "farm.a",
+ TransportPublishOutcomeKind::Accepted,
+ ),
+ ],
+ )
+ .validate()
+ .expect("explicit target outcomes match by scoped identity");
+
+ let mismatched_scope = job_from_targets(
+ TransportPublishJobStatus::DeliverySatisfied,
+ TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com").with_scope("farm.a"),
+ ]),
+ vec![nostr_outcome_for_scope(
+ "wss://relay.example.com",
+ "farm.b",
+ TransportPublishOutcomeKind::Accepted,
+ )],
+ );
+ assert_eq!(
+ mismatched_scope.validate(),
+ Err(TransportPublishProtocolError::InvalidExplicitTargetOutcome { index: 0 })
+ );
}
#[test]
@@ -1619,6 +1853,27 @@ mod tests {
}
#[test]
+ fn serde_round_trip_preserves_target_metadata() {
+ let request = TransportPublishEventRequest {
+ event: event(),
+ target_policy: TransportPublishTargetPolicy::explicit_targets(vec![
+ TransportPublishTarget::nostr("wss://relay.example.com")
+ .with_scope("farm.local")
+ .with_label("Farm local relay"),
+ ]),
+ delivery_policy: TransportPublishDeliveryPolicy::All,
+ idempotency_key: None,
+ timeout_ms: None,
+ };
+ let encoded = serde_json::to_string(&request).expect("encode");
+ assert!(encoded.contains("\"target_scope\":\"farm.local\""));
+ assert!(encoded.contains("\"target_label\":\"Farm local relay\""));
+ let decoded: TransportPublishEventRequest =
+ serde_json::from_str(encoded.as_str()).expect("decode");
+ assert_eq!(decoded, request);
+ }
+
+ #[test]
fn protocol_errors_have_stable_display_strings() {
let cases = [
(
@@ -1657,6 +1912,22 @@ mod tests {
"transport target 3 endpoint_uri is invalid",
),
(
+ TransportPublishProtocolError::EmptyTargetScope { index: 3 },
+ "transport target 3 target_scope must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::InvalidTargetScope { index: 3 },
+ "transport target 3 target_scope must be canonical",
+ ),
+ (
+ TransportPublishProtocolError::EmptyTargetLabel { index: 3 },
+ "transport target 3 target_label must not be empty",
+ ),
+ (
+ TransportPublishProtocolError::InvalidTargetLabel { index: 3 },
+ "transport target 3 target_label is invalid",
+ ),
+ (
TransportPublishProtocolError::InvalidPreviewBehavior { index: 4 },
"transport target 4 preview_behavior is only valid for Reticulum targets",
),
@@ -1781,6 +2052,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: " ".to_owned(),
endpoint_uri: "wss://relay.example".to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: None,
}]);
assert_eq!(
@@ -1792,6 +2065,8 @@ mod tests {
TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget {
transport_kind: "Nostr".to_owned(),
endpoint_uri: "wss://relay.example".to_owned(),
+ target_scope: None,
+ target_label: None,
preview_behavior: None,
}]);
assert_eq!(