lib

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

commit 89fbbd82b03f32dcc226053f6f412e6ac2abb4c9
parent 62fd018c1d76d4d894af02e0d3edc9d6fd242ee3
Author: triesap <tyson@radroots.org>
Date:   Sun, 23 Aug 2026 05:45:08 +0000

transport-nostr: split delivery preparation from execution

- seal exact prepared delivery behind inert adapter validation
- consume configuration-bound authority only at relay execution
- preserve lazy EventSink compatibility and existing outcome behavior
- verify adapter, contract, API, Clippy, doctest, and Rustdoc gates

Diffstat:
Mcontracts/api_baselines/radroots_transport_nostr.txt | 8++++++++
Mcrates/transport_nostr/README.md | 27+++++++++++++++++++++++++++
Mcrates/transport_nostr/src/lib.rs | 1+
Mcrates/transport_nostr/src/sink.rs | 240++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mcrates/transport_nostr/tests/package_boundary.rs | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 258 insertions(+), 75 deletions(-)

diff --git a/contracts/api_baselines/radroots_transport_nostr.txt b/contracts/api_baselines/radroots_transport_nostr.txt @@ -98,6 +98,9 @@ impl radroots_transport_nostr::NostrTransport pub const fn radroots_transport_nostr::NostrTransport::config(&self) -> &radroots_transport_nostr::Config pub fn radroots_transport_nostr::NostrTransport::new(radroots_transport_nostr::Config) -> Self pub fn radroots_transport_nostr::NostrTransport::relay_status(&self) -> radroots_transport_nostr::RelayStatusReport +impl radroots_transport_nostr::NostrTransport +pub fn radroots_transport_nostr::NostrTransport::execute_prepared_delivery(&self, radroots_transport_nostr::PreparedDelivery) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::sink::SinkFailure>> +pub fn radroots_transport_nostr::NostrTransport::prepare_delivery(&self, radroots_transport::sink::DeliveryRequest) -> core::result::Result<radroots_transport_nostr::PreparedDelivery, alloc::boxed::Box<radroots_transport::sink::SinkFailure>> impl core::fmt::Debug for radroots_transport_nostr::NostrTransport pub fn radroots_transport_nostr::NostrTransport::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result impl radroots_transport::sink::EventSink for radroots_transport_nostr::NostrTransport @@ -108,6 +111,11 @@ pub fn radroots_transport_nostr::NostrTransport::fetch(&self, radroots_transport pub fn radroots_transport_nostr::NostrTransport::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::status::SourceStatus, radroots_transport::error::Error>> impl radroots_transport::source::EventSubscriber for radroots_transport_nostr::NostrTransport pub fn radroots_transport_nostr::NostrTransport::subscribe(&self, radroots_transport::source::SubscriptionRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::source::BoxSubscription, radroots_transport::error::Error>> +pub struct radroots_transport_nostr::PreparedDelivery +impl radroots_transport_nostr::PreparedDelivery +pub const fn radroots_transport_nostr::PreparedDelivery::request(&self) -> &radroots_transport::sink::DeliveryRequest +impl core::fmt::Debug for radroots_transport_nostr::PreparedDelivery +pub fn radroots_transport_nostr::PreparedDelivery::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub struct radroots_transport_nostr::ReconnectBackoff impl radroots_transport_nostr::ReconnectBackoff pub const fn radroots_transport_nostr::ReconnectBackoff::initial_delay_ms(self) -> u64 diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -55,6 +55,31 @@ and `DeliveryRequest` values from `radroots_transport`, polls the returned futures on its executor, and applies any retry or scheduling policy outside this crate. +## Prepared delivery boundary + +[`NostrTransport::prepare_delivery`] validates the exact request, writable +relay bindings, and signed-event conversion without reading a clock, polling +status, or performing relay I/O. It returns a sealed [`PreparedDelivery`] +whose ordinary `Debug` is redacted. The composing host may bind the retained +request to durable Submitted state and then pass the capability to +[`NostrTransport::execute_prepared_delivery`], which consumes it and is the +only half of this boundary that may contact relays. Executing a capability +through a differently configured transport fails closed. + +Callers cannot forge or mutate prepared authority: + +```compile_fail +use radroots_transport_nostr::PreparedDelivery; + +let _forged = PreparedDelivery { + request: panic!(), + config: panic!(), + event: panic!(), + authorized: panic!(), + skipped: panic!(), +}; +``` + ## Public surface - [`RelayProfile`] defines public, loopback-simulator, and physical-device @@ -69,6 +94,8 @@ this crate. used by scoped fetch continuation cursors. - [`NostrTransport`] implements all three transport SPIs, exposes passive typed per-relay evidence, and provides explicit NIP-42 challenge lifecycle methods. +- [`PreparedDelivery`] is the non-forgeable, consuming boundary between inert + adapter validation and relay execution. - [`Error`] contains only package-owned validation and authentication errors; upstream failures are normalized before crossing the public boundary. diff --git a/crates/transport_nostr/src/lib.rs b/crates/transport_nostr/src/lib.rs @@ -18,6 +18,7 @@ pub use cursor::RelayCursor; pub use error::Error; pub use profile::{RelayAccess, RelayEndpoint, RelayProfile, RelayProfileKind}; pub use relay::{RelayUrl, RelayUrlPolicy}; +pub use sink::PreparedDelivery; pub use status::{ RelayAggregateState, RelayCapabilityEvidence, RelayEvidenceState, RelayStatus, RelayStatusReport, diff --git a/crates/transport_nostr/src/sink.rs b/crates/transport_nostr/src/sink.rs @@ -1,11 +1,11 @@ //! Nostr implementation of the transport event sink. use crate::{NostrTransport, RelayUrl, status}; -use core::time::Duration; +use core::{fmt, time::Duration}; use futures::{StreamExt, stream}; use radroots_nostr::event::Event; use radroots_transport::{ - BoxFuture, DeliveryReceipt, DeliveryRequest, EventSink, SinkFailure, + BoxFuture, DeliveryReceipt, DeliveryRequest, EventSink, SinkFailure, Target, outcome::DeliveryOutcome, sink::{DeliveryTargetReceipt, SinkStatus}, }; @@ -17,6 +17,35 @@ pub(crate) struct RelayPublishResult { outcome: DeliveryOutcome, } +/// Sealed, no-I/O result of validating one delivery against this adapter. +/// +/// The value retains the exact request and converted signed event. It is +/// constructed only by [`NostrTransport::prepare_delivery`] and is consumed by +/// [`NostrTransport::execute_prepared_delivery`]. Ordinary `Debug` never +/// exposes event bytes, request identities, or relay destinations. +#[must_use = "prepared delivery must be durably bound before execution or deliberately discarded"] +pub struct PreparedDelivery { + request: DeliveryRequest, + config: crate::Config, + event: Event, + authorized: Vec<(RelayUrl, Target)>, + skipped: Vec<DeliveryTargetReceipt>, +} + +impl PreparedDelivery { + /// Returns the exact validated request retained for persistence binding. + #[must_use] + pub const fn request(&self) -> &DeliveryRequest { + &self.request + } +} + +impl fmt::Debug for PreparedDelivery { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("PreparedDelivery([redacted])") + } +} + pub(crate) trait RelayClient: Send + Sync { fn publish<'a>( &'a self, @@ -96,60 +125,77 @@ impl RelayClient for LiveRelayClient { } } -impl EventSink for NostrTransport { - fn status(&self) -> BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { - Box::pin(async move { Ok(status::sink_status(&self.status)) }) +impl NostrTransport { + /// Validates and converts one delivery without reading a clock or performing relay I/O. + pub fn prepare_delivery( + &self, + request: DeliveryRequest, + ) -> Result<PreparedDelivery, Box<SinkFailure>> { + let mut authorized = Vec::new(); + let mut skipped = Vec::new(); + for target in request.target_set().targets() { + match self.config().endpoint_for_target(target) { + Some(endpoint) if endpoint.access().can_write() => { + authorized.push((endpoint.url().clone(), target.clone())); + } + None | Some(_) => skipped.push( + DeliveryTargetReceipt::skipped( + target.clone(), + DeliveryOutcome::rejected() + .with_detail("target_denied", "target is not configured for this sink") + .map_err(|_| Box::new(SinkFailure::invalid_contract(&request)))?, + ) + .map_err(|_| Box::new(SinkFailure::invalid_contract(&request)))?, + ), + } + } + let event = radroots_nostr::event::to_nostr(request.payload().event().envelope()) + .map_err(|_| Box::new(SinkFailure::invalid_contract(&request)))?; + Ok(PreparedDelivery { + request, + config: self.config().clone(), + event, + authorized, + skipped, + }) } - fn deliver( + /// Performs relay I/O for one exact prepared delivery and consumes its authority. + pub fn execute_prepared_delivery( &self, - request: DeliveryRequest, + prepared: PreparedDelivery, ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { Box::pin(async move { + let PreparedDelivery { + request, + config, + event, + authorized, + mut skipped, + } = prepared; + if config != *self.config() { + return Err(SinkFailure::invalid_contract(&request)); + } let now_unix_ms = unix_time_ms(); let mut requested = Vec::new(); - let mut skipped = Vec::new(); - for target in request.target_set().targets() { - match self.config().endpoint_for_target(target) { - Some(endpoint) if endpoint.access().can_write() => { - let relay = endpoint.url().clone(); - if self.status.may_write(&relay, now_unix_ms) { - requested.push((relay, target.clone())); - } else { - skipped.push( - DeliveryTargetReceipt::skipped( - target.clone(), - DeliveryOutcome::unavailable() - .with_detail( - "reconnect_backoff", - "relay reconnect backoff is active", - ) - .map_err(|_| SinkFailure::invalid_contract(&request))?, - ) - .map_err(|_| SinkFailure::invalid_contract(&request))?, - ); - } - } - None | Some(_) => skipped.push( + for (relay, target) in authorized { + if self.status.may_write(&relay, now_unix_ms) { + requested.push((relay, target)); + } else { + skipped.push( DeliveryTargetReceipt::skipped( - target.clone(), - DeliveryOutcome::rejected() + target, + DeliveryOutcome::unavailable() .with_detail( - "target_denied", - "target is not configured for this sink", + "reconnect_backoff", + "relay reconnect backoff is active", ) .map_err(|_| SinkFailure::invalid_contract(&request))?, ) .map_err(|_| SinkFailure::invalid_contract(&request))?, - ), + ); } } - - let event = match radroots_nostr::event::to_nostr(request.payload().event().envelope()) - { - Ok(event) => event, - Err(_) => return Err(SinkFailure::invalid_contract(&request)), - }; let remaining_ms = request.deadline_unix_ms().saturating_sub(now_unix_ms); let operation_timeout_ms = remaining_ms.min(self.config().request_timeout_ms()); if operation_timeout_ms == 0 { @@ -211,6 +257,22 @@ impl EventSink for NostrTransport { } } +impl EventSink for NostrTransport { + fn status(&self) -> BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> { + Box::pin(async move { Ok(status::sink_status(&self.status)) }) + } + + fn deliver( + &self, + request: DeliveryRequest, + ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> { + Box::pin(async move { + let prepared = self.prepare_delivery(request).map_err(|failure| *failure)?; + self.execute_prepared_delivery(prepared).await + }) + } +} + #[cfg_attr(coverage_nightly, coverage(off))] fn unix_time_ms() -> u64 { std::time::SystemTime::now() @@ -264,6 +326,23 @@ mod tests { } } + #[derive(Debug)] + struct CountingRelayClient(Arc<AtomicUsize>); + + impl RelayClient for CountingRelayClient { + fn publish<'a>( + &'a self, + _relays: Vec<RelayUrl>, + _event: Event, + _max_connections: usize, + _connect_timeout: Duration, + _operation_timeout: Duration, + ) -> BoxFuture<'a, Vec<RelayPublishResult>> { + self.0.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Vec::new() }) + } + } + fn payload() -> DeliveryPayload { let raw = r#"{"id":"56bfc78223bb2221bad82b539efdec1ade0f56d0eb0e1f592fd387df4b2ceee0","pubkey":"585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df","created_at":1700000001,"kind":0,"tags":[],"content":"{}","sig":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"}"#; DeliveryPayload::new(radroots_event_codec::decode::signed_event(raw).expect("signed event")) @@ -336,23 +415,6 @@ mod tests { #[test] fn dropping_an_unpolled_delivery_performs_no_relay_work() { - #[derive(Debug)] - struct CountingRelayClient(Arc<AtomicUsize>); - - impl RelayClient for CountingRelayClient { - fn publish<'a>( - &'a self, - _relays: Vec<RelayUrl>, - _event: Event, - _max_connections: usize, - _connect_timeout: Duration, - _operation_timeout: Duration, - ) -> BoxFuture<'a, Vec<RelayPublishResult>> { - self.0.fetch_add(1, Ordering::SeqCst); - Box::pin(async { Vec::new() }) - } - } - let calls = Arc::new(AtomicUsize::new(0)); let config = Config::from_profile( crate::profile::test_profile( @@ -370,24 +432,52 @@ mod tests { } #[test] - fn expired_delivery_deadline_performs_no_relay_work() { - #[derive(Debug)] - struct CountingRelayClient(Arc<AtomicUsize>); - - impl RelayClient for CountingRelayClient { - fn publish<'a>( - &'a self, - _relays: Vec<RelayUrl>, - _event: Event, - _max_connections: usize, - _connect_timeout: Duration, - _operation_timeout: Duration, - ) -> BoxFuture<'a, Vec<RelayPublishResult>> { - self.0.fetch_add(1, Ordering::SeqCst); - Box::pin(async { Vec::new() }) - } - } + fn preparation_is_no_io_redacted_consuming_and_bound_to_exact_config() { + let calls = Arc::new(AtomicUsize::new(0)); + let profile = crate::profile::test_profile( + crate::RelayProfileKind::Public, + RelayUrlPolicy::Public, + ["wss://one.example", "wss://two.example"], + ) + .expect("profile"); + let config = Config::from_profile(profile.clone()); + let transport = + NostrTransport::with_client(config, Arc::new(CountingRelayClient(Arc::clone(&calls)))); + let request = request(); + + let prepared = transport + .prepare_delivery(request.clone()) + .expect("prepared delivery"); + assert_eq!(prepared.request(), &request); + assert_eq!(format!("{prepared:?}"), "PreparedDelivery([redacted])"); + assert_eq!(calls.load(Ordering::SeqCst), 0); + let receipt = futures::executor::block_on(transport.execute_prepared_delivery(prepared)) + .expect("executed delivery"); + assert_eq!(receipt.request_id(), request.request_id()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let mismatch_calls = Arc::new(AtomicUsize::new(0)); + let mismatched = NostrTransport::with_client( + Config::from_profile(profile) + .with_timeouts(5_000, 20_000, 2_000) + .expect("different bounded config"), + Arc::new(CountingRelayClient(Arc::clone(&mismatch_calls))), + ); + let prepared = transport + .prepare_delivery(request) + .expect("second prepared delivery"); + assert_eq!( + futures::executor::block_on(mismatched.execute_prepared_delivery(prepared)) + .expect_err("prepared authority is config-bound") + .code(), + "invalid_transport_contract" + ); + assert_eq!(mismatch_calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn expired_delivery_deadline_performs_no_relay_work() { let calls = Arc::new(AtomicUsize::new(0)); let config = Config::from_profile( crate::profile::test_profile( diff --git a/crates/transport_nostr/tests/package_boundary.rs b/crates/transport_nostr/tests/package_boundary.rs @@ -9,6 +9,7 @@ const PUBLIC_API: &str = include_str!("../../../contracts/api_baselines/radroots_transport_nostr.txt"); const ROOT: &str = include_str!("../src/lib.rs"); const PROFILE: &str = include_str!("../src/profile.rs"); +const SINK: &str = include_str!("../src/sink.rs"); const SUBSCRIPTION: &str = include_str!("../src/subscription.rs"); #[test] @@ -55,6 +56,7 @@ fn manifest_and_root_match_the_governed_transport_boundary() { "pub use error::Error;", "pub use profile::{", "pub use relay::{RelayUrl, RelayUrlPolicy};", + "pub use sink::PreparedDelivery;", ] { assert!(ROOT.contains(export), "crate root is missing `{export}`"); } @@ -67,6 +69,7 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "## Public surface", "## Relay and network security", "## Fetch, live subscription, delivery, and outcome behavior", + "## Prepared delivery boundary", "## Deadlines, cancellation, and commit points", "## Serialization and diagnostics", "## Features and runtime requirements", @@ -79,6 +82,11 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "event-ID tie breaker", "upstream auto-close deadline", "adapter-owned worker", + "validates the exact request, writable\nrelay bindings, and signed-event conversion without reading a clock, polling\nstatus, or performing relay I/O", + "Executing a capability\nthrough a differently configured transport fails closed", + "let _forged = PreparedDelivery {", + "request: panic!(),", + "skipped: panic!(),", ] { assert!(README.contains(required), "README is missing `{required}`"); } @@ -102,6 +110,7 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { for required in [ "pub struct radroots_transport_nostr::Config", "pub struct radroots_transport_nostr::NostrTransport", + "pub struct radroots_transport_nostr::PreparedDelivery", "pub enum radroots_transport_nostr::RelayAggregateState", "pub struct radroots_transport_nostr::RelayProfile", "pub fn radroots_transport_nostr::RelayEndpoint::new(", @@ -116,6 +125,8 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "NostrTransport::begin_authentication", "NostrTransport::complete_authentication", "NostrTransport::reject_authentication", + "NostrTransport::prepare_delivery", + "NostrTransport::execute_prepared_delivery", ] { assert!( PUBLIC_API.contains(required), @@ -129,6 +140,8 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { "radroots_storage", "radroots_outbox", "pub trait radroots_transport_nostr", + "impl core::clone::Clone for radroots_transport_nostr::PreparedDelivery", + "impl serde_core::ser::Serialize for radroots_transport_nostr::PreparedDelivery", ] { assert!( !PUBLIC_API.contains(forbidden), @@ -138,6 +151,50 @@ fn documentation_example_and_reviewed_api_baseline_are_complete() { } #[test] +fn preparation_is_sealed_and_separated_from_execution_io() { + let prepare = SINK + .split_once("pub fn prepare_delivery(") + .expect("prepared delivery function") + .1 + .split_once("pub fn execute_prepared_delivery(") + .expect("execution boundary") + .0; + for forbidden in [".await", "unix_time_ms", "self.status", ".publish("] { + assert!( + !prepare.contains(forbidden), + "delivery preparation contains I/O authority `{forbidden}`" + ); + } + for required in [ + "pub struct PreparedDelivery", + "#[must_use = \"prepared delivery must be durably bound before execution or deliberately discarded\"]", + "request: DeliveryRequest", + "event: Event", + "config: crate::Config", + "formatter.write_str(\"PreparedDelivery([redacted])\")", + "pub const fn request(&self) -> &DeliveryRequest", + ] { + assert!( + SINK.contains(required), + "prepared boundary is missing `{required}`" + ); + } + for forbidden in [ + "impl Clone for PreparedDelivery", + "derive(Clone", + "pub fn new(", + "pub request:", + "pub event:", + "pub config:", + ] { + assert!( + !prepare.contains(forbidden), + "prepared boundary exposes `{forbidden}`" + ); + } +} + +#[test] fn relay_profiles_have_no_implicit_destination_or_policy_constructor() { for forbidden in [ "DEFAULT_PUBLIC_RELAY",