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:
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