lib

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

commit 6638b555d09f1f466413c381b0e3d1136a4d2c5f
parent cf5dadb4b3ef3c65c782c55faacacf81f981d47f
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 07:33:12 +0000

transport-nostr: document public api contract

- Document relay policy, operation semantics, cancellation, and commit points.
- Add an inert source-and-sink configuration example with no relay access.
- Record and index the reviewed cargo-public-api baseline.
- Guard documentation completeness and prevent implementation-type leakage.

Diffstat:
Mcrates/transport_nostr/README.md | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Acrates/transport_nostr/examples/configure_transport.rs | 16++++++++++++++++
Mcrates/transport_nostr/tests/package_boundary.rs | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/api/README.md | 1+
Adocs/api/radroots_transport_nostr.txt | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 321 insertions(+), 8 deletions(-)

diff --git a/crates/transport_nostr/README.md b/crates/transport_nostr/README.md @@ -1,13 +1,157 @@ # radroots_transport_nostr `radroots_transport_nostr` is the concrete, signer-free Nostr implementation -of the generic `radroots_transport` event source and sink interfaces. +of the generic [`radroots_transport::EventSource`] and +[`radroots_transport::EventSink`] interfaces. It validates relay configuration +and network policy, performs bounded fetch and delivery attempts, exposes +explicit host-mediated NIP-42 authentication, and normalizes relay outcomes +and passive status. -The crate validates relay configuration and network policy, performs bounded -fetch and delivery attempts, exposes explicit host-mediated NIP-42 -authentication, and normalizes relay outcomes and passive status. It does not -own event ingestion, persistence, outbox claiming, projection refresh, retry -scheduling, SDK profiles, or a process runtime. Those policies belong to -`radroots_sync` and host applications. +The crate does not own event ingestion, persistence, outbox claiming, +projection refresh, retry scheduling, SDK profiles, or a process runtime. +Those policies belong to `radroots_sync` and host applications. Publication +remains disabled during the `0.1.0-alpha` refactor. -Publication remains disabled during the `0.1.0-alpha` refactor. +The authoritative package charter is the +[`radroots_transport_nostr` section of the Release V1 specification](https://github.com/radrootslabs/lib/blob/master/docs/specs/radroots_crates_release_v1.md#15-radroots_transport_nostr). +The reviewed Rust surface is recorded in the +[public API baseline](../../docs/api/radroots_transport_nostr.txt). + +## Configure without connecting + +Configuration is explicit, validated, and inert. Constructing +[`NostrTransport`] creates no socket and performs no DNS lookup: + +```rust +use radroots_transport::{EventSink, EventSource}; +use radroots_transport_nostr::{Config, NostrTransport, RelayUrlPolicy}; + +let config = Config::new( + RelayUrlPolicy::Public, + ["wss://relay.example.com"], +)?.with_timeouts(5_000, 20_000, 2_000)?; +let transport = NostrTransport::new(config); + +let source: &dyn EventSource = &transport; +let sink: &dyn EventSink = &transport; +drop(source.status()); +drop(sink.status()); +# Ok::<(), Box<dyn std::error::Error>>(()) +``` + +A runnable version is available at +[`examples/configure_transport.rs`](examples/configure_transport.rs). +The composing host constructs bounded `FetchRequest` and `DeliveryRequest` +values from `radroots_transport`, polls the returned futures on its executor, +and applies any retry or scheduling policy outside this crate. + +## Public surface + +- [`Config`] validates a non-empty, duplicate-free relay set plus bounded + connection, request, status, and concurrency limits. +- [`RelayUrl`] is a canonical Nostr relay URL that converts to and from the + generic `radroots_transport::Target` model. +- [`RelayUrlPolicy`] selects public-Internet, exact-loopback, or explicitly + trusted private-network destination rules. +- [`NostrTransport`] implements both transport SPIs and exposes explicit + NIP-42 challenge lifecycle methods. +- [`Error`] contains only package-owned validation and authentication errors; + upstream failures are normalized before crossing the public boundary. + +All source modules are private implementation details. The crate root contains +only the five reviewed exports above and does not expose an upstream client, +relay pool, Tokio handle, signer, storage handle, or retry worker. + +## Relay and network security + +`RelayUrlPolicy::Public` accepts TLS WebSocket URLs with public hostnames or +global addresses. `Local` accepts exact loopback destinations and permits +plaintext WebSocket only for that class. `PrivateNetwork` accepts explicit +trusted private or public destinations but still requires TLS. + +Before opening a socket, the live connector resolves at most 32 addresses, +validates the entire answer set against the selected policy, and connects to a +validated address directly. The original hostname remains the TLS SNI and +certificate-verification identity. Proxy and Tor connection modes are denied; +there is no certificate, hostname, DNS-policy, or fallback bypass. + +Callers that resolve addresses outside the adapter may use +[`RelayUrl::validate_resolved_addresses`] to apply the same destination-class +check before handing control to another network boundary. + +## Fetch, delivery, and outcome behavior + +Fetch accepts only configured Nostr targets, applies the request page bound, +deduplicates events by event ID, preserves per-relay provenance, and emits an +opaque versioned cursor when more results remain. Malformed relay events are +ignored and reported as a partial target outcome rather than admitted. + +Delivery converts an already validated signed Radroots event to Nostr, attempts +each configured target once, and returns one normalized receipt entry per +requested target. Relay rejection, authentication requirements, rate limits, +timeouts, connection failures, missing results, and partial acceptance remain +explicit; this crate never retries, falls back to another transport, or +rewrites an unknown result as success. + +Source and sink status are passive in-memory observations. Reading status does +not connect to a relay, refresh DNS, or begin fetch or delivery work. + +## Deadlines, cancellation, and commit points + +The absolute deadline in each generic request bounds the complete operation. +The configured connection and request timeouts are upper bounds within that +remaining budget. An already-expired request performs no relay work. + +Dropping an unpolled fetch or delivery future performs no I/O. Once polled, +cancellation is best effort at the socket boundary. For delivery, submission +to a relay is the remote commit point: after a relay accepts the event, dropping +the future cannot retract it. A missing final response is therefore reported +as unavailable or unknown evidence, never as proof that no publication +occurred. Fetch is observational and has no local durable commit point. + +NIP-42 authentication is explicit. [`NostrTransport::begin_authentication`] +records one bounded relay challenge and returns the exact host signing input. +The host signs outside this crate, then calls +[`NostrTransport::complete_authentication`] once. Relay submission is the AUTH +commit point. Rejecting a challenge consumes it without network access, and a +challenge is never retried or silently replaced. + +## Serialization and diagnostics + +This package defines no public serialization feature or stable serialized +configuration format. Persist relay profiles in a host-owned, versioned +contract and reconstruct [`Config`] through its validating constructors. +Generic requests, pages, receipts, targets, and status values follow the +serialization contract of `radroots_transport`. + +Public diagnostics are bounded and secret-safe. Raw upstream client errors, +relay challenge payloads, signed authentication events, credentials, and +transport internals are not retained in public status or normalized outcomes. +Applications should still avoid logging relay authentication inputs or signed +event JSON. + +## Features and runtime requirements + +The package has no Cargo features. It is a standard-library native adapter; +Tokio and the upstream Nostr relay client are private implementation choices. +The crate never creates an executor, installs a runtime, spawns a background +worker, installs a tracing subscriber, or owns process lifecycle. The host must +poll operations from a compatible executor and provide clock/deadline policy +through the generic requests. + +## Intended consumers + +- `radroots_sync` composes this source and sink with verification, storage, + projection, outbox, and explicit retry decisions. +- `radroots_sdk` selects and configures the adapter for advanced applications. +- Native services may compose it directly behind the generic transport SPIs. + +Ordinary applications should normally use `radroots` or `radroots_sdk`. +Adapter authors and advanced hosts should depend on `radroots_transport` for +the generic contract and use this package only when Nostr relay I/O is needed. + +## Copyright + +Except as otherwise noted, all files in the `radroots_transport_nostr` +distribution are copyright (c) 2025 Tyson Lupul. See `LICENSE` for usage, +redistribution, and warranty terms. diff --git a/crates/transport_nostr/examples/configure_transport.rs b/crates/transport_nostr/examples/configure_transport.rs @@ -0,0 +1,16 @@ +use radroots_transport::{EventSink, EventSource}; +use radroots_transport_nostr::{Config, NostrTransport, RelayUrlPolicy}; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let config = Config::new(RelayUrlPolicy::Public, ["wss://relay.example.com"])? + .with_timeouts(5_000, 20_000, 2_000)?; + let transport = NostrTransport::new(config); + + let source: &dyn EventSource = &transport; + let sink: &dyn EventSink = &transport; + drop(source.status()); + drop(sink.status()); + + println!("configured a Nostr source and sink without opening a relay connection"); + Ok(()) +} diff --git a/crates/transport_nostr/tests/package_boundary.rs b/crates/transport_nostr/tests/package_boundary.rs @@ -3,6 +3,10 @@ use std::fs; use std::path::Path; const MANIFEST: &str = include_str!("../Cargo.toml"); +const README: &str = include_str!("../README.md"); +const EXAMPLE: &str = include_str!("../examples/configure_transport.rs"); +const PUBLIC_API: &str = include_str!("../../../docs/api/radroots_transport_nostr.txt"); +const API_INDEX: &str = include_str!("../../../docs/api/README.md"); const ROOT: &str = include_str!("../src/lib.rs"); #[test] @@ -43,6 +47,72 @@ fn manifest_and_root_match_the_governed_transport_boundary() { } } +#[test] +fn documentation_example_and_reviewed_api_baseline_are_complete() { + for required in [ + "## Configure without connecting", + "## Public surface", + "## Relay and network security", + "## Fetch, delivery, and outcome behavior", + "## Deadlines, cancellation, and commit points", + "## Serialization and diagnostics", + "## Features and runtime requirements", + "## Intended consumers", + "radroots_crates_release_v1.md#15-radroots_transport_nostr", + "examples/configure_transport.rs", + "docs/api/radroots_transport_nostr.txt", + ] { + assert!(README.contains(required), "README is missing `{required}`"); + } + for required in [ + "Config::new(", + "RelayUrlPolicy::Public", + "NostrTransport::new(config)", + "let source: &dyn EventSource", + "let sink: &dyn EventSink", + "drop(source.status())", + "drop(sink.status())", + ] { + assert!( + EXAMPLE.contains(required), + "example is missing `{required}`" + ); + } + for required in [ + "pub struct radroots_transport_nostr::Config", + "pub struct radroots_transport_nostr::NostrTransport", + "pub struct radroots_transport_nostr::RelayUrl(_)", + "pub enum radroots_transport_nostr::RelayUrlPolicy", + "pub enum radroots_transport_nostr::Error", + "impl radroots_transport::sink::EventSink for radroots_transport_nostr::NostrTransport", + "impl radroots_transport::source::EventSource for radroots_transport_nostr::NostrTransport", + "NostrTransport::begin_authentication", + "NostrTransport::complete_authentication", + "NostrTransport::reject_authentication", + ] { + assert!( + PUBLIC_API.contains(required), + "public API baseline is missing `{required}`" + ); + } + for forbidden in [ + "nostr_sdk", + "nostr_relay_pool", + "tokio::", + "radroots_storage", + "radroots_outbox", + "pub trait radroots_transport_nostr", + ] { + assert!( + !PUBLIC_API.contains(forbidden), + "reviewed public API baseline exposes `{forbidden}`" + ); + } + assert!(API_INDEX.contains( + "| `radroots_transport_nostr` | [`radroots_transport_nostr.txt`](radroots_transport_nostr.txt) |" + )); +} + fn radroots_dependency_keys(manifest: &str) -> BTreeSet<&str> { dependency_keys(manifest) .into_iter() diff --git a/docs/api/README.md b/docs/api/README.md @@ -43,3 +43,4 @@ expand a package beyond its charter. | `radroots_nostr_connect` | [`radroots_nostr_connect.txt`](radroots_nostr_connect.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | | `radroots_secrets` | [`radroots_secrets.txt`](radroots_secrets.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | | `radroots_storage` | [`radroots_storage.txt`](radroots_storage.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | +| `radroots_transport_nostr` | [`radroots_transport_nostr.txt`](radroots_transport_nostr.txt) | [release V1 specification](../specs/radroots_crates_release_v1.md) | diff --git a/docs/api/radroots_transport_nostr.txt b/docs/api/radroots_transport_nostr.txt @@ -0,0 +1,82 @@ +pub mod radroots_transport_nostr +#[non_exhaustive] pub enum radroots_transport_nostr::Error +pub radroots_transport_nostr::Error::AuthChallengeConflict +pub radroots_transport_nostr::Error::AuthChallengeExpired +pub radroots_transport_nostr::Error::AuthChallengeMissing +pub radroots_transport_nostr::Error::AuthRejected +pub radroots_transport_nostr::Error::AuthResponseInvalid +pub radroots_transport_nostr::Error::AuthResponseMismatch +pub radroots_transport_nostr::Error::AuthSignerUnavailable +pub radroots_transport_nostr::Error::AuthStateUnavailable +pub radroots_transport_nostr::Error::AuthTransport +pub radroots_transport_nostr::Error::DuplicateRelayUrl +pub radroots_transport_nostr::Error::DuplicateRelayUrl::url: alloc::string::String +pub radroots_transport_nostr::Error::EmptyRelaySet +pub radroots_transport_nostr::Error::EmptyResolution +pub radroots_transport_nostr::Error::EmptyResolution::url: alloc::string::String +pub radroots_transport_nostr::Error::InvalidAuthChallenge +pub radroots_transport_nostr::Error::InvalidConnectionLimit +pub radroots_transport_nostr::Error::InvalidConnectionLimit::value: usize +pub radroots_transport_nostr::Error::InvalidRelayUrl +pub radroots_transport_nostr::Error::InvalidRelayUrl::reason: alloc::string::String +pub radroots_transport_nostr::Error::InvalidRelayUrl::url: alloc::string::String +pub radroots_transport_nostr::Error::InvalidTimeout +pub radroots_transport_nostr::Error::InvalidTimeout::field: &'static str +pub radroots_transport_nostr::Error::InvalidTimeout::value_ms: u64 +pub radroots_transport_nostr::Error::RelayDestinationDenied +pub radroots_transport_nostr::Error::RelayDestinationDenied::reason: &'static str +pub radroots_transport_nostr::Error::RelayDestinationDenied::url: alloc::string::String +pub radroots_transport_nostr::Error::RelaySchemeDenied +pub radroots_transport_nostr::Error::RelaySchemeDenied::url: alloc::string::String +pub radroots_transport_nostr::Error::ResolvedAddressDenied +pub radroots_transport_nostr::Error::ResolvedAddressDenied::address: alloc::string::String +pub radroots_transport_nostr::Error::ResolvedAddressDenied::url: alloc::string::String +pub radroots_transport_nostr::Error::Target(alloc::string::String) +pub radroots_transport_nostr::Error::TooManyRelays +pub radroots_transport_nostr::Error::TooManyRelays::actual: usize +pub radroots_transport_nostr::Error::TooManyRelays::max: usize +pub radroots_transport_nostr::Error::UnexpectedTransport +pub radroots_transport_nostr::Error::UnexpectedTransport::actual: alloc::string::String +impl core::error::Error for radroots_transport_nostr::Error +impl core::fmt::Display for radroots_transport_nostr::Error +pub fn radroots_transport_nostr::Error::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result +#[non_exhaustive] pub enum radroots_transport_nostr::RelayUrlPolicy +pub radroots_transport_nostr::RelayUrlPolicy::Local +pub radroots_transport_nostr::RelayUrlPolicy::PrivateNetwork +pub radroots_transport_nostr::RelayUrlPolicy::Public +pub struct radroots_transport_nostr::Config +impl radroots_transport_nostr::Config +pub const fn radroots_transport_nostr::Config::connect_timeout_ms(&self) -> u64 +pub const fn radroots_transport_nostr::Config::max_connections(&self) -> usize +pub fn radroots_transport_nostr::Config::new<I, S>(radroots_transport_nostr::RelayUrlPolicy, I) -> core::result::Result<Self, radroots_transport_nostr::Error> where I: core::iter::traits::collect::IntoIterator<Item = S>, S: core::convert::AsRef<str> +pub const fn radroots_transport_nostr::Config::relay_url_policy(&self) -> radroots_transport_nostr::RelayUrlPolicy +pub fn radroots_transport_nostr::Config::relays(&self) -> &[radroots_transport_nostr::RelayUrl] +pub const fn radroots_transport_nostr::Config::request_timeout_ms(&self) -> u64 +pub const fn radroots_transport_nostr::Config::status_timeout_ms(&self) -> u64 +pub fn radroots_transport_nostr::Config::with_max_connections(self, usize) -> core::result::Result<Self, radroots_transport_nostr::Error> +pub fn radroots_transport_nostr::Config::with_timeouts(self, u64, u64, u64) -> core::result::Result<Self, radroots_transport_nostr::Error> +pub struct radroots_transport_nostr::NostrTransport +impl radroots_transport_nostr::NostrTransport +pub fn radroots_transport_nostr::NostrTransport::begin_authentication(&self, &radroots_transport_nostr::RelayUrl, impl core::convert::AsRef<str>, u64, u64) -> core::result::Result<alloc::string::String, radroots_transport_nostr::Error> +pub fn radroots_transport_nostr::NostrTransport::complete_authentication<'a>(&'a self, &'a radroots_transport_nostr::RelayUrl, &'a str, core::option::Option<&'a str>, u64) -> radroots_transport::source::BoxFuture<'a, core::result::Result<(), radroots_transport_nostr::Error>> +pub fn radroots_transport_nostr::NostrTransport::reject_authentication(&self, &radroots_transport_nostr::RelayUrl, &str) -> core::result::Result<(), radroots_transport_nostr::Error> +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 +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 +pub fn radroots_transport_nostr::NostrTransport::deliver(&self, radroots_transport::sink::DeliveryRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::sink::DeliveryReceipt, radroots_transport::error::Error>> +pub fn radroots_transport_nostr::NostrTransport::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::status::SinkStatus, radroots_transport::error::Error>> +impl radroots_transport::source::EventSource for radroots_transport_nostr::NostrTransport +pub fn radroots_transport_nostr::NostrTransport::fetch(&self, radroots_transport::source::FetchRequest) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::source::FetchPage, radroots_transport::error::Error>> +pub fn radroots_transport_nostr::NostrTransport::status(&self) -> radroots_transport::source::BoxFuture<'_, core::result::Result<radroots_transport::status::SourceStatus, radroots_transport::error::Error>> +pub struct radroots_transport_nostr::RelayUrl(_) +impl radroots_transport_nostr::RelayUrl +pub fn radroots_transport_nostr::RelayUrl::as_str(&self) -> &str +pub fn radroots_transport_nostr::RelayUrl::from_target(&radroots_transport::target::Target, radroots_transport_nostr::RelayUrlPolicy) -> core::result::Result<Self, radroots_transport_nostr::Error> +pub fn radroots_transport_nostr::RelayUrl::parse(impl core::convert::AsRef<str>, radroots_transport_nostr::RelayUrlPolicy) -> core::result::Result<Self, radroots_transport_nostr::Error> +pub fn radroots_transport_nostr::RelayUrl::to_target(&self) -> core::result::Result<radroots_transport::target::Target, radroots_transport_nostr::Error> +pub fn radroots_transport_nostr::RelayUrl::validate_resolved_addresses(&self, radroots_transport_nostr::RelayUrlPolicy, impl core::iter::traits::collect::IntoIterator<Item = core::net::ip_addr::IpAddr>) -> core::result::Result<(), radroots_transport_nostr::Error> +impl core::fmt::Display for radroots_transport_nostr::RelayUrl +pub fn radroots_transport_nostr::RelayUrl::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result