lib

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

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.