sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 4fc669d02c8bf516c53cf5ad436cdbed5a715f2f
parent 68e9202c767c70508d294c75d6ae21b2659d4a48
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 09:17:38 +0000

publish-proxy: add sdk proxy publish transport

- replace bridge JSON-RPC requests with publish.event transport
- add direct relay versus radrootsd proxy publish selection
- allow daemon-resolved outbox targets for proxy publishing
- cover proxy decoding, outcome mapping, and workflow enqueue paths

Diffstat:
MCargo.lock | 8++++++++
MCargo.toml | 1+
Mcrates/sdk/Cargo.toml | 23++++++++++++++++++++++-
Mcrates/sdk/README | 6++++--
Mcrates/sdk/src/adapters/mod.rs | 2+-
Mcrates/sdk/src/adapters/radrootsd.rs | 563++++++++++++++++++++++++++++++++-----------------------------------------------
Mcrates/sdk/src/error.rs | 12++++++++++++
Mcrates/sdk/src/lib.rs | 6+++---
Mcrates/sdk/src/relay_targets.rs | 10++++++++++
Mcrates/sdk/src/runtime.rs | 43+++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/src/sync_runtime.rs | 240++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/sdk/src/workflow_runtime.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++--------
Mcrates/sdk/tests/runtime_foundation.rs | 9+++++----
Mcrates/sdk/tests/sync_runtime.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 751++++++++++++++++++++-----------------------------------------------------------
Mcrates/sdk/tests/unit/relay_targets_tests.rs | 7+++++++
Mcrates/sdk/tests/unit/runtime_tests.rs | 2++
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 24++++++++++++++++++++++++
Mcrates/sdk/tests/unit/workflow_runtime_tests.rs | 22++++++++++++++++++++++
19 files changed, 1027 insertions(+), 933 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1844,6 +1844,13 @@ dependencies = [ ] [[package]] +name = "radroots_publish_proxy_protocol" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", +] + +[[package]] name = "radroots_relay_transport" version = "0.1.0-alpha.2" dependencies = [ @@ -1987,6 +1994,7 @@ dependencies = [ "radroots_nostr_connect", "radroots_nostr_signer", "radroots_outbox", + "radroots_publish_proxy_protocol", "radroots_relay_transport", "radroots_replica_db", "radroots_replica_db_schema", diff --git a/Cargo.toml b/Cargo.toml @@ -40,6 +40,7 @@ radroots_nostr = { path = "../lib/crates/nostr", version = "0.1.0-alpha.2", defa radroots_nostr_connect = { path = "../lib/crates/nostr_connect", version = "0.1.0-alpha.2", default-features = false } radroots_nostr_signer = { path = "../lib/crates/nostr_signer", version = "0.1.0-alpha.2", default-features = false } radroots_outbox = { path = "../lib/crates/outbox", version = "0.1.0-alpha.2", default-features = false } +radroots_publish_proxy_protocol = { path = "../lib/crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false } radroots_relay_transport = { path = "../lib/crates/relay_transport", version = "0.1.0-alpha.2", default-features = false } radroots_replica_db = { path = "../lib/crates/replica_db", version = "0.1.0-alpha.2", default-features = false } radroots_replica_db_schema = { path = "../lib/crates/replica_db_schema", version = "0.1.0-alpha.2", default-features = false } diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -32,7 +32,17 @@ identity-models = [ identity-storage = ["identity-models", "std", "radroots_identity/std"] signing = ["dep:radroots_nostr", "nostr"] relay-client = ["signing", "std", "serde_json", "radroots_nostr/client"] -radrootsd-client = ["std", "serde_json", "dep:reqwest"] +radrootsd-proxy = [ + "std", + "serde_json", + "dep:futures", + "dep:radroots_publish_proxy_protocol", + "dep:radroots_relay_transport", + "dep:reqwest", + "radroots_publish_proxy_protocol/serde", + "radroots_publish_proxy_protocol/std", + "radroots_relay_transport/std", +] signer-adapters = [ "identity-models", "signing", @@ -78,13 +88,24 @@ local-runtime = [ "signing", "relay-client", ] +local-runtime-radrootsd-proxy = [ + "std", + "serde", + "serde_json", + "runtime", + "local-signer", + "signing", + "radrootsd-proxy", +] [dependencies] +futures = { workspace = true, optional = true } radroots_authority = { workspace = true, optional = true, default-features = false } radroots_event_store = { workspace = true, optional = true, default-features = false } radroots_events = { workspace = true, default-features = false } radroots_events_codec = { workspace = true, default-features = false } radroots_outbox = { workspace = true, optional = true, default-features = false } +radroots_publish_proxy_protocol = { workspace = true, optional = true, default-features = false } radroots_relay_transport = { workspace = true, optional = true, default-features = false } radroots_trade = { workspace = true, default-features = false } radroots_identity = { workspace = true, optional = true, default-features = false } diff --git a/crates/sdk/README b/crates/sdk/README @@ -30,6 +30,8 @@ signed outbox work when `relay-runtime` is enabled. Push time uses the relay targets already stored on each queued outbox event, so already queued work does not require configured builder relays. `push_outbox_with_adapter(...)` remains available for tests and controlled adapter-level substrate checks. +`radrootsd-proxy` adds the Radrootsd Publish Proxy transport for daemon-resolved +publishing through `publish.event`. The `local-runtime` feature is the curated feature bundle for local product runtime consumers. It enables `std`, `serde`, `serde_json`, `runtime`, @@ -37,8 +39,8 @@ runtime consumers. It enables `std`, `serde`, `serde_json`, `runtime`, is retained in this bundle only for current direct relay publish callers that have not migrated to the product runtime yet; it is classified for removal once those callers are SDK-runtime backed. `local-runtime` does not enable -`radrootsd-client`. CLI, app, and local tooling consumers should depend on this -feature directly so they stay on the same runtime contract. +`radrootsd-proxy`; use `local-runtime-radrootsd-proxy` when the runtime should +publish through Radrootsd instead of a direct relay transport. Relay URL policy is explicit. Public relay URLs must use `wss://`. Local development `ws://` relay URLs are accepted only under `SdkRelayUrlPolicy::Localhost` diff --git a/crates/sdk/src/adapters/mod.rs b/crates/sdk/src/adapters/mod.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "radrootsd-client")] +#[cfg(feature = "radrootsd-proxy")] pub mod radrootsd; #[cfg(feature = "relay-client")] pub mod relay; diff --git a/crates/sdk/src/adapters/radrootsd.rs b/crates/sdk/src/adapters/radrootsd.rs @@ -1,16 +1,23 @@ use core::fmt; use core::time::Duration; -use crate::farm::RadrootsFarm; -use crate::listing; -use crate::listing::RadrootsListing; -use crate::profile::{RadrootsProfile, RadrootsProfileType}; -use radroots_events::RadrootsNostrEvent; -use radroots_events::kinds::KIND_LISTING; +use radroots_events::draft::RadrootsSignedNostrEvent; +use radroots_publish_proxy_protocol::{ + METHOD_EVENT, PublishDeliveryPolicy, PublishEventRequest, PublishEventResponse, + PublishProxyProtocolError, PublishRelayOutcomeKind, PublishRelayPolicy, SignedNostrEventWire, +}; +use radroots_relay_transport::{ + RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, + RadrootsRelayPublishReceipt, RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, + RadrootsRelayTransportError, +}; use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue}; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::{Value, json}; +pub const SDK_RADROOTSD_PROXY_REQUEST_ID: &str = "radroots-sdk-publish-event"; +pub const SDK_RADROOTSD_PROXY_MAX_RELAYS: usize = 20; + #[derive(Clone, PartialEq, Eq, Default, Serialize, Deserialize)] pub enum RadrootsdAuth { #[default] @@ -27,365 +34,160 @@ impl fmt::Debug for RadrootsdAuth { } } -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SdkRadrootsdSignerAuthority { - pub provider_runtime_id: String, - pub account_identity_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub provider_signer_session_id: Option<String>, -} - -impl fmt::Debug for SdkRadrootsdSignerAuthority { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdSignerAuthority"); - debug.field("provider_runtime_id", &self.provider_runtime_id); - debug.field("account_identity_id", &self.account_identity_id); - debug.field( - "provider_signer_session_id", - &self - .provider_signer_session_id - .as_ref() - .map(|_| "<redacted>"), - ); - debug.finish() - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub enum SdkRadrootsdSignerSessionMode { - #[serde(alias = "bunker")] - Bunker, - #[serde(alias = "nostrconnect")] - Nostrconnect, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SdkRadrootsdSignerSessionRole { - InboundLocalSigner, - OutboundRemoteSigner, +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsdProxyConfig { + pub endpoint: String, + pub auth: RadrootsdAuth, + pub relay_policy: PublishRelayPolicy, + pub timeout: Duration, + pub request_timeout_ms: Option<u64>, } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SdkRadrootsdBridgeDeliveryPolicy { - Any, - Quorum, - All, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SdkRadrootsdBridgeJobStatus { - Accepted, - Published, - Failed, -} - -#[derive(Clone, PartialEq, Eq, Serialize)] -pub struct SdkRadrootsdSignerSessionConnectRequest { - pub url: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_secret_key: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_authority: Option<SdkRadrootsdSignerAuthority>, -} - -impl SdkRadrootsdSignerSessionConnectRequest { - pub fn bunker(url: impl Into<String>) -> Self { +impl RadrootsdProxyConfig { + pub fn new(endpoint: impl Into<String>) -> Self { Self { - url: url.into(), - client_secret_key: None, - signer_authority: None, + endpoint: endpoint.into(), + auth: RadrootsdAuth::None, + relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, + timeout: Duration::from_secs(10), + request_timeout_ms: None, } } - pub fn nostrconnect(url: impl Into<String>, client_secret_key: impl Into<String>) -> Self { - Self { - url: url.into(), - client_secret_key: Some(client_secret_key.into()), - signer_authority: None, - } + pub fn with_auth(mut self, auth: RadrootsdAuth) -> Self { + self.auth = auth; + self } - pub fn with_signer_authority(mut self, signer_authority: SdkRadrootsdSignerAuthority) -> Self { - self.signer_authority = Some(signer_authority); + pub fn with_relay_policy(mut self, relay_policy: PublishRelayPolicy) -> Self { + self.relay_policy = relay_policy; self } -} -impl fmt::Debug for SdkRadrootsdSignerSessionConnectRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdSignerSessionConnectRequest"); - debug.field("url", &self.url); - debug.field( - "client_secret_key", - &self.client_secret_key.as_ref().map(|_| "<redacted>"), - ); - debug.field("signer_authority", &self.signer_authority); - debug.finish() + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self } -} -#[derive(Clone, Serialize)] -pub struct SdkRadrootsdProfilePublishRequest { - pub profile: RadrootsProfile, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_type: Option<RadrootsProfileType>, - pub signer_session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_authority: Option<SdkRadrootsdSignerAuthority>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, -} - -impl fmt::Debug for SdkRadrootsdProfilePublishRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdProfilePublishRequest"); - debug.field("profile", &self.profile); - debug.field("profile_type", &self.profile_type); - debug.field("signer_session_id", &"<redacted>"); - debug.field("signer_authority", &self.signer_authority); - debug.field("idempotency_key", &self.idempotency_key); - debug.finish() + pub fn with_request_timeout_ms(mut self, timeout_ms: u64) -> Self { + self.request_timeout_ms = Some(timeout_ms); + self } } -#[derive(Clone, Serialize)] -pub struct SdkRadrootsdFarmPublishRequest { - pub farm: RadrootsFarm, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kind: Option<u32>, - pub signer_session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_authority: Option<SdkRadrootsdSignerAuthority>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsdProxyPublishAdapter { + config: RadrootsdProxyConfig, } -impl fmt::Debug for SdkRadrootsdFarmPublishRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdFarmPublishRequest"); - debug.field("farm", &self.farm); - debug.field("kind", &self.kind); - debug.field("signer_session_id", &"<redacted>"); - debug.field("signer_authority", &self.signer_authority); - debug.field("idempotency_key", &self.idempotency_key); - debug.finish() +impl RadrootsdProxyPublishAdapter { + pub fn new(config: RadrootsdProxyConfig) -> Self { + Self { config } } -} -#[derive(Clone, Serialize)] -pub struct SdkRadrootsdListingPublishRequest { - pub listing: RadrootsListing, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub kind: Option<u32>, - pub signer_session_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub signer_authority: Option<SdkRadrootsdSignerAuthority>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, -} - -impl fmt::Debug for SdkRadrootsdListingPublishRequest { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdListingPublishRequest"); - debug.field("listing", &self.listing); - debug.field("kind", &self.kind); - debug.field("signer_session_id", &"<redacted>"); - debug.field("signer_authority", &self.signer_authority); - debug.field("idempotency_key", &self.idempotency_key); - debug.finish() + pub fn config(&self) -> &RadrootsdProxyConfig { + &self.config } -} -impl SdkRadrootsdListingPublishRequest { - pub fn from_event( - event: &RadrootsNostrEvent, - signer_session_id: impl Into<String>, - signer_authority: Option<SdkRadrootsdSignerAuthority>, - idempotency_key: Option<String>, - ) -> Result<Self, listing::RadrootsListingParseError> { - if event.kind != KIND_LISTING { - return Err(listing::RadrootsListingParseError::InvalidKind(event.kind)); - } - Ok(Self { - listing: listing::parse_event(event)?, - kind: Some(event.kind), - signer_session_id: signer_session_id.into(), - signer_authority, - idempotency_key, - }) + pub async fn publish_signed_event( + &self, + request: RadrootsdProxyPublishRequest, + ) -> Result<RadrootsRelayPublishReceipt, RadrootsdError> { + let request = request.into_protocol_request(self.config.relay_policy)?; + request + .validate(SDK_RADROOTSD_PROXY_MAX_RELAYS) + .map_err(RadrootsdError::from_protocol)?; + let response = publish_event( + self.config.endpoint.as_str(), + &self.config.auth, + &request, + self.config.timeout, + ) + .await?; + proxy_receipt_from_response(response) } } -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct SdkRadrootsdBridgePublishResponse { - pub deduplicated: bool, - pub job: SdkRadrootsdBridgeJob, -} - -#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] -pub struct SdkRadrootsdBridgeStatusResponse { - pub enabled: bool, - pub ready: bool, - pub auth_mode: String, - pub signer_mode: String, - pub default_signer_mode: String, - pub supported_signer_modes: Vec<String>, - pub available_nip46_signer_sessions: usize, - pub relay_count: usize, - pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy, - #[serde(default)] - pub delivery_quorum: Option<usize>, - pub publish_max_attempts: usize, - pub publish_initial_backoff_millis: u64, - pub publish_max_backoff_millis: u64, - pub job_status_retention: usize, - pub retained_jobs: usize, - pub retained_idempotency_keys: usize, - pub accepted_jobs: usize, - pub published_jobs: usize, - pub failed_jobs: usize, - pub recovered_failed_jobs: usize, - pub methods: Vec<String>, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct SdkRadrootsdBridgeRelayPublishResult { - pub relay_url: String, - pub acknowledged: bool, - #[serde(default)] - pub detail: Option<String>, -} - -#[derive(Clone, PartialEq, Eq, Deserialize)] -pub struct SdkRadrootsdBridgeJob { - pub job_id: String, - pub command: String, - pub status: String, - pub terminal: bool, - pub recovered_after_restart: bool, - pub signer_mode: String, - #[serde(default)] - pub signer_session_id: Option<String>, - pub event_kind: u32, - #[serde(default)] - pub event_id: Option<String>, - #[serde(default)] - pub event_addr: Option<String>, - pub relay_count: usize, - pub acknowledged_relay_count: usize, -} - -impl fmt::Debug for SdkRadrootsdBridgeJob { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdBridgeJob"); - debug.field("job_id", &self.job_id); - debug.field("command", &self.command); - debug.field("status", &self.status); - debug.field("terminal", &self.terminal); - debug.field("recovered_after_restart", &self.recovered_after_restart); - debug.field("signer_mode", &"<redacted>"); - debug.field( - "signer_session_id", - &self.signer_session_id.as_ref().map(|_| "<redacted>"), - ); - debug.field("event_kind", &self.event_kind); - debug.field("event_id", &self.event_id); - debug.field("event_addr", &self.event_addr); - debug.field("relay_count", &self.relay_count); - debug.field("acknowledged_relay_count", &self.acknowledged_relay_count); - debug.finish() +impl RadrootsRelayPublishAdapter for RadrootsdProxyPublishAdapter { + fn publish<'a>( + &'a self, + request: RadrootsRelayPublishRequest, + ) -> futures::future::BoxFuture< + 'a, + Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>, + > { + Box::pin(async move { + let request = RadrootsdProxyPublishRequest { + delivery_policy: delivery_policy_from_relay_request( + request.targets.len(), + request.accepted_quorum, + ), + signed_event: request.signed_event, + relays: request.targets.relay_strings(), + idempotency_key: None, + timeout_ms: self.config.request_timeout_ms, + }; + let receipt = self + .publish_signed_event(request) + .await + .map_err(|error| RadrootsRelayTransportError::Transport(error.to_string()))?; + Ok(receipt.relays) + }) } } -#[derive(Clone, PartialEq, Eq, Deserialize)] -pub struct SdkRadrootsdBridgeJobView { - pub job_id: String, - pub command: String, - #[serde(default)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsdProxyPublishRequest { + pub signed_event: RadrootsSignedNostrEvent, + pub relays: Vec<String>, + pub delivery_policy: PublishDeliveryPolicy, pub idempotency_key: Option<String>, - pub status: SdkRadrootsdBridgeJobStatus, - pub terminal: bool, - pub recovered_after_restart: bool, - pub requested_at_unix: u64, - #[serde(default)] - pub completed_at_unix: Option<u64>, - pub signer_mode: String, - #[serde(default)] - pub signer_session_id: Option<String>, - pub event_kind: u32, - #[serde(default)] - pub event_id: Option<String>, - #[serde(default)] - pub event_addr: Option<String>, - pub delivery_policy: SdkRadrootsdBridgeDeliveryPolicy, - #[serde(default)] - pub delivery_quorum: Option<usize>, - pub relay_count: usize, - pub acknowledged_relay_count: usize, - pub required_acknowledged_relay_count: usize, - pub attempt_count: usize, - #[serde(default)] - pub attempt_summaries: Vec<String>, - #[serde(default)] - pub relay_results: Vec<SdkRadrootsdBridgeRelayPublishResult>, - pub relay_outcome_summary: String, -} - -impl fmt::Debug for SdkRadrootsdBridgeJobView { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut debug = f.debug_struct("SdkRadrootsdBridgeJobView"); - debug.field("job_id", &self.job_id); - debug.field("command", &self.command); - debug.field("idempotency_key", &self.idempotency_key); - debug.field("status", &self.status); - debug.field("terminal", &self.terminal); - debug.field("recovered_after_restart", &self.recovered_after_restart); - debug.field("requested_at_unix", &self.requested_at_unix); - debug.field("completed_at_unix", &self.completed_at_unix); - debug.field("signer_mode", &self.signer_mode.as_str()); - debug.field( - "signer_session_id", - &self.signer_session_id.as_ref().map(|_| "<redacted>"), - ); - debug.field("event_kind", &self.event_kind); - debug.field("event_id", &self.event_id); - debug.field("event_addr", &self.event_addr); - debug.field("delivery_policy", &self.delivery_policy); - debug.field("delivery_quorum", &self.delivery_quorum); - debug.field("relay_count", &self.relay_count); - debug.field("acknowledged_relay_count", &self.acknowledged_relay_count); - debug.field( - "required_acknowledged_relay_count", - &self.required_acknowledged_relay_count, - ); - debug.field("attempt_count", &self.attempt_count); - debug.field("attempt_summaries", &self.attempt_summaries); - debug.field("relay_results", &self.relay_results); - debug.field("relay_outcome_summary", &self.relay_outcome_summary); - debug.finish() + pub timeout_ms: Option<u64>, +} + +impl RadrootsdProxyPublishRequest { + fn into_protocol_request( + self, + relay_policy: PublishRelayPolicy, + ) -> Result<PublishEventRequest, RadrootsdError> { + Ok(PublishEventRequest { + event: signed_event_wire(&self.signed_event), + relays: self.relays, + relay_policy, + delivery_policy: self.delivery_policy, + idempotency_key: self.idempotency_key, + timeout_ms: self.timeout_ms, + }) } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum RadrootsdError { InvalidAuthHeader(String), + InvalidRequest(String), Http(String), - JsonRpc(String), + JsonRpc { code: i64, message: String }, MalformedResponse(String), } -impl core::fmt::Display for RadrootsdError { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { +impl RadrootsdError { + fn from_protocol(error: PublishProxyProtocolError) -> Self { + Self::InvalidRequest(error.to_string()) + } +} + +impl fmt::Display for RadrootsdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidAuthHeader(value) => { write!(f, "invalid radrootsd bearer token header: {value}") } - Self::Http(value) => write!(f, "{value}"), - Self::JsonRpc(value) => write!(f, "{value}"), - Self::MalformedResponse(value) => write!(f, "{value}"), + Self::InvalidRequest(value) | Self::Http(value) | Self::MalformedResponse(value) => { + f.write_str(value) + } + Self::JsonRpc { code, message } => { + write!(f, "radrootsd jsonrpc failed {code}: {message}") + } } } } @@ -406,17 +208,17 @@ struct JsonRpcError { message: String, } -pub async fn publish_listing( +pub async fn publish_event( endpoint: &str, auth: &RadrootsdAuth, - request: &SdkRadrootsdListingPublishRequest, + request: &PublishEventRequest, timeout: Duration, -) -> Result<SdkRadrootsdBridgePublishResponse, RadrootsdError> { +) -> Result<PublishEventResponse, RadrootsdError> { jsonrpc_call( endpoint, auth, - "radroots-sdk-listing-publish", - "bridge.listing.publish", + SDK_RADROOTSD_PROXY_REQUEST_ID, + METHOD_EVENT, request, timeout, ) @@ -436,10 +238,8 @@ fn auth_headers(auth: &RadrootsdAuth) -> Result<HeaderMap, RadrootsdError> { } } -pub fn bridge_listing_publish_request_json( - request: &SdkRadrootsdListingPublishRequest, -) -> Result<Value, RadrootsdError> { - Ok(serde_json::to_value(request).expect("radrootsd listing publish request serializes")) +pub fn publish_event_request_json(request: &PublishEventRequest) -> Result<Value, RadrootsdError> { + Ok(serde_json::to_value(request).expect("radrootsd publish.event request serializes")) } fn http_status_error(status: reqwest::StatusCode, body: &str) -> RadrootsdError { @@ -479,10 +279,10 @@ where } match (envelope.result, envelope.error) { (Some(result), None) => Ok(result), - (None, Some(error)) => Err(RadrootsdError::JsonRpc(format!( - "radrootsd {method} failed {}: {}", - error.code, error.message - ))), + (None, Some(error)) => Err(RadrootsdError::JsonRpc { + code: error.code, + message: error.message, + }), (Some(_), Some(error)) => Err(RadrootsdError::MalformedResponse(format!( "radrootsd {method} returned result and error: {} {}", error.code, error.message @@ -538,6 +338,99 @@ where decode_jsonrpc_response(method, request_id, body.as_str()) } +fn signed_event_wire(event: &RadrootsSignedNostrEvent) -> SignedNostrEventWire { + SignedNostrEventWire { + id: event.id.clone(), + pubkey: event.pubkey.clone(), + created_at: event.created_at as u64, + kind: event.kind, + tags: event.tags.clone(), + content: event.content.clone(), + sig: event.sig.clone(), + } +} + +fn delivery_policy_from_relay_request( + target_count: usize, + accepted_quorum: usize, +) -> PublishDeliveryPolicy { + if accepted_quorum >= target_count { + PublishDeliveryPolicy::All + } else if accepted_quorum <= 1 { + PublishDeliveryPolicy::Any + } else { + PublishDeliveryPolicy::Quorum { + quorum: accepted_quorum, + } + } +} + +fn proxy_receipt_from_response( + response: PublishEventResponse, +) -> Result<RadrootsRelayPublishReceipt, RadrootsdError> { + response + .job + .validate() + .map_err(RadrootsdError::from_protocol)?; + let quorum = response + .job + .delivery_policy + .required_ack_count(response.job.relay_count); + let attempted_count = response + .job + .relays + .iter() + .filter(|relay| relay.attempted) + .count(); + let relays = response + .job + .relays + .into_iter() + .map(|relay| RadrootsRelayPublishRelayReceipt { + relay_url: relay.relay_url, + attempted: relay.attempted, + outcome: RadrootsRelayOutcome { + kind: relay_outcome_kind(relay.outcome_kind), + message: relay.message, + }, + }) + .collect(); + Ok(RadrootsRelayPublishReceipt { + event_id: response.job.event_id, + attempted_count, + accepted_count: response.job.acknowledged_count, + retryable_count: response.job.retryable_count, + terminal_count: response.job.terminal_count, + quorum, + quorum_met: response.job.delivery_satisfied, + relays, + }) +} + +fn relay_outcome_kind(kind: PublishRelayOutcomeKind) -> RadrootsRelayOutcomeKind { + match kind { + PublishRelayOutcomeKind::Accepted => RadrootsRelayOutcomeKind::Accepted, + PublishRelayOutcomeKind::DuplicateAccepted => RadrootsRelayOutcomeKind::DuplicateAccepted, + PublishRelayOutcomeKind::Blocked => RadrootsRelayOutcomeKind::Blocked, + PublishRelayOutcomeKind::RateLimited => RadrootsRelayOutcomeKind::RateLimited, + PublishRelayOutcomeKind::Invalid => RadrootsRelayOutcomeKind::Invalid, + PublishRelayOutcomeKind::PowRequired => RadrootsRelayOutcomeKind::PowRequired, + PublishRelayOutcomeKind::Restricted => RadrootsRelayOutcomeKind::Restricted, + PublishRelayOutcomeKind::AuthRequired => RadrootsRelayOutcomeKind::AuthRequired, + PublishRelayOutcomeKind::Muted => RadrootsRelayOutcomeKind::Muted, + PublishRelayOutcomeKind::Unsupported => RadrootsRelayOutcomeKind::Unsupported, + PublishRelayOutcomeKind::PaymentRequired => RadrootsRelayOutcomeKind::PaymentRequired, + PublishRelayOutcomeKind::Error => RadrootsRelayOutcomeKind::Error, + PublishRelayOutcomeKind::Timeout => RadrootsRelayOutcomeKind::Timeout, + PublishRelayOutcomeKind::ConnectionFailed => RadrootsRelayOutcomeKind::ConnectionFailed, + PublishRelayOutcomeKind::RelayUrlRejected => RadrootsRelayOutcomeKind::RelayUrlRejected, + PublishRelayOutcomeKind::SkippedAlreadyAccepted => { + RadrootsRelayOutcomeKind::SkippedAlreadyAccepted + } + PublishRelayOutcomeKind::Unknown => RadrootsRelayOutcomeKind::Unknown, + } +} + #[cfg(test)] #[path = "../../tests/unit/adapters_radrootsd_tests.rs"] mod tests; diff --git a/crates/sdk/src/error.rs b/crates/sdk/src/error.rs @@ -590,6 +590,18 @@ impl From<radroots_relay_transport::RadrootsRelayTransportError> for RadrootsSdk radroots_relay_transport::RadrootsRelayTransportError::RelayUrlQueryOrFragment { url, } => Self::invalid_relay_url(url, "relay URL must not include query or fragment"), + radroots_relay_transport::RadrootsRelayTransportError::RelayUrlForbiddenDestination { + url, + reason, + } => Self::invalid_relay_url(url, reason), + radroots_relay_transport::RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination { + url, + address, + reason, + } => Self::invalid_relay_url( + url, + format!("relay URL resolved to forbidden address `{address}`: {reason}"), + ), radroots_relay_transport::RadrootsRelayTransportError::EmptyTargetSet => { Self::empty_target_relays("relay publish") } diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -7,7 +7,7 @@ extern crate alloc; #[cfg(feature = "runtime")] mod actor_json; #[cfg(any( - feature = "radrootsd-client", + feature = "radrootsd-proxy", feature = "signing", feature = "relay-client", feature = "signer-adapters" @@ -89,8 +89,8 @@ pub use crate::runtime::{ RadrootsSdkBuilder, RadrootsSdkClock, RadrootsSdkStorageConfig, RadrootsSdkStoragePaths, RadrootsSdkTimestamp, RestoreArchive, RestoreReceipt, RestoreRequest, SdkBackupManifest, SdkBackupManifestKind, SdkBackupState, SdkBackupVerification, SdkEventStoreStorageStatus, - SdkOutboxStorageStatus, SdkRestoreState, SdkSqliteStoreStatus, SdkStorageKind, - StorageStatusReceipt, StorageStatusRequest, + SdkOutboxStorageStatus, SdkPublishTransport, SdkRestoreState, SdkSqliteStoreStatus, + SdkStorageKind, StorageStatusReceipt, StorageStatusRequest, }; #[cfg(feature = "runtime")] pub use crate::sync_runtime::{ diff --git a/crates/sdk/src/relay_targets.rs b/crates/sdk/src/relay_targets.rs @@ -27,6 +27,7 @@ impl SdkRelayUrlPolicy { pub enum SdkRelayTargetPolicy { Explicit(SdkRelayTargetSet), UseConfiguredRelays, + UsePublishTransport, } impl SdkRelayTargetPolicy { @@ -44,6 +45,10 @@ impl SdkRelayTargetPolicy { { Ok(Self::Explicit(SdkRelayTargetSet::new(relays, url_policy)?)) } + + pub fn use_publish_transport() -> Self { + Self::UsePublishTransport + } } impl serde::Serialize for SdkRelayTargetPolicy { @@ -64,6 +69,11 @@ impl serde::Serialize for SdkRelayTargetPolicy { state.serialize_field("kind", "use_configured_relays")?; state.end() } + Self::UsePublishTransport => { + let mut state = serializer.serialize_struct("SdkRelayTargetPolicy", 1)?; + state.serialize_field("kind", "use_publish_transport")?; + state.end() + } } } } diff --git a/crates/sdk/src/runtime.rs b/crates/sdk/src/runtime.rs @@ -17,6 +17,9 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +use crate::adapters::radrootsd::RadrootsdProxyConfig; + #[cfg(feature = "runtime")] const SDK_STORAGE_MANIFEST_VERSION: u16 = 1; #[cfg(feature = "runtime")] @@ -328,6 +331,33 @@ pub enum SdkRestoreState { } #[cfg(feature = "runtime")] +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum SdkPublishTransport { + DirectNostrRelay, + #[cfg(feature = "radrootsd-proxy")] + RadrootsdProxy(RadrootsdProxyConfig), +} + +#[cfg(feature = "runtime")] +impl Default for SdkPublishTransport { + fn default() -> Self { + Self::DirectNostrRelay + } +} + +#[cfg(feature = "runtime")] +impl SdkPublishTransport { + pub(crate) fn supports_delegated_relay_resolution(&self) -> bool { + match self { + Self::DirectNostrRelay => false, + #[cfg(feature = "radrootsd-proxy")] + Self::RadrootsdProxy(_) => true, + } + } +} + +#[cfg(feature = "runtime")] #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] pub struct RestoreArchive { pub source: PathBuf, @@ -360,6 +390,7 @@ pub struct RadrootsSdkBuilder { clock: RadrootsSdkClock, relay_urls: Vec<String>, relay_url_policy: SdkRelayUrlPolicy, + publish_transport: SdkPublishTransport, } #[cfg(feature = "runtime")] @@ -370,6 +401,7 @@ impl Default for RadrootsSdkBuilder { clock: RadrootsSdkClock::System, relay_urls: Vec::new(), relay_url_policy: SdkRelayUrlPolicy::Public, + publish_transport: SdkPublishTransport::DirectNostrRelay, } } } @@ -406,6 +438,11 @@ impl RadrootsSdkBuilder { self } + pub fn publish_transport(mut self, transport: SdkPublishTransport) -> Self { + self.publish_transport = transport; + self + } + pub async fn build(self) -> Result<RadrootsSdk, RadrootsSdkError> { let storage = open_storage(&self.storage).await?; let relay_urls = @@ -416,6 +453,7 @@ impl RadrootsSdkBuilder { storage_paths: storage.paths, clock: self.clock, relay_urls, + publish_transport: self.publish_transport, }) } } @@ -428,6 +466,7 @@ pub struct RadrootsSdk { storage_paths: Option<RadrootsSdkStoragePaths>, clock: RadrootsSdkClock, relay_urls: Vec<String>, + publish_transport: SdkPublishTransport, } #[cfg(feature = "runtime")] @@ -460,6 +499,10 @@ impl RadrootsSdk { &self.relay_urls } + pub fn publish_transport(&self) -> &SdkPublishTransport { + &self.publish_transport + } + pub fn storage_paths(&self) -> Option<&RadrootsSdkStoragePaths> { self.storage_paths.as_ref() } diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -1,3 +1,7 @@ +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +use crate::adapters::radrootsd::{ + RadrootsdError, RadrootsdProxyPublishAdapter, RadrootsdProxyPublishRequest, +}; #[cfg(feature = "runtime")] use crate::{RadrootsSdkError, SdkRelayUrlPolicy, SyncClient, runtime::sdk_now_ms}; #[cfg(feature = "runtime")] @@ -7,7 +11,11 @@ use radroots_events::ids::RadrootsEventId; #[cfg(all(feature = "runtime", feature = "relay-runtime"))] use radroots_nostr::prelude::RadrootsNostrClient; #[cfg(feature = "runtime")] -use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary}; +use radroots_outbox::{ + RadrootsOutboxClaimedEvent, RadrootsOutboxEventState, RadrootsOutboxStatusSummary, +}; +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +use radroots_publish_proxy_protocol::PublishDeliveryPolicy; #[cfg(all(feature = "runtime", feature = "relay-runtime"))] use radroots_relay_transport::RadrootsNostrClientPublishAdapter; #[cfg(feature = "runtime")] @@ -310,9 +318,14 @@ pub enum PushOutboxRelayOutcomeKind { PowRequired, Restricted, AuthRequired, + Muted, + Unsupported, + PaymentRequired, Error, Timeout, ConnectionFailed, + RelayUrlRejected, + SkippedAlreadyAccepted, Unknown, } @@ -328,9 +341,14 @@ impl From<RadrootsRelayOutcomeKind> for PushOutboxRelayOutcomeKind { RadrootsRelayOutcomeKind::PowRequired => Self::PowRequired, RadrootsRelayOutcomeKind::Restricted => Self::Restricted, RadrootsRelayOutcomeKind::AuthRequired => Self::AuthRequired, + RadrootsRelayOutcomeKind::Muted => Self::Muted, + RadrootsRelayOutcomeKind::Unsupported => Self::Unsupported, + RadrootsRelayOutcomeKind::PaymentRequired => Self::PaymentRequired, RadrootsRelayOutcomeKind::Error => Self::Error, RadrootsRelayOutcomeKind::Timeout => Self::Timeout, RadrootsRelayOutcomeKind::ConnectionFailed => Self::ConnectionFailed, + RadrootsRelayOutcomeKind::RelayUrlRejected => Self::RelayUrlRejected, + RadrootsRelayOutcomeKind::SkippedAlreadyAccepted => Self::SkippedAlreadyAccepted, RadrootsRelayOutcomeKind::Unknown => Self::Unknown, } } @@ -361,20 +379,30 @@ impl<'sdk> SyncClient<'sdk> { &self, request: PushOutboxRequest, ) -> Result<PushOutboxReceipt, RadrootsSdkError> { - #[cfg(feature = "relay-runtime")] - { - let adapter = - RadrootsNostrClientPublishAdapter::new(RadrootsNostrClient::new_signerless()); - self.push_outbox_with_adapter(&adapter, request).await - } - - #[cfg(not(feature = "relay-runtime"))] - { - let _ = request; - Err(RadrootsSdkError::ProductSyncUnsupported { - operation: "sync.push_outbox", - required_feature: "relay-runtime", - }) + match self.sdk.publish_transport() { + crate::runtime::SdkPublishTransport::DirectNostrRelay => { + #[cfg(feature = "relay-runtime")] + { + let adapter = RadrootsNostrClientPublishAdapter::new( + RadrootsNostrClient::new_signerless(), + ); + self.push_outbox_with_adapter(&adapter, request).await + } + + #[cfg(not(feature = "relay-runtime"))] + { + let _ = request; + Err(RadrootsSdkError::ProductSyncUnsupported { + operation: "sync.push_outbox", + required_feature: "relay-runtime", + }) + } + } + #[cfg(feature = "radrootsd-proxy")] + crate::runtime::SdkPublishTransport::RadrootsdProxy(config) => { + let adapter = RadrootsdProxyPublishAdapter::new(config.clone()); + self.push_outbox_with_proxy_adapter(&adapter, request).await + } } } @@ -427,6 +455,188 @@ impl<'sdk> SyncClient<'sdk> { } Ok(receipt) } + + #[cfg(feature = "radrootsd-proxy")] + pub async fn push_outbox_with_proxy_adapter( + &self, + adapter: &RadrootsdProxyPublishAdapter, + request: PushOutboxRequest, + ) -> Result<PushOutboxReceipt, RadrootsSdkError> { + request.validate()?; + let mut receipt = PushOutboxReceipt::default(); + for _ in 0..request.limit { + let claim_now_ms = sdk_now_ms(self.sdk)?; + let claim_token = push_outbox_claim_token(); + let Some(claimed) = self + .sdk + ._outbox + .claim_next_ready_signed_event( + CLAIM_OWNER, + claim_token.as_str(), + claim_now_ms.saturating_add(request.claim_ttl_ms), + claim_now_ms, + ) + .await? + else { + break; + }; + let publish_now_ms = claim_now_ms; + let publish = push_proxy_claimed_outbox_event( + self, + adapter, + &claimed, + request.next_attempt_delay_ms, + publish_now_ms, + ) + .await?; + receipt.push_event(push_event_receipt( + claimed.outbox_event_id, + push_event_final_state(&publish), + publish, + )); + } + Ok(receipt) + } +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +async fn push_proxy_claimed_outbox_event( + sync: &SyncClient<'_>, + adapter: &RadrootsdProxyPublishAdapter, + claimed: &RadrootsOutboxClaimedEvent, + next_attempt_delay_ms: i64, + now_ms: i64, +) -> Result<RadrootsRelayPublishReceipt, RadrootsSdkError> { + let signed_event = claimed.signed_event.clone().ok_or( + radroots_relay_transport::RadrootsRelayTransportError::MissingSignedOutboxEvent( + claimed.outbox_event_id, + ), + )?; + sync.sdk + ._outbox + .ingest_signed_event_local( + &sync.sdk._event_store, + claimed.outbox_event_id, + claimed.claim_token.as_str(), + now_ms, + ) + .await?; + let request = RadrootsdProxyPublishRequest { + signed_event: signed_event.clone(), + relays: claimed.target_relays.clone(), + delivery_policy: proxy_delivery_policy(claimed.target_relays.len()), + idempotency_key: Some(proxy_outbox_idempotency_key( + claimed.outbox_event_id, + signed_event.id.as_str(), + )), + timeout_ms: adapter.config().request_timeout_ms, + }; + let publish = match adapter.publish_signed_event(request).await { + Ok(publish) => publish, + Err(error) => { + let message = proxy_error_message(&error); + sync.sdk + ._outbox + .mark_publish_retryable( + claimed.outbox_event_id, + claimed.claim_token.as_str(), + message.as_str(), + now_ms.saturating_add(next_attempt_delay_ms), + now_ms, + ) + .await?; + return Ok(proxy_transport_error_receipt(signed_event.id)); + } + }; + complete_proxy_publish_attempt(sync, claimed, &publish, next_attempt_delay_ms, now_ms).await?; + Ok(publish) +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn proxy_delivery_policy(target_count: usize) -> PublishDeliveryPolicy { + if target_count == 0 { + PublishDeliveryPolicy::Any + } else { + PublishDeliveryPolicy::All + } +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn proxy_outbox_idempotency_key(outbox_event_id: i64, event_id: &str) -> String { + format!("radroots-sdk-outbox-{outbox_event_id}-{event_id}") +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +async fn complete_proxy_publish_attempt( + sync: &SyncClient<'_>, + claimed: &RadrootsOutboxClaimedEvent, + publish: &RadrootsRelayPublishReceipt, + next_attempt_delay_ms: i64, + now_ms: i64, +) -> Result<(), RadrootsSdkError> { + if publish.quorum_met { + sync.sdk + ._outbox + .set_publish_quorum( + claimed.outbox_event_id, + claimed.claim_token.as_str(), + 0, + now_ms, + ) + .await?; + sync.sdk + ._outbox + .complete_publish_attempt( + claimed.outbox_event_id, + claimed.claim_token.as_str(), + "radrootsd proxy publish incomplete", + "radrootsd proxy publish terminal", + now_ms.saturating_add(next_attempt_delay_ms), + now_ms, + ) + .await?; + } else if publish.retryable_count > 0 { + sync.sdk + ._outbox + .mark_publish_retryable( + claimed.outbox_event_id, + claimed.claim_token.as_str(), + "radrootsd proxy publish incomplete", + now_ms.saturating_add(next_attempt_delay_ms), + now_ms, + ) + .await?; + } else { + sync.sdk + ._outbox + .mark_publish_failed_terminal( + claimed.outbox_event_id, + claimed.claim_token.as_str(), + "radrootsd proxy publish terminal", + now_ms, + ) + .await?; + } + Ok(()) +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn proxy_transport_error_receipt(event_id: String) -> RadrootsRelayPublishReceipt { + RadrootsRelayPublishReceipt { + event_id, + attempted_count: 1, + accepted_count: 0, + retryable_count: 1, + terminal_count: 0, + quorum: 1, + quorum_met: false, + relays: Vec::new(), + } +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn proxy_error_message(error: &RadrootsdError) -> String { + format!("radrootsd proxy publish failed: {error}") } #[cfg(feature = "runtime")] diff --git a/crates/sdk/src/workflow_runtime.rs b/crates/sdk/src/workflow_runtime.rs @@ -42,7 +42,7 @@ pub(crate) async fn enqueue_signed_workflow( request.operation_kind, request.frozen_draft.expected_event_id.as_str(), request.frozen_draft.expected_pubkey.as_str(), - target_relays.canonical_relays(), + target_relays.canonical_relays.as_slice(), ), }; let observed_at_ms = sdk_now_ms(sdk)?; @@ -52,8 +52,8 @@ pub(crate) async fn enqueue_signed_workflow( let ingest = RadrootsEventIngest::new(event, observed_at_ms) .with_raw_json(signed_event.raw_json.clone()); let ingest_receipt = sdk._event_store.ingest_event(ingest).await?; - let canonical_target_relays = target_relays.canonical_relays().to_vec(); - let target_relay_values = target_relays.into_vec(); + let canonical_target_relays = target_relays.canonical_relays.clone(); + let target_relay_values = target_relays.relays; let partial_failure_digest_prefix = outbox_idempotency_digest_prefix( request.operation_kind, request.frozen_draft, @@ -65,6 +65,7 @@ pub(crate) async fn enqueue_signed_workflow( signed_event, target_relay_values, idempotency_key, + target_relays.allow_empty_target_relays, ingest_receipt.inserted, observed_at_ms, ); @@ -101,14 +102,46 @@ pub(crate) async fn enqueue_signed_workflow( }) } +struct SdkResolvedRelayTargets { + relays: Vec<String>, + canonical_relays: Vec<String>, + allow_empty_target_relays: bool, +} + fn resolved_target_relays( sdk: &RadrootsSdk, target_relays: &SdkRelayTargetPolicy, -) -> Result<SdkRelayTargetSet, RadrootsSdkError> { +) -> Result<SdkResolvedRelayTargets, RadrootsSdkError> { match target_relays { - SdkRelayTargetPolicy::Explicit(target_relays) => Ok(target_relays.clone()), + SdkRelayTargetPolicy::Explicit(target_relays) => Ok(SdkResolvedRelayTargets { + relays: target_relays.relays().to_vec(), + canonical_relays: target_relays.canonical_relays().to_vec(), + allow_empty_target_relays: false, + }), SdkRelayTargetPolicy::UseConfiguredRelays => { - SdkRelayTargetSet::from_normalized_relays(sdk.relay_urls().to_vec()) + let target_relays = + SdkRelayTargetSet::from_normalized_relays(sdk.relay_urls().to_vec())?; + Ok(SdkResolvedRelayTargets { + relays: target_relays.relays().to_vec(), + canonical_relays: target_relays.canonical_relays().to_vec(), + allow_empty_target_relays: false, + }) + } + SdkRelayTargetPolicy::UsePublishTransport => { + if sdk + .publish_transport() + .supports_delegated_relay_resolution() + { + Ok(SdkResolvedRelayTargets { + relays: Vec::new(), + canonical_relays: Vec::new(), + allow_empty_target_relays: true, + }) + } else { + Err(RadrootsSdkError::empty_target_relays( + "publish transport relay resolution", + )) + } } } } @@ -153,10 +186,11 @@ fn signed_outbox_input( signed_event: RadrootsSignedNostrEvent, target_relays: Vec<String>, idempotency_key: SdkIdempotencyKey, + allow_empty_target_relays: bool, event_store_inserted: bool, observed_at_ms: i64, ) -> RadrootsOutboxSignedOperationInput { - RadrootsOutboxSignedOperationInput::new( + let input = RadrootsOutboxSignedOperationInput::new( operation_kind, frozen_draft.clone(), signed_event, @@ -165,7 +199,12 @@ fn signed_outbox_input( observed_at_ms, observed_at_ms, ) - .with_idempotency_key(idempotency_key.into_string()) + .with_idempotency_key(idempotency_key.into_string()); + if allow_empty_target_relays { + input.allow_empty_target_relays() + } else { + input + } } fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEvent { diff --git a/crates/sdk/tests/runtime_foundation.rs b/crates/sdk/tests/runtime_foundation.rs @@ -48,10 +48,11 @@ async fn sdk_builder_rejects_ws_relay_without_localhost_policy() { .build() .await; - assert!(matches!( - result, - Err(RadrootsSdkError::InvalidRelayUrl { .. }) - )); + match result { + Err(RadrootsSdkError::InvalidRelayUrl { .. }) => {} + Err(error) => panic!("unexpected builder error: {error}"), + Ok(_) => panic!("builder accepted ws relay without localhost policy"), + } } #[test] diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs @@ -30,8 +30,16 @@ use radroots_sdk::{ SdkBackupManifestKind, SdkRelayAuthPolicy, SdkRelayTargetPolicy, SdkRelayUrlPolicy, SdkRestoreState, StorageStatusRequest, SyncStatusRequest, SyncStatusSource, }; +#[cfg(feature = "radrootsd-proxy")] +use radroots_sdk::{SdkPublishTransport, adapters::radrootsd::RadrootsdProxyConfig}; +#[cfg(feature = "radrootsd-proxy")] +use std::io::{Read, Write}; +#[cfg(feature = "radrootsd-proxy")] +use std::net::TcpListener; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +#[cfg(feature = "radrootsd-proxy")] +use std::thread::JoinHandle; use std::time::Duration; const SELLER: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; @@ -55,6 +63,11 @@ struct FixtureSigner { struct TransportFailurePublishAdapter; +#[cfg(feature = "radrootsd-proxy")] +struct RecordedProxyRequest { + body: String, +} + #[derive(Clone)] struct RecordingPublishAdapter { delay: Duration, @@ -62,6 +75,96 @@ struct RecordingPublishAdapter { request_times_ms: Arc<Mutex<Vec<i64>>>, } +#[cfg(feature = "radrootsd-proxy")] +fn spawn_publish_proxy_server() -> (String, JoinHandle<RecordedProxyRequest>) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy server"); + let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept"); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let read = stream.read(&mut buffer).expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if request.windows(4).any(|window| window == b"\r\n\r\n") { + let headers_end = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .expect("headers end") + + 4; + let header_text = String::from_utf8_lossy(&request[..headers_end]); + let content_length = header_text + .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")) + }) + .unwrap_or(0); + while request.len() < headers_end + content_length { + let read = stream.read(&mut buffer).expect("read body"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + break; + } + } + let request_text = String::from_utf8_lossy(&request); + let (_, body) = request_text.split_once("\r\n\r\n").expect("request body"); + let body_json: serde_json::Value = serde_json::from_str(body).expect("body json"); + let event = &body_json["params"]["event"]; + let response_body = serde_json::json!({ + "jsonrpc": "2.0", + "id": body_json["id"], + "result": { + "deduplicated": false, + "job": { + "job_id": "job-1", + "status": "delivery_satisfied", + "terminal": true, + "delivery_satisfied": true, + "event_id": event["id"], + "pubkey": event["pubkey"], + "event_kind": event["kind"], + "relay_policy": body_json["params"]["relay_policy"], + "delivery_policy": body_json["params"]["delivery_policy"], + "relay_count": 1, + "acknowledged_count": 1, + "retryable_count": 0, + "terminal_count": 0, + "requested_at_ms": 1700000000000i64, + "completed_at_ms": 1700000000100i64, + "relays": [{ + "relay_url": "wss://daemon-resolved.example.com", + "source": "daemon_default", + "attempted": true, + "outcome_kind": "accepted", + "message": "accepted" + }] + } + } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + response_body.len(), + response_body + ); + stream + .write_all(response.as_bytes()) + .expect("write response"); + RecordedProxyRequest { + body: body.to_owned(), + } + }); + (endpoint, handle) +} + impl RadrootsRelayPublishAdapter for TransportFailurePublishAdapter { fn publish<'a>( &'a self, @@ -1185,6 +1288,79 @@ async fn push_outbox_empty_queue_returns_zero_counts() { assert!(adapter.captured_raw_events().is_empty()); } +#[cfg(feature = "radrootsd-proxy")] +#[tokio::test] +async fn product_push_outbox_uses_radrootsd_proxy_transport_with_daemon_resolved_relays() { + let (endpoint, handle) = spawn_publish_proxy_server(); + let tempdir = tempfile::tempdir().expect("tempdir"); + let sdk = RadrootsSdk::builder() + .directory_storage(tempdir.path().join("sdk")) + .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000)) + .publish_transport(SdkPublishTransport::RadrootsdProxy( + RadrootsdProxyConfig::new(endpoint), + )) + .build() + .await + .expect("sdk"); + + let enqueue = sdk + .listings() + .enqueue_publish( + ListingEnqueuePublishRequest::new( + actor(), + listing(LISTING_A_D_TAG, "Proxy Coffee"), + SdkRelayTargetPolicy::use_publish_transport(), + ), + &FixtureSigner::new(SELLER), + ) + .await + .expect("enqueue"); + + let receipt = sdk + .sync() + .push_outbox(PushOutboxRequest::new().with_limit(1)) + .await + .expect("proxy push"); + + assert_eq!(receipt.attempted_events, 1); + assert_eq!(receipt.published_events, 1); + assert_eq!(receipt.events[0].outbox_event_id, enqueue.outbox_event_id); + assert_eq!( + receipt.events[0].final_state, + PushOutboxEventState::Published + ); + assert_eq!(receipt.events[0].relays.len(), 1); + assert_eq!( + receipt.events[0].relays[0].relay_url, + "wss://daemon-resolved.example.com" + ); + assert_eq!( + receipt.events[0].relays[0].outcome_kind, + PushOutboxRelayOutcomeKind::Accepted + ); + + let recorded = handle.join().expect("proxy request"); + let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); + assert_eq!(body["method"], "publish.event"); + assert_eq!(body["params"]["relays"], serde_json::json!([])); + assert_eq!( + body["params"]["relay_policy"], + "request_then_author_write_then_daemon_default" + ); + assert_eq!(body["params"]["delivery_policy"]["mode"], "any"); + assert!(body["params"]["event"]["sig"].as_str().is_some()); + assert!(!recorded.body.contains("bridge.")); + assert!(!recorded.body.contains("signer_session_id")); + + let status = sdk + .sync() + .status(SyncStatusRequest::new()) + .await + .expect("status"); + assert_eq!(status.outbox.terminal_events, 1); + assert_eq!(status.outbox.ready_signed_events, 0); +} + #[test] fn push_outbox_contract_dtos_serialize_deterministically() { let request = PushOutboxRequest::new() diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -1,12 +1,7 @@ use super::*; -use crate::farm::RadrootsFarmRef; -use crate::listing::{ - RadrootsListingAvailability, RadrootsListingBin, RadrootsListingDeliveryMethod, - RadrootsListingLocation, RadrootsListingProduct, RadrootsListingStatus, -}; -use radroots_core::{ - RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, - RadrootsCoreQuantityPrice, RadrootsCoreUnit, +use radroots_publish_proxy_protocol::{ + PublishJobStatus, PublishJobView, PublishRelayOutcome, PublishRelayOutcomeKind, + PublishRelaySource, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -22,18 +17,11 @@ fn spawn_http_server( status: &str, response_body: &str, ) -> (String, JoinHandle<RecordedHttpRequest>) { - spawn_http_server_with_content_length(status, response_body, response_body.len()) -} - -fn spawn_http_server_with_content_length( - status: &str, - response_body: &str, - content_length: usize, -) -> (String, JoinHandle<RecordedHttpRequest>) { let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server"); let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); let status = status.to_owned(); let response_body = response_body.to_owned(); + let content_length = response_body.len(); let handle = std::thread::spawn(move || { let (mut stream, _) = listener.accept().expect("accept"); let mut request = Vec::new(); @@ -51,7 +39,7 @@ fn spawn_http_server_with_content_length( .expect("headers end") + 4; let header_text = String::from_utf8_lossy(&request[..headers_end]); - let content_length = header_text + let request_content_length = header_text .lines() .find_map(|line| { let (name, value) = line.split_once(':')?; @@ -59,7 +47,7 @@ fn spawn_http_server_with_content_length( .then(|| value.trim().parse::<usize>().expect("content length")) }) .unwrap_or(0); - while request.len() < headers_end + content_length { + while request.len() < headers_end + request_content_length { let read = stream.read(&mut buffer).expect("read body"); if read == 0 { break; @@ -94,127 +82,78 @@ fn spawn_http_server_with_content_length( (endpoint, handle) } -fn sample_listing() -> RadrootsListing { - RadrootsListing { - d_tag: "AAAAAAAAAAAAAAAAAAAAAg".parse().expect("listing d tag"), - published_at: None, - farm: RadrootsFarmRef { - pubkey: "a".repeat(64), - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), - }, - product: RadrootsListingProduct { - key: "coffee".into(), - title: "Coffee".into(), - category: "coffee".into(), - summary: Some("Single origin coffee".into()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: "bin-1".parse().expect("primary bin id"), - bins: vec![RadrootsListingBin { - bin_id: "bin-1".parse().expect("bin id"), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1000u32), - RadrootsCoreUnit::MassG, - ), - price_per_canonical_unit: RadrootsCoreQuantityPrice { - amount: RadrootsCoreMoney::new( - RadrootsCoreDecimal::from(20u32), - RadrootsCoreCurrency::USD, - ), - quantity: RadrootsCoreQuantity::new( - RadrootsCoreDecimal::from(1u32), - RadrootsCoreUnit::MassG, - ), - }, - display_amount: None, - display_unit: None, - display_label: None, - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts: None, - inventory_available: Some(RadrootsCoreDecimal::from(5u32)), - availability: Some(RadrootsListingAvailability::Status { - status: RadrootsListingStatus::Active, - }), - delivery_method: Some(RadrootsListingDeliveryMethod::Pickup), - location: Some(RadrootsListingLocation { - primary: "North Farm".into(), - city: None, - region: None, - country: None, - lat: None, - lng: None, - geohash: None, - }), - images: None, +fn signed_event() -> RadrootsSignedNostrEvent { + RadrootsSignedNostrEvent { + id: "a".repeat(64), + pubkey: "b".repeat(64), + created_at: 1_700_000_000, + kind: 30_402, + tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], + content: "{\"name\":\"carrots\"}".to_owned(), + sig: "c".repeat(128), + raw_json: serde_json::json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "created_at": 1_700_000_000u32, + "kind": 30402u32, + "tags": [["d", "listing-1"]], + "content": "{\"name\":\"carrots\"}", + "sig": "c".repeat(128) + }) + .to_string(), } } -fn sample_profile() -> RadrootsProfile { - RadrootsProfile { - name: "North Farm".into(), - display_name: Some("North Farm".into()), - nip05: None, - about: Some("Organic coffee".into()), - website: Some("https://example.com".into()), - picture: None, - banner: None, - lud06: None, - lud16: None, - bot: None, +fn publish_request() -> PublishEventRequest { + PublishEventRequest { + event: signed_event_wire(&signed_event()), + relays: vec!["wss://relay.example.com".to_owned()], + relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, + delivery_policy: PublishDeliveryPolicy::Any, + idempotency_key: Some("idem-1".to_owned()), + timeout_ms: Some(10_000), } } -fn sample_farm() -> RadrootsFarm { - RadrootsFarm { - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".into(), - name: "North Farm".into(), - about: Some("Organic coffee".into()), - website: None, - picture: None, - banner: None, - location: None, - tags: Some(vec!["coffee".into()]), - } -} - -fn sample_listing_event(kind: u32) -> RadrootsNostrEvent { - let listing = sample_listing(); - let parts = listing::build_draft(&listing).expect("listing draft"); - RadrootsNostrEvent { - id: "event-1".into(), - author: listing.farm.pubkey, - created_at: 1, - kind, - tags: parts.as_wire_parts().tags.clone(), - content: parts.as_wire_parts().content.clone(), - sig: String::new(), - } -} - -fn sample_authority() -> SdkRadrootsdSignerAuthority { - SdkRadrootsdSignerAuthority { - provider_runtime_id: "local-runtime".into(), - account_identity_id: "account-1".into(), - provider_signer_session_id: Some("provider-session-secret".into()), +fn job(outcome_kind: PublishRelayOutcomeKind) -> PublishJobView { + PublishJobView { + job_id: "job-1".to_owned(), + status: PublishJobStatus::DeliverySatisfied, + terminal: true, + delivery_satisfied: true, + event_id: "a".repeat(64), + pubkey: "b".repeat(64), + event_kind: 30_402, + relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, + delivery_policy: PublishDeliveryPolicy::Any, + relay_count: 1, + acknowledged_count: usize::from(outcome_kind.counts_toward_quorum()), + retryable_count: usize::from(outcome_kind.is_retryable()), + terminal_count: usize::from(outcome_kind.is_terminal_failure()), + requested_at_ms: 1_700_000_000_000, + completed_at_ms: Some(1_700_000_000_100), + last_error: None, + relays: vec![PublishRelayOutcome { + relay_url: "wss://relay.example.com".to_owned(), + source: PublishRelaySource::Request, + attempted: true, + outcome_kind, + message: Some("relay outcome".to_owned()), + latency_ms: Some(7), + }], } } -fn sample_listing_publish_request() -> SdkRadrootsdListingPublishRequest { - SdkRadrootsdListingPublishRequest { - listing: sample_listing(), - kind: Some(KIND_LISTING), - signer_session_id: "signer-session-secret".into(), - signer_authority: Some(sample_authority()), - idempotency_key: Some("idem-1".into()), - } +fn publish_response_json() -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": SDK_RADROOTSD_PROXY_REQUEST_ID, + "result": { + "deduplicated": false, + "job": job(PublishRelayOutcomeKind::Accepted) + } + }) + .to_string() } fn assert_message(error: RadrootsdError, fragment: &str) { @@ -226,495 +165,179 @@ fn assert_message(error: RadrootsdError, fragment: &str) { } #[test] -fn auth_headers_omit_authorization_when_auth_is_none() { - let headers = auth_headers(&RadrootsdAuth::None).expect("headers"); - - assert!(!headers.contains_key(AUTHORIZATION)); -} - -#[test] -fn auth_headers_build_bearer_authorization() { - let headers = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("headers"); +fn auth_headers_omit_or_redact_bearer_authorization() { + let none = auth_headers(&RadrootsdAuth::None).expect("none auth"); + assert!(!none.contains_key(AUTHORIZATION)); + let bearer = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("bearer"); assert_eq!( - headers + bearer .get(AUTHORIZATION) .expect("authorization") .to_str() .expect("authorization str"), "Bearer sdk-token" ); -} -#[test] -fn auth_headers_reject_invalid_bearer_header_values() { let error = auth_headers(&RadrootsdAuth::BearerToken("bad\ntoken".into())).expect_err("error"); - assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_))); -} - -#[test] -fn bridge_listing_publish_request_json_preserves_request_contract() { - let value = - bridge_listing_publish_request_json(&sample_listing_publish_request()).expect("json"); - - assert_eq!(value["kind"], KIND_LISTING); - assert_eq!(value["signer_session_id"], "signer-session-secret"); assert_eq!( - value["signer_authority"]["provider_signer_session_id"], - "provider-session-secret" + format!("{:?}", RadrootsdAuth::BearerToken("token-secret".into())), + "BearerToken(<redacted>)" ); - assert_eq!(value["idempotency_key"], "idem-1"); - assert_eq!(value["listing"]["product"]["title"], "Coffee"); } #[test] -fn listing_publish_request_from_event_parses_listing_and_rejects_wrong_kind() { - let event = sample_listing_event(KIND_LISTING); - let request = SdkRadrootsdListingPublishRequest::from_event( - &event, - "signer-session-secret", - Some(sample_authority()), - Some("idem-1".to_owned()), - ) - .expect("request"); +fn publish_event_request_json_uses_signed_event_contract() { + let value = publish_event_request_json(&publish_request()).expect("request json"); - assert_eq!(request.kind, Some(KIND_LISTING)); - assert_eq!(request.signer_session_id, "signer-session-secret"); - assert_eq!(request.idempotency_key.as_deref(), Some("idem-1")); - assert_eq!(request.listing.product.title, "Coffee"); - - let wrong_kind = sample_listing_event(1); + assert_eq!(value["event"]["id"], "a".repeat(64)); + assert_eq!(value["event"]["pubkey"], "b".repeat(64)); + assert_eq!(value["event"]["kind"], 30_402); + assert_eq!(value["relays"][0], "wss://relay.example.com"); assert_eq!( - SdkRadrootsdListingPublishRequest::from_event(&wrong_kind, "session", None, None) - .expect_err("wrong kind"), - listing::RadrootsListingParseError::InvalidKind(1) - ); - - let mut malformed = sample_listing_event(KIND_LISTING); - malformed.tags = Vec::new(); - let malformed_error = - SdkRadrootsdListingPublishRequest::from_event(&malformed, "session", None, None) - .expect_err("malformed listing"); - assert!(!malformed_error.to_string().is_empty()); -} - -#[tokio::test] -async fn jsonrpc_call_rejects_invalid_auth_before_transport() { - let error = jsonrpc_call::<_, Value>( - "http://127.0.0.1:9/rpc", - &RadrootsdAuth::BearerToken("bad\ntoken".to_owned()), - "1", - "listing.publish", - &json!({}), - core::time::Duration::from_millis(10), - ) - .await - .expect_err("invalid auth"); - assert!(matches!(error, RadrootsdError::InvalidAuthHeader(_))); -} - -#[test] -fn debug_output_redacts_auth_and_signer_secrets() { - let auth = RadrootsdAuth::BearerToken("token-secret".into()); - let none_auth = RadrootsdAuth::None; - let bunker = SdkRadrootsdSignerSessionConnectRequest::bunker("bunker://session"); - let connect = - SdkRadrootsdSignerSessionConnectRequest::nostrconnect("nostrconnect://session", "nsec") - .with_signer_authority(sample_authority()); - let profile_request = SdkRadrootsdProfilePublishRequest { - profile: sample_profile(), - profile_type: Some(RadrootsProfileType::Farm), - signer_session_id: "profile-session-secret".into(), - signer_authority: Some(sample_authority()), - idempotency_key: Some("profile-idem".into()), - }; - let farm_request = SdkRadrootsdFarmPublishRequest { - farm: sample_farm(), - kind: Some(30_000), - signer_session_id: "farm-session-secret".into(), - signer_authority: Some(sample_authority()), - idempotency_key: Some("farm-idem".into()), - }; - let listing_request = sample_listing_publish_request(); - let job = SdkRadrootsdBridgeJob { - job_id: "job-1".into(), - command: "bridge.listing.publish".into(), - status: "accepted".into(), - terminal: false, - recovered_after_restart: false, - signer_mode: "bunker".into(), - signer_session_id: Some("signer-session-secret".into()), - event_kind: KIND_LISTING, - event_id: Some("event-1".into()), - event_addr: Some("30402:pubkey:d-tag".into()), - relay_count: 2, - acknowledged_relay_count: 1, - }; - let job_view = SdkRadrootsdBridgeJobView { - job_id: "job-view-1".into(), - command: "bridge.listing.publish".into(), - idempotency_key: Some("view-idem".into()), - status: SdkRadrootsdBridgeJobStatus::Accepted, - terminal: false, - recovered_after_restart: true, - requested_at_unix: 1, - completed_at_unix: Some(2), - signer_mode: "nostrconnect".into(), - signer_session_id: Some("view-session-secret".into()), - event_kind: KIND_LISTING, - event_id: Some("event-1".into()), - event_addr: Some("30402:pubkey:d-tag".into()), - delivery_policy: SdkRadrootsdBridgeDeliveryPolicy::Quorum, - delivery_quorum: Some(2), - relay_count: 3, - acknowledged_relay_count: 2, - required_acknowledged_relay_count: 2, - attempt_count: 1, - attempt_summaries: vec!["ok".into()], - relay_results: vec![SdkRadrootsdBridgeRelayPublishResult { - relay_url: "wss://relay.example.com".into(), - acknowledged: true, - detail: Some("accepted".into()), - }], - relay_outcome_summary: "2/3 acknowledged".into(), - }; - - let rendered = format!( - "{none_auth:?} {auth:?} {bunker:?} {connect:?} {profile_request:?} {farm_request:?} {listing_request:?} {job:?} {job_view:?}" - ); - - assert!(rendered.contains("None")); - assert!(rendered.contains("<redacted>")); - assert!(!rendered.contains("token-secret")); - assert!(!rendered.contains("nsec")); - assert!(!rendered.contains("provider-session-secret")); - assert!(!rendered.contains("signer-session-secret")); - assert!(!rendered.contains("profile-session-secret")); - assert!(!rendered.contains("farm-session-secret")); - assert!(!rendered.contains("view-session-secret")); - assert!(!rendered.contains("signer_mode: \"bunker\"")); -} - -#[test] -fn http_status_error_omits_raw_body() { - let error = http_status_error(reqwest::StatusCode::UNAUTHORIZED, "missing secret token"); - - let message = error.to_string(); - assert!(message.contains("radrootsd returned http 401")); - assert!(message.contains("response body omitted")); - assert!(!message.contains("missing secret token")); - - assert_message( - http_status_error(reqwest::StatusCode::BAD_GATEWAY, ""), - "response body empty", - ); -} - -#[test] -fn radrootsd_error_display_covers_all_variants() { - assert_message( - RadrootsdError::InvalidAuthHeader("bad header".into()), - "invalid radrootsd bearer token header", - ); - assert_message(RadrootsdError::Http("http".into()), "http"); - assert_message(RadrootsdError::JsonRpc("jsonrpc".into()), "jsonrpc"); - assert_message( - RadrootsdError::MalformedResponse("malformed".into()), - "malformed", + value["relay_policy"], + "request_then_author_write_then_daemon_default" ); + assert_eq!(value["delivery_policy"]["mode"], "any"); + assert_eq!(value["idempotency_key"], "idem-1"); + let rendered = value.to_string(); + assert!(!rendered.contains("signer_session_id")); + assert!(!rendered.contains("bridge.")); } #[test] -fn decode_jsonrpc_response_returns_result() { - let response: SdkRadrootsdBridgePublishResponse = decode_jsonrpc_response( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { - "deduplicated": false, - "job": { - "job_id": "job-1", - "command": "bridge.listing.publish", - "status": "accepted", - "terminal": false, - "recovered_after_restart": false, - "signer_mode": "bunker", - "signer_session_id": "signer-session-secret", - "event_kind": 30402, - "event_id": "event-1", - "event_addr": "30402:pubkey:d-tag", - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - }"#, +fn decode_jsonrpc_response_validates_envelope_and_errors() { + let response: PublishEventResponse = decode_jsonrpc_response( + METHOD_EVENT, + SDK_RADROOTSD_PROXY_REQUEST_ID, + publish_response_json().as_str(), ) .expect("response"); + assert_eq!(response.job.event_id, "a".repeat(64)); - assert!(!response.deduplicated); - assert_eq!(response.job.job_id, "job-1"); - assert_eq!( - response.job.signer_session_id.as_deref(), - Some("signer-session-secret") - ); -} - -#[test] -fn decode_jsonrpc_response_returns_jsonrpc_error() { - let error = decode_jsonrpc_response::<SdkRadrootsdBridgePublishResponse>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "error": { "code": -32001, "message": "signer unavailable" } - }"#, + let error = decode_jsonrpc_response::<PublishEventResponse>( + METHOD_EVENT, + SDK_RADROOTSD_PROXY_REQUEST_ID, + r#"{"jsonrpc":"2.0","id":"radroots-sdk-publish-event","error":{"code":-32001,"message":"principal unauthorized"}}"#, ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::JsonRpc(_))); - assert_message( + .expect_err("jsonrpc error"); + assert!(matches!( error, - "radrootsd bridge.listing.publish failed -32001: signer unavailable", - ); + RadrootsdError::JsonRpc { code: -32001, .. } + )); + assert_message(error, "principal unauthorized"); + + assert!(matches!( + decode_jsonrpc_response::<PublishEventResponse>( + METHOD_EVENT, + "expected", + r#"{"jsonrpc":"2.0","id":"other","result":{}}"# + ), + Err(RadrootsdError::MalformedResponse(_)) + )); + assert!(matches!( + decode_jsonrpc_response::<PublishEventResponse>( + METHOD_EVENT, + "expected", + r#"{"jsonrpc":"2.0","id":"expected"}"# + ), + Err(RadrootsdError::MalformedResponse(_)) + )); } #[test] -fn decode_jsonrpc_response_rejects_result_plus_error() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { "ok": true }, - "error": { "code": -32002, "message": "ambiguous response" } - }"#, - ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message( - error, - "radrootsd bridge.listing.publish returned result and error: -32002 ambiguous response", +fn daemon_outcomes_map_to_relay_transport_receipts() { + let payment = proxy_receipt_from_response(PublishEventResponse { + deduplicated: false, + job: job(PublishRelayOutcomeKind::PaymentRequired), + }) + .expect("payment receipt"); + assert_eq!( + payment.relays[0].outcome.kind, + RadrootsRelayOutcomeKind::PaymentRequired ); -} - -#[test] -fn decode_jsonrpc_response_rejects_missing_result_and_error() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#, - ) - .expect_err("error"); + assert_eq!(payment.terminal_count, 1); - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message( - error, - "radrootsd bridge.listing.publish returned neither result nor error", + let skipped = proxy_receipt_from_response(PublishEventResponse { + deduplicated: true, + job: job(PublishRelayOutcomeKind::SkippedAlreadyAccepted), + }) + .expect("skipped receipt"); + assert_eq!( + skipped.relays[0].outcome.kind, + RadrootsRelayOutcomeKind::SkippedAlreadyAccepted ); -} - -#[test] -fn decode_jsonrpc_response_rejects_malformed_json() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ "result": "#, - ) - .expect_err("error"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "decode radrootsd bridge.listing.publish response"); -} - -#[test] -fn decode_jsonrpc_response_rejects_invalid_version() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "1.0", - "id": "radroots-sdk-listing-publish", - "result": { "ok": true } - }"#, - ) - .expect_err("error"); - - assert_message(error, "returned invalid jsonrpc version"); -} - -#[test] -fn decode_jsonrpc_response_rejects_mismatched_id() { - let error = decode_jsonrpc_response::<serde_json::Value>( - "bridge.listing.publish", - "radroots-sdk-listing-publish", - r#"{ - "jsonrpc": "2.0", - "id": "other-id", - "result": { "ok": true } - }"#, - ) - .expect_err("error"); - - assert_message(error, "returned mismatched jsonrpc id"); + assert!(skipped.quorum_met); } #[tokio::test] -async fn publish_listing_uses_http_jsonrpc_request_path() { - let (endpoint, handle) = spawn_http_server( - "200 OK", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "result": { - "deduplicated": true, - "job": { - "job_id": "job-1", - "command": "bridge.listing.publish", - "status": "accepted", - "terminal": false, - "recovered_after_restart": false, - "signer_mode": "bunker", - "signer_session_id": "signer-session-secret", - "event_kind": 30402, - "event_id": "event-1", - "event_addr": "30402:pubkey:d-tag", - "relay_count": 2, - "acknowledged_relay_count": 1 - } - } - }"#, - ); +async fn publish_event_posts_publish_proxy_jsonrpc() { + let (endpoint, handle) = spawn_http_server("200 OK", publish_response_json().as_str()); - let response = publish_listing( - &endpoint, + let receipt = publish_event( + endpoint.as_str(), &RadrootsdAuth::BearerToken("sdk-token".into()), - &sample_listing_publish_request(), - Duration::from_secs(5), + &publish_request(), + Duration::from_secs(2), ) .await - .expect("publish response"); - let request = handle.join().expect("request"); - let body = serde_json::from_str::<Value>(&request.body).expect("body json"); + .expect("publish"); - assert!(response.deduplicated); - assert_eq!(request.request_line, "POST /rpc HTTP/1.1"); + assert_eq!(receipt.job.event_id, "a".repeat(64)); + let recorded = handle.join().expect("server thread"); + assert_eq!(recorded.request_line, "POST /rpc HTTP/1.1"); assert!( - request + recorded .headers .iter() - .any(|(name, value)| { name == "authorization" && value == "Bearer sdk-token" }) - ); - assert_eq!(body["jsonrpc"], "2.0"); - assert_eq!(body["id"], "radroots-sdk-listing-publish"); - assert_eq!(body["method"], "bridge.listing.publish"); - assert_eq!( - body["params"]["signer_authority"]["provider_signer_session_id"], - "provider-session-secret" - ); -} - -#[tokio::test] -async fn publish_listing_returns_jsonrpc_errors_from_http_path() { - let (endpoint, handle) = spawn_http_server( - "200 OK", - r#"{ - "jsonrpc": "2.0", - "id": "radroots-sdk-listing-publish", - "error": { "code": -32001, "message": "signer unavailable" } - }"#, + .any(|(name, value)| name == "authorization" && value == "Bearer sdk-token") ); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect_err("error"); - handle.join().expect("request"); - - assert!(matches!(error, RadrootsdError::JsonRpc(_))); - assert_message(error, "signer unavailable"); + let body: serde_json::Value = serde_json::from_str(recorded.body.as_str()).expect("body"); + assert_eq!(body["method"], METHOD_EVENT); + assert_eq!(body["id"], SDK_RADROOTSD_PROXY_REQUEST_ID); + assert_eq!(body["params"]["event"]["content"], "{\"name\":\"carrots\"}"); + assert_eq!(body["params"]["relays"][0], "wss://relay.example.com"); } #[tokio::test] -async fn publish_listing_sanitizes_http_status_body() { - let (endpoint, handle) = spawn_http_server("500 Internal Server Error", "secret body"); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), +async fn publish_event_http_errors_omit_body_and_token_material() { + let body = "{\"error\":\"token-secret content carrots\"}"; + let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body); + + let error = publish_event( + endpoint.as_str(), + &RadrootsdAuth::BearerToken("token-secret".into()), + &publish_request(), + Duration::from_secs(2), ) .await - .expect_err("error"); - handle.join().expect("request"); - + .expect_err("http error"); let message = error.to_string(); - assert!(message.contains("radrootsd returned http 500")); - assert!(!message.contains("secret body")); -} - -#[tokio::test] -async fn publish_listing_reports_malformed_http_response_body() { - let (endpoint, handle) = spawn_http_server("200 OK", r#"{ "result": "#); - - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect_err("error"); - handle.join().expect("request"); - - assert!(matches!(error, RadrootsdError::MalformedResponse(_))); - assert_message(error, "decode radrootsd bridge.listing.publish response"); -} - -#[tokio::test] -async fn publish_listing_reports_http_response_body_read_errors() { - let body = r#"{ "jsonrpc": "2.0", "id": "radroots-sdk-listing-publish" }"#; - let (endpoint, handle) = spawn_http_server_with_content_length("200 OK", body, body.len() + 64); - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_secs(5), - ) - .await - .expect_err("error"); - handle.join().expect("request"); - - assert!(matches!(error, RadrootsdError::Http(_))); - assert_message(error, "read radrootsd response body"); + assert!(message.contains("503")); + assert!(message.contains("response body omitted")); + assert!(!message.contains("token-secret")); + assert!(!message.contains("carrots")); } #[tokio::test] -async fn publish_listing_reports_transport_send_errors() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind unused port"); - let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); - drop(listener); +async fn adapter_rejects_invalid_request_before_transport() { + let adapter = + RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc")); + let mut request = RadrootsdProxyPublishRequest { + signed_event: signed_event(), + relays: Vec::new(), + delivery_policy: PublishDeliveryPolicy::Quorum { quorum: 0 }, + idempotency_key: None, + timeout_ms: None, + }; + request.signed_event.id = "A".repeat(64); - let error = publish_listing( - &endpoint, - &RadrootsdAuth::None, - &sample_listing_publish_request(), - Duration::from_millis(250), - ) - .await - .expect_err("error"); + let error = adapter + .publish_signed_event(request) + .await + .expect_err("invalid request"); - assert!(matches!(error, RadrootsdError::Http(_))); - assert_message(error, "send radrootsd bridge.listing.publish request"); + assert!(matches!(error, RadrootsdError::InvalidRequest(_))); } diff --git a/crates/sdk/tests/unit/relay_targets_tests.rs b/crates/sdk/tests/unit/relay_targets_tests.rs @@ -40,6 +40,13 @@ fn use_configured_policy_serializes_as_kind_only() { serde_json::json!({ "kind": "use_configured_relays" }) ); assert_struct_serialize_error_paths(&policy, 1); + + let publish_transport_policy = SdkRelayTargetPolicy::use_publish_transport(); + assert_eq!( + serde_json::to_value(&publish_transport_policy).expect("json"), + serde_json::json!({ "kind": "use_publish_transport" }) + ); + assert_struct_serialize_error_paths(&publish_transport_policy, 1); } #[test] diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs @@ -130,6 +130,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( storage_paths: None, clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), relay_urls: Vec::new(), + publish_transport: SdkPublishTransport::DirectNostrRelay, }; assert_eq!(memory_sdk.storage_kind(), SdkStorageKind::Memory); @@ -147,6 +148,7 @@ async fn open_storage_and_storage_kind_cover_memory_directory_and_file_failures( storage_paths: Some(directory_paths), clock: RadrootsSdkClock::Fixed(RadrootsSdkTimestamp::from_unix_seconds(1)), relay_urls: Vec::new(), + publish_transport: SdkPublishTransport::DirectNostrRelay, }; assert_eq!(directory_sdk.storage_kind(), SdkStorageKind::Directory); diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -87,6 +87,30 @@ fn push_event_final_state_follows_publish_quorum_and_retryability() { } #[test] +fn push_relay_outcome_mapping_covers_daemon_proxy_results() { + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Muted), + PushOutboxRelayOutcomeKind::Muted + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::Unsupported), + PushOutboxRelayOutcomeKind::Unsupported + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::PaymentRequired), + PushOutboxRelayOutcomeKind::PaymentRequired + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::RelayUrlRejected), + PushOutboxRelayOutcomeKind::RelayUrlRejected + ); + assert_eq!( + PushOutboxRelayOutcomeKind::from(RadrootsRelayOutcomeKind::SkippedAlreadyAccepted), + PushOutboxRelayOutcomeKind::SkippedAlreadyAccepted + ); +} + +#[test] fn auth_policy_defaults_and_outbox_state_mappings_cover_all_public_states() { assert_eq!( SdkRelayAuthPolicy::default(), diff --git a/crates/sdk/tests/unit/workflow_runtime_tests.rs b/crates/sdk/tests/unit/workflow_runtime_tests.rs @@ -111,6 +111,7 @@ fn workflow_digest_and_event_helpers_cover_error_and_input_paths() { signed_event(), vec!["wss://relay.example.com".to_owned()], idempotency_key, + false, true, 1_700_000_000_000, ); @@ -212,3 +213,24 @@ async fn enqueue_signed_workflow_reports_clock_failures() { Err(RadrootsSdkError::ClockBeforeUnixEpoch) )); } + +#[tokio::test] +async fn enqueue_signed_workflow_rejects_publish_transport_targets_without_proxy_transport() { + let sdk = crate::RadrootsSdk::builder().build().await.expect("sdk"); + let actor = RadrootsActorContext::test(FARMER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer]) + .expect("actor"); + let draft = frozen_draft_for(FARMER_PUBLIC_KEY_HEX); + let request = SdkWorkflowEnqueueRequest { + operation_kind: "workflow.test.v1", + actor: &actor, + frozen_draft: &draft, + target_relays: SdkRelayTargetPolicy::UsePublishTransport, + idempotency_key: None, + }; + + assert!(matches!( + enqueue_signed_workflow(&sdk, request, &WorkflowSigner::new()).await, + Err(RadrootsSdkError::EmptyTargetRelays { operation }) + if operation == "publish transport relay resolution" + )); +}