README.md (17938B)
1 # radroots_transport_nostr 2 3 `radroots_transport_nostr` is the concrete, signer-free Nostr implementation 4 of the generic [`radroots_transport::EventSource`], 5 [`radroots_transport::EventSubscriber`], and [`radroots_transport::EventSink`] 6 interfaces. It validates relay configuration and network policy, performs 7 bounded fetch, live-subscription, and delivery attempts, exposes explicit 8 host-mediated NIP-42 authentication, and normalizes relay outcomes and passive 9 status. 10 11 The crate does not own event ingestion, persistence, outbox claiming, 12 projection refresh, durable retry scheduling, or a process runtime. Those 13 policies belong to `radroots_sync` and host applications. It does own bounded 14 relay profiles, per-relay reconnect suppression, and evidence-based status. 15 16 The authoritative package charter is the 17 [`radroots_transport_nostr` section of the Release V1 specification](../../contracts/crates/release_v1/radroots_crates_release_v1.toml). 18 The reviewed Rust surface is recorded in the 19 [public API baseline](../../contracts/api_baselines/radroots_transport_nostr.txt). 20 21 ## Configure without connecting 22 23 Configuration is explicit, validated, and inert. Constructing 24 [`NostrTransport`] creates no socket and performs no DNS lookup: 25 26 ```rust 27 use radroots_transport::{EventSink, EventSource, EventSubscriber}; 28 use radroots_transport_nostr::{ 29 Config, NostrTransport, RelayAccess, RelayEndpoint, RelayProfile, 30 RelayProfileKind, RelayUrlPolicy, 31 }; 32 33 let endpoint = RelayEndpoint::new( 34 "wss://relay.example.com", 35 RelayUrlPolicy::Public, 36 RelayAccess::ReadWrite, 37 )?; 38 let profile = RelayProfile::explicit(RelayProfileKind::Public, [endpoint])?; 39 let config = Config::from_profile(profile).with_timeouts(5_000, 20_000, 2_000)?; 40 let transport = NostrTransport::new(config); 41 42 let source: &dyn EventSource = &transport; 43 let subscriber: &dyn EventSubscriber = &transport; 44 let sink: &dyn EventSink = &transport; 45 drop(source.status()); 46 let _ = subscriber; 47 drop(sink.status()); 48 # Ok::<(), Box<dyn std::error::Error>>(()) 49 ``` 50 51 A runnable version is available at 52 [`examples/configure_transport.rs`](examples/configure_transport.rs). 53 The composing host constructs bounded `FetchRequest`, `SubscriptionRequest`, 54 and `DeliveryRequest` values from `radroots_transport`, polls the returned 55 futures on its executor, and applies any retry or scheduling policy outside 56 this crate. 57 58 ## Prepared delivery boundary 59 60 [`NostrTransport::prepare_delivery`] validates the exact request, writable 61 relay bindings, and signed-event conversion without reading a clock, polling 62 status, or performing relay I/O. It returns a sealed [`PreparedDelivery`] 63 whose ordinary `Debug` is redacted. The composing host may bind the retained 64 request to durable Submitted state and then pass the capability to 65 [`NostrTransport::execute_prepared_delivery`], which consumes it and is the 66 only half of this boundary that may contact relays. Executing a capability 67 through a differently configured transport fails closed. 68 69 The prepared event retains its signed raw JSON. Delivery wraps those exact 70 bytes in the Nostr `EVENT` frame without parsing and serializing them again; 71 whitespace, field order, and admitted extension fields remain unchanged. The 72 frame must fit the existing 512 KiB wire bound. Publication shares the existing 73 hardened connection writer with SDK authentication and subscription messages. 74 It requires a matching event-ID `OK` from that relay before recording acceptance. 75 Closed or replaced connections do not grant fresh send authority to a retained 76 writer. Missing acknowledgements remain unknown or unavailable evidence. 77 78 Callers cannot forge or mutate prepared authority: 79 80 ```compile_fail 81 use radroots_transport_nostr::PreparedDelivery; 82 83 let _forged = PreparedDelivery { 84 request: panic!(), 85 config: panic!(), 86 event: panic!(), 87 authorized: panic!(), 88 skipped: panic!(), 89 }; 90 ``` 91 92 `prepare_delivery_selected` and `EventSink::deliver_selected` further constrain 93 an attempt to an exact subset of the original targets. The retained request, 94 signed bytes and full receipt binding do not change. Unselected targets receive 95 unattempted, retryable `target_not_selected` rows before configuration or I/O 96 admission. Selected targets still require writable configuration and backoff 97 admission. This adapter does not choose application eligibility policy. 98 99 ## Public surface 100 101 - [`RelayProfile`] defines public, loopback-simulator, and physical-device 102 profiles with independent read-only/read-write authority per endpoint. 103 - [`Config`] retains the validated profile plus bounded connection, request, 104 status, concurrency, and reconnect limits. 105 - [`RelayUrl`] is a canonical Nostr relay URL that converts to and from the 106 generic `radroots_transport::Target` model. 107 - [`RelayUrlPolicy`] selects public-Internet, exact-loopback, or explicitly 108 typed private-device destination rules. 109 - [`RelayCursor`] provides the equal-timestamp-safe event ordering primitive 110 used by scoped fetch continuation cursors. 111 - [`NostrTransport`] implements all three transport SPIs, exposes passive typed 112 per-relay evidence, and provides explicit NIP-42 challenge lifecycle methods. 113 - [`PreparedDelivery`] is the non-forgeable, consuming boundary between inert 114 adapter validation and relay execution. 115 - [`Error`] contains only package-owned validation and authentication errors; 116 upstream failures are normalized before crossing the public boundary. 117 118 All source modules are private implementation details. The crate root contains 119 only reviewed adapter-owned exports and does not expose an upstream client, 120 relay pool, Tokio handle, signer, storage handle, or retry worker. 121 122 ## Relay and network security 123 124 Profiles never inject a relay or infer destination policy. The caller supplies 125 every endpoint together with its public-Internet, exact-loopback, or typed 126 private-device policy and independent read-only/read-write authority. Public 127 profiles admit only public endpoints, simulator profiles admit only exact 128 loopback endpoints, and physical-device profiles admit public endpoints or 129 literal RFC1918 IPv4 and ULA IPv6 endpoints. 130 131 `RelayUrlPolicy::Public` accepts TLS WebSocket URLs with public hostnames or 132 global addresses. `Local` accepts exact loopback destinations and permits 133 plaintext WebSocket only for that class. `PrivateNetwork` accepts only literal RFC1918 IPv4 or ULA IPv6 destinations 134 and permits plaintext WebSocket for that explicit device-network class. It 135 rejects names, public, loopback, link-local, 136 unspecified, and multicast destinations before DNS or socket I/O. 137 138 Before opening a socket, the live connector resolves at most 32 addresses, 139 validates the entire answer set against the selected policy, and connects to a 140 validated address directly. The original hostname remains the TLS SNI and 141 certificate-verification identity. Proxy and Tor connection modes are denied; 142 there is no certificate, hostname, DNS-policy, or fallback bypass. 143 144 Callers that resolve addresses outside the adapter may use 145 [`RelayUrl::validate_resolved_addresses`] to apply the same destination-class 146 check before handing control to another network boundary. 147 148 ## Fetch, live subscription, delivery, and outcome behavior 149 150 Fetch accepts only configured readable Nostr targets, translates 151 transport-neutral kind, author, exact indexed single-letter tag, and event-time 152 selectors into Nostr filters, reapplies those selectors defensively, applies 153 the request page bound, deduplicates events by event ID, preserves per-relay 154 provenance, and emits an opaque versioned cursor bound to the exact target set 155 and selector when more collected results remain. Equal timestamps are ordered 156 by event ID so all received peers can be paged without timestamp-only loss. 157 Malformed relay events 158 are ignored and reported as a partial target outcome rather than admitted. 159 Fetch reports `Complete` only after the exact subscription receives EOSE 160 strictly before its absolute deadline. Deadline expiry is `Cancelled`, and a 161 relay result that exceeds the bounded inventory is `Partial`; neither state is 162 rewritten as completion even when it carries admissible events. 163 164 One fetch shares an 8 MiB incoming-byte budget, a 4,096-data-message attempt 165 limit and an 8,192-frame work limit across all selected relays and connection 166 generations. Accounting precedes WebSocket message assembly and Nostr decoding: 167 it includes decrypted HTTP upgrade bytes, frame headers and payloads, malformed 168 and discarded messages, duplicate events, unsolicited traffic, control frames, 169 and empty continuation frames. Text and binary message starts conservatively 170 consume data attempts without inspecting their JSON. The HTTP upgrade response 171 is also limited to 16 KiB. Each relay retains at most 1,000 events. An event is 172 at most 256 KiB, and the WebSocket connector explicitly limits both frames and 173 complete messages to 512 KiB. SDK notification and canonical event collection 174 retain independent limits of 8,192 notifications, 4,096 events and 8 MiB. 175 Exhaustion is sticky and wakes all affected fetch collectors; later EOSE cannot 176 turn it into completion. At most 64 fetch calls may hold ingress registrations 177 on one transport; additional calls fail closed as partial evidence. 178 The meter reads into a fixed 4 KiB plaintext scratch buffer before admission; 179 at most that scratch capacity can be read beyond the remaining allowance and 180 discarded without reaching WebSocket assembly or Nostr parsing. This bound 181 does not claim control over kernel or TLS implementation buffers. 182 Shared canonical decoding rechecks the event, aggregate-byte and inventory 183 bounds before candidate collection. A fetch returns at most the caller's 184 validated 1,000-event page limit. EOSE describes only the requested relay 185 subscription and never proves complete global history. A response reaching the 186 1,000-event cap is `Partial` even when EOSE follows: the relay may still conceal 187 other events at the same timestamp or older history. Capped EOSE proves relay 188 availability and does not start connection-failure backoff. 189 190 After all collected candidates have been paged, a capped window returns 191 `NextPage::Cancelled` with an optional opaque older-window continuation. Shared 192 pull yields control at that boundary. Callers must disclose the partial 193 coverage before explicitly continuing older discovery; additional same-time 194 events remain unproven and are not declared recovered. The continuation moves 195 strictly before the capped timestamp using the same exact target/selector scope. 196 Malformed-only or out-of-bound results cannot invent a continuation; timestamp 197 zero cannot underflow. Ordinary `nostr-v2` event cursors remain supported; the 198 additive `nostr-until-v1` form is interpreted only by this concrete adapter. 199 No automatic retry, unlimited scan, new protocol or global completeness claim 200 is implied by either form. 201 202 Live subscriptions use the same explicit readable targets and selector 203 translation. A caller checkpoint is scoped to one exact target and selector; 204 the adapter reconnects with Nostr's inclusive `since` timestamp, suppresses 205 older timestamps and the exact checkpoint event, and permits at-least-once 206 replay of other events from the checkpoint second. Within one subscription it 207 deduplicates exact event IDs, accepts same-second events in relay arrival order, 208 and never regresses the canonical target checkpoint. Each emitted event carries 209 exact relay provenance and that current checkpoint. 210 Event limits, absolute deadlines, explicit cancellation, source closure, and 211 stable repeated terminal results follow the generic subscription contract. 212 213 Delivery validates an already signed Radroots event and sends its retained JSON, 214 admits at most one attempt per configured writable target, and returns one 215 normalized receipt entry per requested target. Relay rejection, authentication 216 requirements, quota refusals, rate limits, timeouts, connection failures, missing results, and partial acceptance remain 217 explicit; this crate never retries, falls back to another transport, or 218 rewrites an unknown result as success. 219 220 An explicit quota refusal has the stable `quota_exceeded` code, a redacted 221 message and terminal rejection classification. It suppresses retries of the 222 unchanged request. Resolving the quota and authorizing further work belong to 223 the host; the adapter does not discard durable intent or recorded effects. 224 Rate limiting remains distinct from quota exhaustion. 225 226 Source and sink status are passive in-memory observations. A configured relay 227 starts unobserved and never appears available before successful read or write 228 evidence. Read and write evidence, failure counters, retry classes, and 229 next-attempt times are independent. Aggregate status distinguishes configured, 230 connecting, read-only, writable, degraded, offline, and terminally failed 231 states. Reading status does not connect to a relay, refresh DNS, or begin fetch 232 or delivery work. 233 234 ## Deadlines, cancellation, and commit points 235 236 The absolute deadline in each generic request bounds the complete operation. 237 The configured connection and request timeouts are upper bounds within that 238 remaining budget. An already-expired request performs no relay work. 239 Queued relay batches consume that same frozen deadline; they never receive a 240 new timeout after an earlier relay stalls. Bounded local normalization retains 241 the events and distinct outcomes already collected when network work ends, so 242 one timed-out relay cannot erase another relay's earlier successful evidence. 243 An expired queued target remains unattempted. The attempted flag records entry 244 into connection/publication work, not proof that bytes reached the wire or that 245 a remote effect occurred. A skipped target cannot report acceptance; missing 246 results cannot prove that an attempt was absent. 247 248 Dropping an unpolled fetch, subscription-start, or delivery future performs no 249 I/O. Once polled, cancellation is best effort at the socket boundary. For 250 delivery, submission to a relay is the remote commit point: after a relay 251 accepts the event, dropping the future cannot retract it. A missing final 252 response is therefore reported as unavailable or unknown evidence, never as 253 proof that no publication occurred. Fetch and live observation have no local 254 durable commit point. 255 256 A cancelled, timed-out or resource-limited fetch invalidates its selected 257 connection generations and signals SDK disconnection with reconnect disabled. 258 This includes connections whose earlier relay batch already completed; their 259 collected evidence remains intact. Receive admission stops at the generation 260 boundary, while ordinary SDK task teardown is asynchronous. These authenticated 261 sockets are shared with subscriptions, delivery and authentication, so those 262 overlapping operations can also observe disconnection. A successful fetch 263 removes its registration and preserves the shared connections. A later explicit 264 operation may establish a fresh connection and receives a fresh fetch budget; 265 closed generations never regain receive authority. 266 267 Dropping a pending subscription `next` or `cancel` future records a 268 cancellation request in the retained capability; its next operation awaits 269 relay unsubscription and returns the stable cancelled terminal result. 270 Dropping the capability itself cannot await network cleanup. Every published 271 relay subscription therefore also carries an upstream auto-close deadline 272 bounded by the request's absolute deadline, so remote work cannot continue 273 indefinitely. 274 275 NIP-42 authentication is explicit. [`NostrTransport::begin_authentication`] 276 records one bounded relay challenge and returns the exact host signing input. 277 The host signs outside this crate, then calls 278 [`NostrTransport::complete_authentication`] once. Relay submission is the AUTH 279 commit point. Rejecting a challenge consumes it without network access, and a 280 challenge is never retried or silently replaced. 281 282 ## Serialization and diagnostics 283 284 This package defines no public serialization feature or stable serialized 285 configuration format. Persist relay profiles in a host-owned, versioned 286 contract and reconstruct [`Config`] through its validating constructors. 287 Generic requests, pages, receipts, targets, and status values follow the 288 serialization contract of `radroots_transport`. 289 290 Public diagnostics are bounded and secret-safe. Raw upstream client errors, 291 relay URLs and resolved addresses, relay challenge payloads, signed 292 authentication events, credentials, and transport internals are not retained 293 in public errors, status, or normalized outcomes. 294 Applications should still avoid logging relay authentication inputs or signed 295 event JSON. 296 297 ## Features and runtime requirements 298 299 The package has no Cargo features. It is a standard-library native adapter; 300 Tokio and the upstream Nostr relay client are private implementation choices. 301 The crate never creates an executor, installs a runtime, launches an 302 adapter-owned worker, installs a tracing subscriber, or owns process lifecycle. 303 The host must poll operations from a compatible executor and provide 304 clock/deadline policy through the generic requests. After an explicit operation 305 begins, the private upstream relay client owns its ordinary socket tasks and 306 the bounded auto-close timer for live relay subscriptions. 307 308 ## Intended consumers 309 310 - `radroots_sync` composes this source, subscriber, and sink with verification, 311 storage, projection, outbox, and explicit retry decisions. 312 - `radroots_sdk` selects and configures the adapter for advanced applications. 313 - Native services may compose it directly behind the generic transport SPIs. 314 315 Ordinary applications should normally use `radroots` or `radroots_sdk`. 316 Adapter authors and advanced hosts should depend on `radroots_transport` for 317 the generic contract and use this package only when Nostr relay I/O is needed. 318 319 ## Copyright 320 321 Except as otherwise noted, all files in the `radroots_transport_nostr` 322 distribution are copyright (c) 2025 Tyson Lupul. See `LICENSE` for usage, 323 redistribution, and warranty terms.