lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 1b54111a98cbd402243420846f77bc29b50bbbb1
parent 66edce8df3a716a27fe2eaba1af8da3901767c6f
Author: triesap <tyson@radroots.org>
Date:   Mon, 13 Jul 2026 06:10:03 +0000

transport: add operation capabilities

- add UnsupportedOperation to the public transport error contract
- expose deliver and fetch capabilities on transport status models
- report Nostr fetch as unsupported and Reticulum preview as inert
- cover protocol capability rows and focused transport tests

Diffstat:
Mcrates/runtime/src/transport.rs | 15+++++++++++----
Mcrates/transport/src/error.rs | 2++
Mcrates/transport/src/lib.rs | 4++--
Mcrates/transport/src/status.rs | 48++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/source_boundary.rs | 7+++++++
Mcrates/transport/tests/transport.rs | 28++++++++++++++++++++++------
Mcrates/transport_nostr/src/outbox.rs | 23++++++++++++-----------
Mcrates/transport_nostr/src/publish.rs | 19+++++++++----------
Mcrates/transport_nostr/tests/transport.rs | 26+++++++++++++++++++++++---
Mcrates/transport_publish_protocol/src/lib.rs | 21+++++++++++++++++++++
Mcrates/transport_reticulum/tests/reticulum_preview.rs | 4++++
11 files changed, 161 insertions(+), 36 deletions(-)

diff --git a/crates/runtime/src/transport.rs b/crates/runtime/src/transport.rs @@ -632,9 +632,9 @@ mod tests { #[cfg(feature = "transport-workers")] use radroots_event::draft::{RadrootsSignedEvent, RadrootsSignedEventParts}; use radroots_transport::{ - RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, - RadrootsTransportDeliveryTargetStatus, RadrootsTransportFetchReceipt, - RadrootsTransportFetchRequest, RadrootsTransportFuture, + RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt, + RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, + RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, @@ -690,7 +690,8 @@ mod tests { RadrootsTransportImplementationState::Real, true, "ready", - )) + ) + .with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch())) }) } @@ -838,6 +839,10 @@ mod tests { .expect("receipt"); let status = transport.status().await.expect("status"); assert_eq!(status.kind, RadrootsTransportKind::Nostr); + assert_eq!( + status.capabilities, + RadrootsTransportCapabilities::deliver_and_fetch() + ); let fetch = transport .fetch(RadrootsTransportFetchRequest::new( "nostr-fetch", @@ -913,6 +918,8 @@ mod tests { status.implementation, RadrootsTransportImplementationState::PreviewUnavailable ); + assert!(!status.capabilities.deliver); + assert!(!status.capabilities.fetch); let fetch = transport .fetch(RadrootsTransportFetchRequest::new( "reticulum-fetch", diff --git a/crates/transport/src/error.rs b/crates/transport/src/error.rs @@ -2,6 +2,7 @@ use core::fmt; #[derive(Clone, Debug, PartialEq, Eq)] pub enum RadrootsTransportError { + UnsupportedOperation, EmptyTransportKind, InvalidTransportKind, EmptyTargetUri, @@ -29,6 +30,7 @@ pub enum RadrootsTransportError { impl fmt::Display for RadrootsTransportError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::UnsupportedOperation => f.write_str("transport operation is unsupported"), Self::EmptyTransportKind => f.write_str("transport kind is empty"), Self::InvalidTransportKind => f.write_str("transport kind is invalid"), Self::EmptyTargetUri => f.write_str("transport target URI is empty"), diff --git a/crates/transport/src/lib.rs b/crates/transport/src/lib.rs @@ -25,8 +25,8 @@ pub use message::{ }; pub use payload::RadrootsTransportPayload; pub use status::{ - RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, - RadrootsTransportStatus, + RadrootsTransportCapabilities, RadrootsTransportDeliveryTargetStatus, RadrootsTransportOutcome, + RadrootsTransportOutcomeKind, RadrootsTransportStatus, }; pub use target::{ RadrootsTransportMeshScopeId, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, diff --git a/crates/transport/src/status.rs b/crates/transport/src/status.rs @@ -173,6 +173,43 @@ impl RadrootsTransportOutcome { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsTransportCapabilities { + pub deliver: bool, + pub fetch: bool, +} + +impl RadrootsTransportCapabilities { + pub const fn none() -> Self { + Self { + deliver: false, + fetch: false, + } + } + + pub const fn deliver_only() -> Self { + Self { + deliver: true, + fetch: false, + } + } + + pub const fn fetch_only() -> Self { + Self { + deliver: false, + fetch: true, + } + } + + pub const fn deliver_and_fetch() -> Self { + Self { + deliver: true, + fetch: true, + } + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug, PartialEq, Eq)] pub struct RadrootsTransportStatus { #[cfg_attr(feature = "serde", serde(rename = "transport"))] pub kind: RadrootsTransportKind, @@ -181,6 +218,7 @@ pub struct RadrootsTransportStatus { pub configured: bool, pub implementation: RadrootsTransportImplementationState, pub usable_for_delivery: bool, + pub capabilities: RadrootsTransportCapabilities, pub message: String, } @@ -199,10 +237,20 @@ impl RadrootsTransportStatus { configured, implementation, usable_for_delivery, + capabilities: if usable_for_delivery { + RadrootsTransportCapabilities::deliver_only() + } else { + RadrootsTransportCapabilities::none() + }, message: message.into(), } } + pub fn with_capabilities(mut self, capabilities: RadrootsTransportCapabilities) -> Self { + self.capabilities = capabilities; + self + } + pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self { self.profile_id = Some(profile_id.into()); self diff --git a/crates/transport/tests/source_boundary.rs b/crates/transport/tests/source_boundary.rs @@ -407,11 +407,18 @@ fn transport_publish_capabilities_keep_canonical_status_fields() { "pub configured: bool,", "pub implementation: TransportPublishImplementation,", "pub usable_for_delivery: bool,", + "pub capabilities: TransportPublishOperationCapabilities,", + "pub struct TransportPublishOperationCapabilities", + "pub deliver: bool,", + "pub fetch: bool,", "TransportPublishImplementation::Real", "TransportPublishImplementation::PreviewUnavailable", "configured: true", "usable_for_delivery: true", "usable_for_delivery: false", + "capabilities: TransportPublishOperationCapabilities", + "deliver: true", + "fetch: false", ] { assert!( source.contains(required), diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -1,10 +1,11 @@ use radroots_transport::{ RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RADROOTS_RETICULUM_PREVIEW_SCOPE_ID, - RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, - RadrootsTransportDeliveryTargetStatus, RadrootsTransportError, RadrootsTransportFetchReceipt, - RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, - RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportOutcome, - RadrootsTransportOutcomeKind, RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt, + RadrootsTransportDeliveryRequest, RadrootsTransportDeliveryTargetStatus, + RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, + RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, + RadrootsTransportMeshScopeId, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, + RadrootsTransportPayload, RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, RadrootsTransportTargetUri, @@ -204,6 +205,10 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() { RadrootsTransportImplementationState::Real ); assert!(status.usable_for_delivery); + assert_eq!( + status.capabilities, + RadrootsTransportCapabilities::deliver_only() + ); assert_eq!(status.message, "ready"); let json = serde_json::to_value(&status).expect("status json"); @@ -211,6 +216,8 @@ fn transport_status_models_canonical_configuration_and_delivery_usability() { assert_eq!(json["implementation"], "real"); assert_eq!(json["configured"], true); assert_eq!(json["usable_for_delivery"], true); + assert_eq!(json["capabilities"]["deliver"], true); + assert_eq!(json["capabilities"]["fetch"], false); assert_eq!(json["message"], "ready"); for retired in [ "kind", @@ -382,6 +389,10 @@ fn fingerprint_parser_rejects_non_sha256_hex() { fn transport_errors_have_stable_display_strings() { let cases = [ ( + RadrootsTransportError::UnsupportedOperation, + "transport operation is unsupported", + ), + ( RadrootsTransportError::EmptyTransportKind, "transport kind is empty", ), @@ -920,7 +931,8 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { RadrootsTransportImplementationState::Real, true, "ready", - )) + ) + .with_capabilities(RadrootsTransportCapabilities::deliver_and_fetch())) }) } @@ -963,6 +975,10 @@ fn neutral_transport_trait_covers_status_delivery_and_fetch() { assert_eq!(transport.transport_kind(), RadrootsTransportKind::Local); let status = futures::executor::block_on(transport.status()).expect("status"); assert_eq!(status.kind, RadrootsTransportKind::Local); + assert_eq!( + status.capabilities, + RadrootsTransportCapabilities::deliver_and_fetch() + ); let delivery = futures::executor::block_on(transport.deliver(RadrootsTransportDeliveryRequest::new( "deliver-1", diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -684,6 +684,18 @@ fn transport_satisfaction_policy_for_publishable( fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRelayTransportError { match error { + RadrootsTransportError::UnsupportedOperation + | RadrootsTransportError::EmptyTransportKind + | RadrootsTransportError::InvalidTransportKind + | RadrootsTransportError::EmptyTargetScope + | RadrootsTransportError::InvalidTargetScope + | RadrootsTransportError::EmptyTargetLabel + | RadrootsTransportError::InvalidTargetLabel + | RadrootsTransportError::InvalidSatisfactionPolicy + | RadrootsTransportError::EmptyRequiredTargetSet + | RadrootsTransportError::DuplicateRequiredTargetFingerprint => { + RadrootsRelayTransportError::Transport(error.to_string()) + } RadrootsTransportError::EmptyTargetUri | RadrootsTransportError::InvalidTargetUri | RadrootsTransportError::EmptyTargetSet @@ -701,17 +713,6 @@ fn transport_error_to_relay_error(error: RadrootsTransportError) -> RadrootsRela | RadrootsTransportError::PayloadDigestMismatch => { RadrootsRelayTransportError::NostrEventJson(error.to_string()) } - RadrootsTransportError::EmptyTransportKind - | RadrootsTransportError::InvalidTransportKind - | RadrootsTransportError::EmptyTargetScope - | RadrootsTransportError::InvalidTargetScope - | RadrootsTransportError::EmptyTargetLabel - | RadrootsTransportError::InvalidTargetLabel - | RadrootsTransportError::InvalidSatisfactionPolicy - | RadrootsTransportError::EmptyRequiredTargetSet - | RadrootsTransportError::DuplicateRequiredTargetFingerprint => { - RadrootsRelayTransportError::Transport(error.to_string()) - } } } diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -6,12 +6,12 @@ use core::time::Duration; use futures::future::BoxFuture; use radroots_event::draft::{RadrootsSignedEvent, RadrootsSignedEventParts}; use radroots_transport::{ - RadrootsTransport, RadrootsTransportDeliveryReceipt, RadrootsTransportDeliveryRequest, - RadrootsTransportError, RadrootsTransportFetchReceipt, RadrootsTransportFetchRequest, - RadrootsTransportFuture, RadrootsTransportImplementationState, RadrootsTransportKind, - RadrootsTransportOutcome, RadrootsTransportOutcomeKind, RadrootsTransportPayload, - RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, RadrootsTransportTarget, - RadrootsTransportTargetReceipt, + RadrootsTransport, RadrootsTransportCapabilities, RadrootsTransportDeliveryReceipt, + RadrootsTransportDeliveryRequest, RadrootsTransportError, RadrootsTransportFetchReceipt, + RadrootsTransportFetchRequest, RadrootsTransportFuture, RadrootsTransportImplementationState, + RadrootsTransportKind, RadrootsTransportOutcome, RadrootsTransportOutcomeKind, + RadrootsTransportPayload, RadrootsTransportSatisfactionPolicy, RadrootsTransportStatus, + RadrootsTransportTarget, RadrootsTransportTargetReceipt, }; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -138,7 +138,8 @@ impl<A> RadrootsNostrTransport<A> { RadrootsTransportImplementationState::Real, true, "ready", - ), + ) + .with_capabilities(RadrootsTransportCapabilities::deliver_only()), } } @@ -206,9 +207,7 @@ where &'a self, _request: RadrootsTransportFetchRequest, ) -> RadrootsTransportFuture<'a, RadrootsTransportFetchReceipt> { - Box::pin( - async move { Err(radroots_transport::RadrootsTransportError::InvalidTransportKind) }, - ) + Box::pin(async move { Err(RadrootsTransportError::UnsupportedOperation) }) } } diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -18,9 +18,10 @@ use radroots_outbox::{ }; use radroots_transport::{ RadrootsTransport, RadrootsTransportDeliveryRequest, RadrootsTransportError, - RadrootsTransportKind, RadrootsTransportMeshScopeId, RadrootsTransportPayload, - RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy, - RadrootsTransportTarget, RadrootsTransportTargetLabel, RadrootsTransportTargetSet, + RadrootsTransportFetchRequest, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportPayload, RadrootsTransportSatisfactionClass, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel, + RadrootsTransportTargetSet, }; use radroots_transport_nostr::{ RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, @@ -718,8 +719,11 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() { ); let receipt = transport.deliver(request).await.expect("delivery"); + let status = transport.status().await.expect("status"); assert_eq!(adapter.captured_raw_events(), vec![signed.raw_json]); + assert!(status.capabilities.deliver); + assert!(!status.capabilities.fetch); assert_eq!(receipt.request_id, "facade-request-1"); assert_eq!(receipt.target_receipts.len(), 1); assert_eq!(receipt.target_receipts[0].target, target); @@ -735,6 +739,22 @@ async fn nostr_transport_facade_delivers_signed_event_payloads() { } #[tokio::test] +async fn nostr_transport_facade_reports_fetch_as_unsupported_operation() { + let transport = RadrootsNostrTransport::new(RadrootsMockRelayPublishAdapter::new()); + let target_set = + RadrootsTransportTargetSet::new(vec![nostr_target(RELAY_PRIMARY_WSS)]).expect("targets"); + let error = transport + .fetch(RadrootsTransportFetchRequest::new( + "facade-fetch-unsupported", + target_set, + )) + .await + .expect_err("fetch unsupported"); + + assert_eq!(error, RadrootsTransportError::UnsupportedOperation); +} + +#[tokio::test] async fn nostr_transport_facade_rejects_unsupported_payloads_and_targets() { let signed = signed_post("facade rejected"); let transport = RadrootsNostrTransport::new(RadrootsMockRelayPublishAdapter::new()); diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -992,6 +992,10 @@ impl TransportPublishCapabilities { configured: true, implementation: TransportPublishImplementation::Real, usable_for_delivery: true, + capabilities: TransportPublishOperationCapabilities { + deliver: true, + fetch: false, + }, preview_behavior: None, message: "Nostr relay publish is available".to_owned(), }, @@ -1000,6 +1004,10 @@ impl TransportPublishCapabilities { configured: true, implementation: TransportPublishImplementation::PreviewUnavailable, usable_for_delivery: false, + capabilities: TransportPublishOperationCapabilities { + deliver: false, + fetch: false, + }, preview_behavior: Some( TransportPublishPreviewBehavior::RejectDeliveryAttempts, ), @@ -1048,6 +1056,7 @@ pub struct TransportPublishTransportCapability { pub configured: bool, pub implementation: TransportPublishImplementation, pub usable_for_delivery: bool, + pub capabilities: TransportPublishOperationCapabilities, #[cfg_attr( feature = "serde", serde(default, skip_serializing_if = "Option::is_none") @@ -1057,6 +1066,14 @@ pub struct TransportPublishTransportCapability { } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransportPublishOperationCapabilities { + pub deliver: bool, + pub fetch: bool, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum TransportPublishDeliveryPolicyName { @@ -1519,6 +1536,8 @@ mod tests { assert!(nostr.configured); assert_eq!(nostr.implementation, TransportPublishImplementation::Real); assert!(nostr.usable_for_delivery); + assert!(nostr.capabilities.deliver); + assert!(!nostr.capabilities.fetch); let reticulum = capabilities .publish .transports @@ -1531,6 +1550,8 @@ mod tests { TransportPublishImplementation::PreviewUnavailable ); assert!(!reticulum.usable_for_delivery); + assert!(!reticulum.capabilities.deliver); + assert!(!reticulum.capabilities.fetch); assert_eq!( reticulum.preview_behavior, Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts) diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -292,6 +292,8 @@ fn core_transport_trait_reports_preview_status_delivery_and_fetch() { RadrootsTransportImplementationState::PreviewUnavailable ); assert!(!status.usable_for_delivery); + assert!(!status.capabilities.deliver); + assert!(!status.capabilities.fetch); let delivery = futures::executor::block_on(RadrootsTransport::deliver( &transport, @@ -503,6 +505,8 @@ fn configured_agent_endpoint_is_metadata_only_for_status_delivery_and_fetch() { RadrootsTransportImplementationState::PreviewUnavailable ); assert!(!status.transport_status.usable_for_delivery); + assert!(!status.transport_status.capabilities.deliver); + assert!(!status.transport_status.capabilities.fetch); let receipt = transport .deliver(delivery_request(vec![reticulum_target(