cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 1bf01554c5ecd3778dbe4486d898d4443268961f
parent 3f7964c064e12aea3eb736547822abcfbdb66d0f
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 04:44:59 +0000

cli: wire radrootsd listing transport

- add the sdk bridge client dependency for listing publish
- submit daemon listing writes through bridge.listing.publish
- return daemon job and signer-session truth in listing output
- cover daemon publish routing without relay configuration

Diffstat:
MCargo.lock | 319+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MCargo.toml | 1+
Msrc/domain/runtime.rs | 4++++
Msrc/runtime/listing.rs | 244+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/target_cli.rs | 215++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
5 files changed, 742 insertions(+), 41 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -197,6 +197,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" [[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -336,6 +342,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] name = "chacha20" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -833,9 +845,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -941,12 +955,94 @@ dependencies = [ ] [[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] name = "iana-time-zone" version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1114,6 +1210,12 @@ dependencies = [ ] [[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1251,6 +1353,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" [[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] name = "matchers" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1662,6 +1770,61 @@ dependencies = [ ] [[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] name = "quote" version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1705,6 +1868,7 @@ dependencies = [ "radroots_replica_db_schema", "radroots_replica_sync", "radroots_runtime_paths", + "radroots_sdk", "radroots_secret_vault", "radroots_sql_core", "radroots_trade", @@ -1919,6 +2083,19 @@ dependencies = [ ] [[package]] +name = "radroots_sdk" +version = "0.1.0-alpha.2" +dependencies = [ + "radroots_events", + "radroots_events_codec", + "radroots_identity", + "radroots_trade", + "reqwest", + "serde", + "serde_json", +] + +[[package]] name = "radroots_secret_vault" version = "0.1.0-alpha.2" dependencies = [ @@ -2046,6 +2223,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2128,6 +2343,12 @@ dependencies = [ ] [[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2160,6 +2381,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -2181,6 +2403,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] name = "salsa20" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2316,6 +2544,18 @@ dependencies = [ ] [[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2432,6 +2672,15 @@ dependencies = [ ] [[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2702,6 +2951,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2763,6 +3057,12 @@ dependencies = [ ] [[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] name = "ts-rs" version = "11.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2950,6 +3250,15 @@ dependencies = [ ] [[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3074,6 +3383,16 @@ dependencies = [ ] [[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] name = "webpki-roots" version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -37,6 +37,7 @@ radroots_replica_db = { path = "../lib/crates/replica_db" } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema" } radroots_replica_sync = { path = "../lib/crates/replica_sync" } radroots_runtime_paths = { path = "../lib/crates/runtime_paths" } +radroots_sdk = { path = "../lib/crates/sdk", features = ["radrootsd-client"] } radroots_secret_vault = { path = "../lib/crates/secret_vault", features = ["std", "os-keyring"] } radroots_sql_core = { path = "../lib/crates/sql_core", features = ["native"] } radroots_trade = { path = "../lib/crates/trade" } diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -2635,6 +2635,10 @@ pub struct ListingMutationJobView { pub signer_mode: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub signer_session_id: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub relay_count: Option<usize>, + #[serde(skip_serializing_if = "Option::is_none")] + pub acknowledged_relay_count: Option<usize>, } #[derive(Debug, Clone, Serialize)] diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -21,6 +21,12 @@ use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_events_codec::wire::WireEventParts; use radroots_replica_db::ReplicaSql; +use radroots_sdk::{ + RadrootsSdkClient, RadrootsSdkConfig, RadrootsdAuth, SdkEnvironment, SdkPublishError, + SdkPublishReceipt, SdkRadrootsdListingPublishOptions, SdkRadrootsdPublishReceipt, + SdkRadrootsdSignerSessionRef, SdkTransportMode, SdkTransportReceipt, + SignerConfig as SdkSignerConfig, +}; use radroots_sql_core::SqliteExecutor; use radroots_trade::listing::publish::validate_listing_for_seller; use radroots_trade::listing::validation::validate_listing_event; @@ -28,12 +34,15 @@ use serde::{Deserialize, Serialize}; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, - ListingMutationEventView, ListingMutationView, ListingNewView, ListingSummaryView, - ListingValidateView, ListingValidationIssueView, RelayFailureView, SyncFreshnessView, + ListingMutationEventView, ListingMutationJobView, ListingMutationView, ListingNewView, + ListingSummaryView, ListingValidateView, ListingValidationIssueView, RelayFailureView, + SyncFreshnessView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; -use crate::runtime::config::{PublishMode, RuntimeConfig, SignerBackend}; +use crate::runtime::config::{ + PublishMode, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, +}; use crate::runtime::direct_relay::{ DirectRelayFailure, DirectRelayPublishError, DirectRelayPublishReceipt, publish_parts_with_identity, @@ -52,8 +61,7 @@ const RELAY_LISTING_WRITE_SOURCE: &str = "direct Nostr relay publish · local ke const RADROOTSD_LISTING_WRITE_SOURCE: &str = "radrootsd publish transport · signer session"; const DIRECT_RELAY_UNAVAILABLE_REASON: &str = "direct Nostr relay publishing is not implemented for listing update"; -const RADROOTSD_LISTING_UNAVAILABLE_REASON: &str = - "radrootsd listing publish transport is not implemented"; +const RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD: &str = "bridge.listing.publish"; const LISTING_DRAFTS_DIR: &str = "listings/drafts"; static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -927,14 +935,14 @@ fn mutate( listing_addr, event_draft, ), - PublishMode::Radrootsd => Ok(radrootsd_unavailable_view( + PublishMode::Radrootsd => mutate_via_radrootsd( config, args, operation, &canonical, listing_addr, - event_draft.event, - )), + event_draft, + ), } } @@ -1022,6 +1030,216 @@ fn mutate_via_direct_relay( )) } +fn mutate_via_radrootsd( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + event_draft: ListingMutationEventDraft, +) -> Result<ListingMutationView, RuntimeError> { + let Some(signer_session_id) = resolve_radrootsd_signer_session_id(config, args) else { + return Ok(radrootsd_preflight_view( + config, + args, + operation, + canonical, + listing_addr, + event_draft.event, + "unconfigured", + "radrootsd listing publish requires `signer_session_id` input or a signer.remote_nip46 capability binding with signer_session_ref", + )); + }; + if config.rpc.bridge_bearer_token.is_none() { + return Ok(radrootsd_preflight_view( + config, + args, + operation, + canonical, + listing_addr, + event_draft.event, + "unconfigured", + "radrootsd bridge bearer token is required for listing publish; set RADROOTS_RPC_BEARER_TOKEN", + )); + } + + let receipt = publish_listing_via_radrootsd( + config, + &canonical.listing, + signer_session_id.as_str(), + args.idempotency_key.as_deref(), + )?; + + radrootsd_mutation_view( + config, + args, + operation, + canonical, + listing_addr, + event_draft.event, + signer_session_id, + receipt, + ) +} + +fn publish_listing_via_radrootsd( + config: &RuntimeConfig, + listing: &RadrootsListing, + signer_session_id: &str, + idempotency_key: Option<&str>, +) -> Result<SdkPublishReceipt, RuntimeError> { + let mut sdk_config = RadrootsSdkConfig::for_environment(SdkEnvironment::Custom); + sdk_config.transport = SdkTransportMode::Radrootsd; + sdk_config.signer = SdkSignerConfig::Nip46; + sdk_config.radrootsd.endpoint = Some(config.rpc.url.clone()); + sdk_config.radrootsd.auth = config + .rpc + .bridge_bearer_token + .clone() + .map(RadrootsdAuth::BearerToken) + .unwrap_or(RadrootsdAuth::None); + + let client = RadrootsSdkClient::from_config(sdk_config).map_err(|error| { + RuntimeError::Config(format!("configure radrootsd listing publish: {error}")) + })?; + let signer_session = SdkRadrootsdSignerSessionRef::from_session_id(signer_session_id); + let mut options = SdkRadrootsdListingPublishOptions::from_signer_session_ref(&signer_session); + if let Some(idempotency_key) = idempotency_key.filter(|value| !value.trim().is_empty()) { + options = options.with_idempotency_key(idempotency_key.to_owned()); + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| { + RuntimeError::Network(format!("build radrootsd listing publish runtime: {error}")) + })?; + + runtime + .block_on( + client + .listing() + .publish_listing_via_radrootsd_with_options(listing, &options), + ) + .map_err(map_sdk_listing_publish_error) +} + +fn map_sdk_listing_publish_error(error: SdkPublishError) -> RuntimeError { + let message = format!("radrootsd listing publish failed: {error}"); + match error { + SdkPublishError::Config(_) + | SdkPublishError::Encode(_) + | SdkPublishError::UnsupportedTransport { .. } + | SdkPublishError::UnsupportedSignerMode { .. } => RuntimeError::Config(message), + SdkPublishError::Relay(_) + | SdkPublishError::RelayNotAcknowledged { .. } + | SdkPublishError::Radrootsd(_) => RuntimeError::Network(message), + } +} + +fn resolve_radrootsd_signer_session_id( + config: &RuntimeConfig, + args: &ListingMutationArgs, +) -> Option<String> { + args.signer_session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .or_else(|| { + config + .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) + .and_then(|binding| binding.signer_session_ref.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) +} + +fn radrootsd_mutation_view( + config: &RuntimeConfig, + args: &ListingMutationArgs, + operation: ListingMutationOperation, + canonical: &CanonicalListingDraft, + listing_addr: String, + mut event: ListingMutationEventView, + requested_session_id: String, + receipt: SdkPublishReceipt, +) -> Result<ListingMutationView, RuntimeError> { + let SdkPublishReceipt { + event_kind, + event_id, + transport_receipt, + .. + } = receipt; + let SdkTransportReceipt::Radrootsd(radrootsd) = transport_receipt else { + return Err(RuntimeError::Config( + "radrootsd listing publish returned a non-radrootsd transport receipt".to_owned(), + )); + }; + if let Some(event_id) = event_id.as_ref() { + event.event_id = Some(event_id.clone()); + } + let event_addr = radrootsd + .event_addr + .clone() + .unwrap_or_else(|| listing_addr.clone()); + let job_status = radrootsd.status.clone(); + let state = match operation { + ListingMutationOperation::Archive => "archived", + ListingMutationOperation::Publish | ListingMutationOperation::Update => "published", + } + .to_owned(); + let job = radrootsd_job_view(args, &requested_session_id, &radrootsd, job_status.clone()); + + Ok(ListingMutationView { + state, + operation: operation.as_str().to_owned(), + source: listing_write_source(config).to_owned(), + file: args.file.display().to_string(), + listing_id: canonical.listing_id.clone(), + listing_addr: listing_addr.clone(), + seller_pubkey: canonical.seller_pubkey.clone(), + event_kind: event_kind.unwrap_or(KIND_LISTING), + dry_run: false, + deduplicated: radrootsd.deduplicated, + target_relays: Vec::new(), + connected_relays: Vec::new(), + acknowledged_relays: Vec::new(), + failed_relays: Vec::new(), + job_id: radrootsd.job_id.clone(), + job_status, + signer_mode: radrootsd.signer_mode.clone(), + event_id, + event_addr: Some(event_addr), + idempotency_key: args.idempotency_key.clone(), + signer_session_id: radrootsd.signer_session_id.clone(), + requested_signer_session_id: Some(requested_session_id), + reason: None, + job: Some(job), + event: args.print_event.then_some(event), + actions: Vec::new(), + }) +} + +fn radrootsd_job_view( + args: &ListingMutationArgs, + requested_session_id: &str, + receipt: &SdkRadrootsdPublishReceipt, + state: Option<String>, +) -> ListingMutationJobView { + ListingMutationJobView { + rpc_method: RADROOTSD_BRIDGE_LISTING_PUBLISH_METHOD.to_owned(), + state: state.unwrap_or_else(|| "accepted".to_owned()), + job_id: receipt.job_id.clone(), + idempotency_key: args.idempotency_key.clone(), + requested_signer_session_id: Some(requested_session_id.to_owned()), + signer_mode: receipt.signer_mode.clone(), + signer_session_id: receipt.signer_session_id.clone(), + relay_count: receipt.relay_count, + acknowledged_relay_count: receipt.acknowledged_relay_count, + } +} + fn listing_write_source(config: &RuntimeConfig) -> &'static str { match config.publish.mode { PublishMode::NostrRelay => RELAY_LISTING_WRITE_SOURCE, @@ -1518,16 +1736,18 @@ fn direct_relay_unavailable_view( } } -fn radrootsd_unavailable_view( +fn radrootsd_preflight_view( config: &RuntimeConfig, args: &ListingMutationArgs, operation: ListingMutationOperation, canonical: &CanonicalListingDraft, listing_addr: String, event_preview: ListingMutationEventView, + state: &str, + reason: impl Into<String>, ) -> ListingMutationView { ListingMutationView { - state: "unavailable".to_owned(), + state: state.to_owned(), operation: operation.as_str().to_owned(), source: listing_write_source(config).to_owned(), file: args.file.display().to_string(), @@ -1543,13 +1763,13 @@ fn radrootsd_unavailable_view( failed_relays: Vec::new(), job_id: None, job_status: None, - signer_mode: Some(config.signer.backend.as_str().to_owned()), + signer_mode: Some("nip46".to_owned()), event_id: None, event_addr: Some(listing_addr), idempotency_key: args.idempotency_key.clone(), signer_session_id: None, requested_signer_session_id: args.signer_session_id.clone(), - reason: Some(RADROOTSD_LISTING_UNAVAILABLE_REASON.to_owned()), + reason: Some(reason.into()), job: None, event: args.print_event.then_some(event_preview), actions: Vec::new(), diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1,9 +1,15 @@ mod support; use std::fs; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; use std::path::Path; +use std::sync::mpsc::{self, Receiver}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; use serde_json::Value; +use serde_json::json; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, @@ -16,6 +22,114 @@ use support::{ const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; +struct JsonRpcRequest { + headers: String, + body: Value, +} + +struct OneShotJsonRpcServer { + endpoint: String, + requests: Receiver<JsonRpcRequest>, + handle: JoinHandle<()>, +} + +impl OneShotJsonRpcServer { + fn listing_publish() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake radrootsd"); + let endpoint = format!( + "http://{}/jsonrpc", + listener.local_addr().expect("fake radrootsd addr") + ); + let (tx, requests) = mpsc::channel(); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept fake radrootsd request"); + let request = read_jsonrpc_request(&mut stream); + tx.send(request).expect("send fake radrootsd request"); + let response = json!({ + "jsonrpc": "2.0", + "id": "radroots-sdk-listing-publish", + "result": { + "deduplicated": false, + "job": { + "job_id": "job_listing_publish_test", + "command": "bridge.listing.publish", + "status": "published", + "terminal": true, + "recovered_after_restart": false, + "signer_mode": "nip46", + "signer_session_id": "session_test", + "event_kind": 30402, + "event_id": "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "event_addr": "30402:daemon_test:radrootsd-router", + "relay_count": 2, + "acknowledged_relay_count": 1 + } + } + }) + .to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + response.len(), + response + ) + .expect("write fake radrootsd response"); + }); + Self { + endpoint, + requests, + handle, + } + } + + fn take_request(self) -> JsonRpcRequest { + let request = self + .requests + .recv_timeout(Duration::from_secs(5)) + .expect("fake radrootsd request"); + self.handle.join().expect("fake radrootsd join"); + request + } +} + +fn read_jsonrpc_request(stream: &mut TcpStream) -> JsonRpcRequest { + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 1024]; + loop { + let count = stream.read(&mut buffer).expect("read fake radrootsd"); + assert!(count > 0, "fake radrootsd request ended before headers"); + bytes.extend_from_slice(&buffer[..count]); + if let Some(header_end) = find_header_end(&bytes) { + let headers = String::from_utf8_lossy(&bytes[..header_end]).to_string(); + let content_length = content_length(&headers); + let body_start = header_end + 4; + while bytes.len() < body_start + content_length { + let count = stream.read(&mut buffer).expect("read fake radrootsd body"); + assert!(count > 0, "fake radrootsd request ended before body"); + bytes.extend_from_slice(&buffer[..count]); + } + let body = serde_json::from_slice(&bytes[body_start..body_start + content_length]) + .expect("fake radrootsd json body"); + return JsonRpcRequest { headers, body }; + } + } +} + +fn find_header_end(bytes: &[u8]) -> Option<usize> { + bytes.windows(4).position(|window| window == b"\r\n\r\n") +} + +fn content_length(headers: &str) -> usize { + headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::<usize>().expect("content length")) + }) + .expect("content-length header") +} + #[test] fn root_help_exposes_only_target_namespaces() { let output = radroots().arg("--help").output().expect("run root help"); @@ -329,31 +443,74 @@ fn radrootsd_listing_publish_reaches_listing_router_without_relay_config() { .as_str() .expect("farm d tag"), ); + sandbox.write_app_config( + r#"[[capability_binding]] +capability = "signer.remote_nip46" +provider = "myc" +target_kind = "explicit_endpoint" +target = "http://myc.invalid" +signer_session_ref = "session_test" +"#, + ); + let server = OneShotJsonRpcServer::listing_publish(); - let (output, value) = sandbox.json_output(&[ - "--format", - "json", - "--publish-mode", - "radrootsd", - "--approval-token", - "approve", - "listing", - "publish", - listing_file.to_string_lossy().as_ref(), - ]); + let output = sandbox + .command() + .env("RADROOTS_RPC_URL", &server.endpoint) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge_test") + .args([ + "--format", + "json", + "--publish-mode", + "radrootsd", + "--approval-token", + "approve", + "--idempotency-key", + "idem_listing", + "listing", + "publish", + listing_file.to_string_lossy().as_ref(), + ]) + .output() + .expect("run radrootsd listing publish"); + let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); + let request = server.take_request(); - assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); + assert!(output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "provider_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "provider"); - assert_contains(&value["errors"][0]["message"], "radrootsd listing publish"); + assert_eq!( + value["result"]["source"], + "radrootsd publish transport · signer session" + ); + assert_eq!(value["result"]["job_id"], "job_listing_publish_test"); + assert_eq!(value["result"]["job_status"], "published"); + assert_eq!(value["result"]["event_id"], "e".repeat(64)); + assert_eq!( + value["result"]["event_addr"], + "30402:daemon_test:radrootsd-router" + ); + assert_eq!(value["result"]["signer_mode"], "nip46"); + assert_eq!(value["result"]["signer_session_id"], "session_test"); + assert_eq!( + value["result"]["requested_signer_session_id"], + "session_test" + ); + assert_eq!(value["result"]["idempotency_key"], "idem_listing"); + assert_eq!( + value["result"]["job"]["rpc_method"], + "bridge.listing.publish" + ); + assert_eq!(value["result"]["job"]["relay_count"], 2); + assert_eq!(value["result"]["job"]["acknowledged_relay_count"], 1); + assert_eq!(request.body["method"], "bridge.listing.publish"); + assert_eq!(request.body["params"]["kind"], 30402); + assert_eq!(request.body["params"]["signer_session_id"], "session_test"); + assert_eq!(request.body["params"]["idempotency_key"], "idem_listing"); assert!( - !value["errors"][0]["message"] - .as_str() - .expect("error message") - .contains("configured relay") + request + .headers + .to_ascii_lowercase() + .contains("authorization: bearer bridge_test") ); } @@ -395,11 +552,11 @@ fn radrootsd_listing_publish_bypasses_relay_signer_preflight() { ]); assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); + assert_eq!(output.status.code(), Some(7)); assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["errors"][0]["code"], "provider_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "provider"); - assert_contains(&value["errors"][0]["message"], "radrootsd listing publish"); + assert_eq!(value["errors"][0]["code"], "signer_unconfigured"); + assert_eq!(value["errors"][0]["detail"]["class"], "signer"); + assert_contains(&value["errors"][0]["message"], "signer_session_id"); assert!( !value["errors"][0]["message"] .as_str() @@ -445,12 +602,12 @@ fn radrootsd_publish_mode_routes_listing_update() { ]); assert!(!output.status.success()); - assert_eq!(output.status.code(), Some(3)); + assert_eq!(output.status.code(), Some(7)); assert_eq!(value["operation_id"], "listing.update"); assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "provider_unavailable"); - assert_eq!(value["errors"][0]["detail"]["class"], "provider"); - assert_contains(&value["errors"][0]["message"], "radrootsd listing publish"); + assert_eq!(value["errors"][0]["code"], "signer_unconfigured"); + assert_eq!(value["errors"][0]["detail"]["class"], "signer"); + assert_contains(&value["errors"][0]["message"], "signer_session_id"); } #[test]