lib

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

commit 8055e43b12f46784857d5715ac68cfe7417023e9
parent 93e2280834d72aceb3c02d9467d73abf864552c6
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 02:49:37 +0000

transport: define publish protocol v2

- Replace the publish proxy wire API with transport.publish v2 contracts.

- Rename the shared package and workspace dependency to radroots_transport_publish_protocol.

- Add transport target, delivery, outcome, and capability types for Nostr and Reticulum preview targets.

- Validate the protocol crate with cargo test --package radroots_transport_publish_protocol.

Diffstat:
MCargo.lock | 16++++++++--------
MCargo.toml | 2+-
Mcrates/publish_proxy_protocol/Cargo.toml | 4++--
Mcrates/publish_proxy_protocol/README | 4++--
Mcrates/publish_proxy_protocol/src/lib.rs | 719++++++++++++++++++++++++++++++++++++++-----------------------------------------
5 files changed, 360 insertions(+), 385 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4547,14 +4547,6 @@ dependencies = [ ] [[package]] -name = "radroots_publish_proxy_protocol" -version = "0.1.0-alpha.2" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] name = "radroots_replica_db" version = "0.1.0-alpha.2" dependencies = [ @@ -4879,6 +4871,14 @@ dependencies = [ ] [[package]] +name = "radroots_transport_publish_protocol" +version = "0.1.0-alpha.2" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] name = "radroots_transport_reticulum" version = "0.1.0-alpha.2" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -86,7 +86,7 @@ radroots_log = { path = "crates/log", version = "0.1.0-alpha.2", default-feature radroots_net = { path = "crates/net", version = "0.1.0-alpha.2", default-features = false } radroots_nostr_runtime = { path = "crates/nostr_runtime", version = "0.1.0-alpha.2", default-features = false } radroots_outbox = { path = "crates/outbox", version = "0.1.0-alpha.2", default-features = false } -radroots_publish_proxy_protocol = { path = "crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false } +radroots_transport_publish_protocol = { path = "crates/publish_proxy_protocol", version = "0.1.0-alpha.2", default-features = false } radroots_transport_nostr = { path = "crates/transport_nostr", version = "0.1.0-alpha.2", default-features = false } radroots_transport_reticulum = { path = "crates/transport_reticulum", version = "0.1.0-alpha.2", default-features = false } radroots_simplex_agent_proto = { path = "crates/simplex_agent_proto", version = "0.1.0-alpha.2", default-features = false } diff --git a/crates/publish_proxy_protocol/Cargo.toml b/crates/publish_proxy_protocol/Cargo.toml @@ -1,12 +1,12 @@ [package] -name = "radroots_publish_proxy_protocol" +name = "radroots_transport_publish_protocol" publish = false version.workspace = true edition.workspace = true authors = ["Tyson Lupul <tyson@radroots.org>"] rust-version.workspace = true license = "MIT OR Apache-2.0" -description = "Publish proxy protocol for Radroots" +description = "Transport publish protocol for Radrootsd" repository.workspace = true homepage.workspace = true readme = "README" diff --git a/crates/publish_proxy_protocol/README b/crates/publish_proxy_protocol/README @@ -1,3 +1,3 @@ -# radroots_publish_proxy_protocol +# radroots_transport_publish_protocol -Shared Radrootsd Publish Proxy JSON-RPC wire contracts. +Shared Radrootsd transport publish JSON-RPC wire contracts. diff --git a/crates/publish_proxy_protocol/src/lib.rs b/crates/publish_proxy_protocol/src/lib.rs @@ -11,16 +11,15 @@ use std::{string::String, vec::Vec}; use core::fmt; -pub const API_VERSION: &str = "radrootsd.publish_proxy.v1"; +pub const API_VERSION: &str = "radrootsd.transport_publish.v2"; pub const DAEMON_NAME: &str = "radrootsd"; -pub const METHOD_CAPABILITIES: &str = "publish.capabilities"; -pub const METHOD_EVENT: &str = "publish.event"; -pub const METHOD_JOB_GET: &str = "publish.job.get"; -pub const METHOD_JOB_LIST: &str = "publish.job.list"; -pub const METHOD_RELAYS_RESOLVE: &str = "publish.relays.resolve"; +pub const METHOD_CAPABILITIES: &str = "transport.publish.capabilities"; +pub const METHOD_EVENT: &str = "transport.publish.event"; +pub const METHOD_JOB_GET: &str = "transport.publish.job.get"; +pub const METHOD_JOB_LIST: &str = "transport.publish.job.list"; #[derive(Clone, Debug, PartialEq, Eq)] -pub enum PublishProxyProtocolError { +pub enum TransportPublishProtocolError { InvalidHexField { field: &'static str, expected_len: usize, @@ -30,32 +29,44 @@ pub enum PublishProxyProtocolError { index: usize, }, EmptyIdempotencyKey, - EmptyRelayUrl { + EmptyTransportKind { index: usize, }, - RelayLimitExceeded { + EmptyEndpointUri { + index: usize, + }, + TargetLimitExceeded { max: usize, actual: usize, }, + EmptyTargetSet, InvalidQuorum, EmptyPrincipalId, EmptyJobId, } -impl fmt::Display for PublishProxyProtocolError { +impl fmt::Display for TransportPublishProtocolError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidHexField { field, expected_len, } => write!(f, "{field} must be {expected_len} lowercase hex characters"), - Self::InvalidKind(kind) => write!(f, "event kind {kind} exceeds publish proxy range"), + Self::InvalidKind(kind) => { + write!(f, "event kind {kind} exceeds transport publish range") + } Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"), Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"), - Self::EmptyRelayUrl { index } => write!(f, "relay URL {index} must not be empty"), - Self::RelayLimitExceeded { max, actual } => { - write!(f, "relay count {actual} exceeds limit {max}") + Self::EmptyTransportKind { index } => { + write!(f, "transport target {index} kind must not be empty") } + Self::EmptyEndpointUri { index } => { + write!(f, "transport target {index} endpoint_uri must not be empty") + } + Self::TargetLimitExceeded { max, actual } => { + write!(f, "transport target count {actual} exceeds limit {max}") + } + Self::EmptyTargetSet => f.write_str("transport publish target set must not be empty"), Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"), Self::EmptyPrincipalId => f.write_str("principal id must not be empty"), Self::EmptyJobId => f.write_str("job id must not be empty"), @@ -64,7 +75,7 @@ impl fmt::Display for PublishProxyProtocolError { } #[cfg(feature = "std")] -impl std::error::Error for PublishProxyProtocolError {} +impl std::error::Error for TransportPublishProtocolError {} #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] @@ -80,16 +91,16 @@ pub struct SignedNostrEventWire { } impl SignedNostrEventWire { - pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { + pub fn validate(&self) -> Result<(), TransportPublishProtocolError> { validate_lower_hex("id", self.id.as_str(), 64)?; validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; validate_lower_hex("sig", self.sig.as_str(), 128)?; if self.kind > u16::MAX as u32 { - return Err(PublishProxyProtocolError::InvalidKind(self.kind)); + return Err(TransportPublishProtocolError::InvalidKind(self.kind)); } for (index, tag) in self.tags.iter().enumerate() { if tag.is_empty() { - return Err(PublishProxyProtocolError::EmptyTag { index }); + return Err(TransportPublishProtocolError::EmptyTag { index }); } } Ok(()) @@ -99,7 +110,65 @@ impl SignedNostrEventWire { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PublishRelayPolicy { +pub enum TransportPublishPreviewBehavior { + RejectDeliveryAttempts, + DeferDeliveryPlans, +} + +impl Default for TransportPublishPreviewBehavior { + fn default() -> Self { + Self::RejectDeliveryAttempts + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransportPublishTarget { + pub transport_kind: String, + pub endpoint_uri: String, + #[cfg_attr( + feature = "serde", + serde(default, skip_serializing_if = "Option::is_none") + )] + pub preview_behavior: Option<TransportPublishPreviewBehavior>, +} + +impl TransportPublishTarget { + pub fn nostr(endpoint_uri: impl Into<String>) -> Self { + Self { + transport_kind: "nostr".to_owned(), + endpoint_uri: endpoint_uri.into(), + preview_behavior: None, + } + } + + pub fn reticulum_preview( + endpoint_uri: impl Into<String>, + behavior: TransportPublishPreviewBehavior, + ) -> Self { + Self { + transport_kind: "reticulum".to_owned(), + endpoint_uri: endpoint_uri.into(), + preview_behavior: Some(behavior), + } + } + + fn validate(&self, index: usize) -> Result<(), TransportPublishProtocolError> { + if self.transport_kind.trim().is_empty() { + return Err(TransportPublishProtocolError::EmptyTransportKind { index }); + } + if self.endpoint_uri.trim().is_empty() { + return Err(TransportPublishProtocolError::EmptyEndpointUri { index }); + } + Ok(()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NostrPublishTargetSourcePolicy { ExplicitOnly, RequestThenAuthorWriteThenDaemonDefault, AuthorWriteThenDaemonDefault, @@ -107,27 +176,84 @@ pub enum PublishRelayPolicy { } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(tag = "kind", rename_all = "snake_case"))] +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TransportPublishTargetPolicy { + ExplicitTargets { + targets: Vec<TransportPublishTarget>, + }, + Nostr { + source_policy: NostrPublishTargetSourcePolicy, + #[cfg_attr(feature = "serde", serde(default))] + relay_urls: Vec<String>, + }, +} + +impl TransportPublishTargetPolicy { + pub fn explicit_targets(targets: Vec<TransportPublishTarget>) -> Self { + Self::ExplicitTargets { targets } + } + + pub fn nostr(source_policy: NostrPublishTargetSourcePolicy, relay_urls: Vec<String>) -> Self { + Self::Nostr { + source_policy, + relay_urls, + } + } + + pub fn request_target_count(&self) -> usize { + match self { + Self::ExplicitTargets { targets } => targets.len(), + Self::Nostr { relay_urls, .. } => relay_urls.len(), + } + } + + fn validate(&self, max_targets: usize) -> Result<(), TransportPublishProtocolError> { + match self { + Self::ExplicitTargets { targets } => { + validate_target_limit(targets.len(), max_targets)?; + if targets.is_empty() { + return Err(TransportPublishProtocolError::EmptyTargetSet); + } + for (index, target) in targets.iter().enumerate() { + target.validate(index)?; + } + } + Self::Nostr { relay_urls, .. } => { + validate_target_limit(relay_urls.len(), max_targets)?; + for (index, endpoint_uri) in relay_urls.iter().enumerate() { + if endpoint_uri.trim().is_empty() { + return Err(TransportPublishProtocolError::EmptyEndpointUri { index }); + } + } + } + } + Ok(()) + } +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))] #[derive(Clone, Debug, PartialEq, Eq)] -pub enum PublishDeliveryPolicy { +pub enum TransportPublishDeliveryPolicy { Any, All, Quorum { quorum: usize }, } -impl PublishDeliveryPolicy { - pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { +impl TransportPublishDeliveryPolicy { + pub fn validate(&self) -> Result<(), TransportPublishProtocolError> { if matches!(self, Self::Quorum { quorum: 0 }) { - Err(PublishProxyProtocolError::InvalidQuorum) + Err(TransportPublishProtocolError::InvalidQuorum) } else { Ok(()) } } - pub fn required_ack_count(&self, relay_count: usize) -> usize { + pub fn required_target_count(&self, target_count: usize) -> usize { match self { - Self::Any => usize::from(relay_count > 0), - Self::All => relay_count, + Self::Any => usize::from(target_count > 0), + Self::All => target_count, Self::Quorum { quorum } => *quorum, } } @@ -136,12 +262,10 @@ impl PublishDeliveryPolicy { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishEventRequest { +pub struct TransportPublishEventRequest { pub event: SignedNostrEventWire, - #[cfg_attr(feature = "serde", serde(default))] - pub relays: Vec<String>, - pub relay_policy: PublishRelayPolicy, - pub delivery_policy: PublishDeliveryPolicy, + pub target_policy: TransportPublishTargetPolicy, + pub delivery_policy: TransportPublishDeliveryPolicy, #[cfg_attr( feature = "serde", serde(default, skip_serializing_if = "Option::is_none") @@ -154,27 +278,17 @@ pub struct PublishEventRequest { pub timeout_ms: Option<u64>, } -impl PublishEventRequest { - pub fn validate(&self, max_relays: usize) -> Result<(), PublishProxyProtocolError> { +impl TransportPublishEventRequest { + pub fn validate(&self, max_targets: usize) -> Result<(), TransportPublishProtocolError> { self.event.validate()?; + self.target_policy.validate(max_targets)?; self.delivery_policy.validate()?; - if self.relays.len() > max_relays { - return Err(PublishProxyProtocolError::RelayLimitExceeded { - max: max_relays, - actual: self.relays.len(), - }); - } - for (index, relay) in self.relays.iter().enumerate() { - if relay.trim().is_empty() { - return Err(PublishProxyProtocolError::EmptyRelayUrl { index }); - } - } if self .idempotency_key .as_ref() .is_some_and(|key| key.trim().is_empty()) { - return Err(PublishProxyProtocolError::EmptyIdempotencyKey); + return Err(TransportPublishProtocolError::EmptyIdempotencyKey); } Ok(()) } @@ -183,7 +297,7 @@ impl PublishEventRequest { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PublishJobStatus { +pub enum TransportPublishJobStatus { Accepted, Publishing, DeliverySatisfied, @@ -195,7 +309,7 @@ pub enum PublishJobStatus { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PublishRelayOutcomeKind { +pub enum TransportPublishOutcomeKind { Accepted, DuplicateAccepted, Blocked, @@ -210,13 +324,15 @@ pub enum PublishRelayOutcomeKind { Error, Timeout, ConnectionFailed, - RelayUrlRejected, + TargetRejected, SkippedAlreadyAccepted, + Deferred, + Unavailable, Unknown, } -impl PublishRelayOutcomeKind { - pub fn counts_toward_quorum(self) -> bool { +impl TransportPublishOutcomeKind { + pub fn counts_toward_satisfaction(self) -> bool { matches!( self, Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted @@ -245,19 +361,32 @@ impl PublishRelayOutcomeKind { | Self::Muted | Self::Unsupported | Self::PaymentRequired - | Self::RelayUrlRejected + | Self::TargetRejected + | Self::Deferred + | Self::Unavailable ) } } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransportPublishTargetSource { + Request, + NostrAuthorWrite, + DaemonDefault, + ReticulumPreview, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishRelayOutcome { - pub relay_url: String, - pub source: PublishRelaySource, +pub struct TransportPublishTargetOutcome { + pub transport_kind: String, + pub endpoint_uri: String, + pub source: TransportPublishTargetSource, pub attempted: bool, - pub outcome_kind: PublishRelayOutcomeKind, + pub outcome_kind: TransportPublishOutcomeKind, #[cfg_attr( feature = "serde", serde(default, skip_serializing_if = "Option::is_none") @@ -271,28 +400,19 @@ pub struct PublishRelayOutcome { } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PublishRelaySource { - Request, - AuthorWrite, - DaemonDefault, -} - -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishJobView { +pub struct TransportPublishJobView { pub job_id: String, - pub status: PublishJobStatus, + pub status: TransportPublishJobStatus, pub terminal: bool, pub delivery_satisfied: bool, pub event_id: String, pub pubkey: String, pub event_kind: u32, - pub relay_policy: PublishRelayPolicy, - pub delivery_policy: PublishDeliveryPolicy, - pub relay_count: usize, + pub target_policy: TransportPublishTargetPolicy, + pub delivery_policy: TransportPublishDeliveryPolicy, + pub target_count: usize, pub acknowledged_count: usize, pub retryable_count: usize, pub terminal_count: usize, @@ -308,18 +428,18 @@ pub struct PublishJobView { )] pub last_error: Option<String>, #[cfg_attr(feature = "serde", serde(default))] - pub relays: Vec<PublishRelayOutcome>, + pub targets: Vec<TransportPublishTargetOutcome>, } -impl PublishJobView { - pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { +impl TransportPublishJobView { + pub fn validate(&self) -> Result<(), TransportPublishProtocolError> { if self.job_id.trim().is_empty() { - return Err(PublishProxyProtocolError::EmptyJobId); + return Err(TransportPublishProtocolError::EmptyJobId); } validate_lower_hex("event_id", self.event_id.as_str(), 64)?; validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; if self.event_kind > u16::MAX as u32 { - return Err(PublishProxyProtocolError::InvalidKind(self.event_kind)); + return Err(TransportPublishProtocolError::InvalidKind(self.event_kind)); } self.delivery_policy.validate() } @@ -328,25 +448,25 @@ impl PublishJobView { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishEventResponse { +pub struct TransportPublishEventResponse { pub deduplicated: bool, - pub job: PublishJobView, + pub job: TransportPublishJobView, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishCapabilities { +pub struct TransportPublishCapabilities { pub daemon: String, pub api_version: String, pub transports: Vec<String>, pub methods: Vec<String>, - pub auth: PublishAuthCapabilities, - pub publish: PublishSurfaceCapabilities, + pub auth: TransportPublishAuthCapabilities, + pub publish: TransportPublishSurfaceCapabilities, } -impl PublishCapabilities { - pub fn v1(max_event_bytes: usize, max_relays_per_request: usize) -> Self { +impl TransportPublishCapabilities { + pub fn v2(max_event_bytes: usize, max_targets_per_request: usize) -> Self { Self { daemon: DAEMON_NAME.to_owned(), api_version: API_VERSION.to_owned(), @@ -356,27 +476,25 @@ impl PublishCapabilities { METHOD_EVENT.to_owned(), METHOD_JOB_GET.to_owned(), METHOD_JOB_LIST.to_owned(), - METHOD_RELAYS_RESOLVE.to_owned(), ], - auth: PublishAuthCapabilities { + auth: TransportPublishAuthCapabilities { mode: "scoped_bearer_token".to_owned(), }, - publish: PublishSurfaceCapabilities { + publish: TransportPublishSurfaceCapabilities { signed_event_ingress: true, server_side_user_signing: false, max_event_bytes, - max_relays_per_request, + max_targets_per_request, delivery_policies: vec![ - PublishDeliveryPolicyName::Any, - PublishDeliveryPolicyName::Quorum, - PublishDeliveryPolicyName::All, + TransportPublishDeliveryPolicyName::Any, + TransportPublishDeliveryPolicyName::Quorum, + TransportPublishDeliveryPolicyName::All, ], - relay_policies: vec![ - PublishRelayPolicy::ExplicitOnly, - PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, - PublishRelayPolicy::AuthorWriteThenDaemonDefault, - PublishRelayPolicy::DaemonDefaultOnly, + target_policy_modes: vec![ + TransportPublishTargetPolicyName::ExplicitTargets, + TransportPublishTargetPolicyName::Nostr, ], + transport_kinds: vec!["nostr".to_owned(), "reticulum".to_owned()], }, } } @@ -385,36 +503,59 @@ impl PublishCapabilities { #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishAuthCapabilities { +pub struct TransportPublishAuthCapabilities { pub mode: String, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] #[derive(Clone, Debug, PartialEq, Eq)] -pub struct PublishSurfaceCapabilities { +pub struct TransportPublishSurfaceCapabilities { pub signed_event_ingress: bool, pub server_side_user_signing: bool, pub max_event_bytes: usize, - pub max_relays_per_request: usize, - pub delivery_policies: Vec<PublishDeliveryPolicyName>, - pub relay_policies: Vec<PublishRelayPolicy>, + pub max_targets_per_request: usize, + pub delivery_policies: Vec<TransportPublishDeliveryPolicyName>, + pub target_policy_modes: Vec<TransportPublishTargetPolicyName>, + pub transport_kinds: Vec<String>, } #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum PublishDeliveryPolicyName { +pub enum TransportPublishDeliveryPolicyName { Any, Quorum, All, } +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TransportPublishTargetPolicyName { + ExplicitTargets, + Nostr, +} + +fn validate_target_limit( + target_count: usize, + max_targets: usize, +) -> Result<(), TransportPublishProtocolError> { + if target_count > max_targets { + Err(TransportPublishProtocolError::TargetLimitExceeded { + max: max_targets, + actual: target_count, + }) + } else { + Ok(()) + } +} + fn validate_lower_hex( field: &'static str, value: &str, expected_len: usize, -) -> Result<(), PublishProxyProtocolError> { +) -> Result<(), TransportPublishProtocolError> { if value.len() == expected_len && value .as_bytes() @@ -423,7 +564,7 @@ fn validate_lower_hex( { Ok(()) } else { - Err(PublishProxyProtocolError::InvalidHexField { + Err(TransportPublishProtocolError::InvalidHexField { field, expected_len, }) @@ -441,310 +582,144 @@ mod tests { created_at: 1_700_000_000, kind: 30_402, tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], - content: "{\"name\":\"carrots\"}".to_owned(), + content: "{}".to_owned(), sig: "2".repeat(128), } } - fn job_view() -> PublishJobView { - PublishJobView { - job_id: "job-1".to_owned(), - status: PublishJobStatus::Accepted, - terminal: false, - delivery_satisfied: false, - event_id: "0".repeat(64), - pubkey: "1".repeat(64), - event_kind: 30_402, - relay_policy: PublishRelayPolicy::ExplicitOnly, - delivery_policy: PublishDeliveryPolicy::Any, - relay_count: 1, - acknowledged_count: 0, - retryable_count: 0, - terminal_count: 0, - requested_at_ms: 1_700_000_000_000, - completed_at_ms: None, - last_error: None, - relays: Vec::new(), - } - } - #[test] - fn protocol_error_display_covers_all_variants() { - let cases = [ - ( - PublishProxyProtocolError::InvalidHexField { - field: "id", - expected_len: 64, - }, - "id must be 64 lowercase hex characters", - ), - ( - PublishProxyProtocolError::InvalidKind(65_536), - "event kind 65536 exceeds publish proxy range", - ), - ( - PublishProxyProtocolError::EmptyTag { index: 3 }, - "tag 3 must not be empty", - ), - ( - PublishProxyProtocolError::EmptyIdempotencyKey, - "idempotency key must not be empty", - ), - ( - PublishProxyProtocolError::EmptyRelayUrl { index: 2 }, - "relay URL 2 must not be empty", - ), - ( - PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 }, - "relay count 2 exceeds limit 1", - ), - ( - PublishProxyProtocolError::InvalidQuorum, - "delivery quorum must be greater than zero", - ), - ( - PublishProxyProtocolError::EmptyPrincipalId, - "principal id must not be empty", - ), - ( - PublishProxyProtocolError::EmptyJobId, - "job id must not be empty", - ), - ]; - - for (error, expected) in cases { - assert_eq!(error.to_string(), expected); - } - } - - #[test] - fn signed_event_wire_uses_pubkey_and_rejects_author() { - let value = serde_json::to_value(event()).expect("serialize"); - assert!(value.get("pubkey").is_some()); - assert!(value.get("author").is_none()); - - let err = serde_json::from_value::<SignedNostrEventWire>(serde_json::json!({ - "id": "0".repeat(64), - "author": "1".repeat(64), - "created_at": 1_700_000_000u64, - "kind": 30402u32, - "tags": [["d", "listing-1"]], - "content": "{}", - "sig": "2".repeat(128) - })) - .expect_err("author must not be accepted"); - let message = err.to_string(); - assert!(message.contains("author")); - assert!(message.contains("pubkey")); - } - - #[test] - fn signed_event_validation_rejects_malformed_fields() { - let mut invalid_id = event(); - invalid_id.id = "A".repeat(64); - assert!(matches!( - invalid_id.validate(), - Err(PublishProxyProtocolError::InvalidHexField { field: "id", .. }) - )); - - let mut invalid_pubkey = event(); - invalid_pubkey.pubkey = "g".repeat(64); - assert!(matches!( - invalid_pubkey.validate(), - Err(PublishProxyProtocolError::InvalidHexField { - field: "pubkey", - .. - }) - )); - - let mut invalid_sig = event(); - invalid_sig.sig = "2".repeat(127); - assert!(matches!( - invalid_sig.validate(), - Err(PublishProxyProtocolError::InvalidHexField { field: "sig", .. }) - )); - - let mut invalid_kind = event(); - invalid_kind.kind = u16::MAX as u32 + 1; - assert!(matches!( - invalid_kind.validate(), - Err(PublishProxyProtocolError::InvalidKind(_)) - )); - - let mut empty_tag = event(); - empty_tag.tags = vec![Vec::new()]; - assert!(matches!( - empty_tag.validate(), - Err(PublishProxyProtocolError::EmptyTag { index: 0 }) - )); + fn transport_publish_capabilities_match_v2_surface() { + let capabilities = TransportPublishCapabilities::v2(1024, 10); + + assert_eq!(capabilities.api_version, "radrootsd.transport_publish.v2"); + assert_eq!( + capabilities.methods, + vec![ + "transport.publish.capabilities".to_owned(), + "transport.publish.event".to_owned(), + "transport.publish.job.get".to_owned(), + "transport.publish.job.list".to_owned() + ] + ); + assert_eq!(capabilities.publish.max_targets_per_request, 10); + assert_eq!( + capabilities.publish.target_policy_modes, + vec![ + TransportPublishTargetPolicyName::ExplicitTargets, + TransportPublishTargetPolicyName::Nostr + ] + ); } #[test] - fn publish_request_validation_covers_policy_and_relay_limits() { - let request = PublishEventRequest { + fn request_validation_covers_targets_and_policy() { + let request = TransportPublishEventRequest { event: event(), - relays: vec!["wss://relay.example.com".to_owned()], - relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, - delivery_policy: PublishDeliveryPolicy::Quorum { quorum: 1 }, - idempotency_key: Some("key-1".to_owned()), - timeout_ms: Some(10_000), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com"), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-1".to_owned()), + timeout_ms: Some(5_000), }; + request.validate(1).expect("valid request"); - assert_eq!(request.delivery_policy.required_ack_count(3), 1); - assert_eq!(PublishDeliveryPolicy::Any.required_ack_count(0), 0); - assert_eq!(PublishDeliveryPolicy::Any.required_ack_count(3), 1); - assert_eq!(PublishDeliveryPolicy::All.required_ack_count(3), 3); - PublishDeliveryPolicy::Any.validate().expect("any policy"); - PublishDeliveryPolicy::All.validate().expect("all policy"); let mut too_many = request.clone(); - too_many.relays.push("wss://relay-2.example.com".to_owned()); + too_many.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com"), + TransportPublishTarget::reticulum_preview( + "reticulum:preview", + TransportPublishPreviewBehavior::RejectDeliveryAttempts, + ), + ]); assert!(matches!( too_many.validate(1), - Err(PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 }) + Err(TransportPublishProtocolError::TargetLimitExceeded { max: 1, actual: 2 }) )); - let mut empty_relay = request.clone(); - empty_relay.relays = vec![" ".to_owned()]; + let mut empty_endpoint = request.clone(); + empty_endpoint.target_policy = + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget::nostr( + " ", + )]); assert!(matches!( - empty_relay.validate(1), - Err(PublishProxyProtocolError::EmptyRelayUrl { index: 0 }) + empty_endpoint.validate(1), + Err(TransportPublishProtocolError::EmptyEndpointUri { index: 0 }) )); - let mut empty_idempotency_key = request.clone(); - empty_idempotency_key.idempotency_key = Some(" ".to_owned()); - assert!(matches!( - empty_idempotency_key.validate(1), - Err(PublishProxyProtocolError::EmptyIdempotencyKey) - )); - - let mut no_idempotency_key = request.clone(); - no_idempotency_key.idempotency_key = None; - no_idempotency_key - .validate(1) - .expect("missing idempotency key is valid"); - - let mut invalid_quorum = request; - invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 }; - assert!(matches!( - invalid_quorum.validate(1), - Err(PublishProxyProtocolError::InvalidQuorum) - )); - } - - #[test] - fn capabilities_match_publish_proxy_v1_surface() { - let capabilities = PublishCapabilities::v1(65_536, 20); - let value = serde_json::to_value(&capabilities).expect("capabilities"); - assert_eq!(value["daemon"], DAEMON_NAME); - assert_eq!(value["api_version"], API_VERSION); - assert_eq!(value["auth"]["mode"], "scoped_bearer_token"); - assert_eq!(value["publish"]["server_side_user_signing"], false); - assert!( - value["methods"] - .as_array() - .expect("methods") - .iter() - .any(|method| method == METHOD_EVENT) + let mut empty_key = request.clone(); + empty_key.idempotency_key = Some(" ".to_owned()); + assert_eq!( + empty_key.validate(1), + Err(TransportPublishProtocolError::EmptyIdempotencyKey) ); } #[test] - fn outcome_kind_semantics_cover_daemon_results() { - let all = [ - PublishRelayOutcomeKind::Accepted, - PublishRelayOutcomeKind::DuplicateAccepted, - PublishRelayOutcomeKind::Blocked, - PublishRelayOutcomeKind::RateLimited, - PublishRelayOutcomeKind::Invalid, - PublishRelayOutcomeKind::PowRequired, - PublishRelayOutcomeKind::Restricted, - PublishRelayOutcomeKind::AuthRequired, - PublishRelayOutcomeKind::Muted, - PublishRelayOutcomeKind::Unsupported, - PublishRelayOutcomeKind::PaymentRequired, - PublishRelayOutcomeKind::Error, - PublishRelayOutcomeKind::Timeout, - PublishRelayOutcomeKind::ConnectionFailed, - PublishRelayOutcomeKind::RelayUrlRejected, - PublishRelayOutcomeKind::SkippedAlreadyAccepted, - PublishRelayOutcomeKind::Unknown, - ]; - let quorum = [ - PublishRelayOutcomeKind::Accepted, - PublishRelayOutcomeKind::DuplicateAccepted, - PublishRelayOutcomeKind::SkippedAlreadyAccepted, - ]; - let retryable = [ - PublishRelayOutcomeKind::RateLimited, - PublishRelayOutcomeKind::PowRequired, - PublishRelayOutcomeKind::AuthRequired, - PublishRelayOutcomeKind::Error, - PublishRelayOutcomeKind::Timeout, - PublishRelayOutcomeKind::ConnectionFailed, - PublishRelayOutcomeKind::Unknown, - ]; - let terminal = [ - PublishRelayOutcomeKind::Blocked, - PublishRelayOutcomeKind::Invalid, - PublishRelayOutcomeKind::Restricted, - PublishRelayOutcomeKind::Muted, - PublishRelayOutcomeKind::Unsupported, - PublishRelayOutcomeKind::PaymentRequired, - PublishRelayOutcomeKind::RelayUrlRejected, - ]; - - for kind in all { - assert_eq!(kind.counts_toward_quorum(), quorum.contains(&kind)); - assert_eq!(kind.is_retryable(), retryable.contains(&kind)); - assert_eq!(kind.is_terminal_failure(), terminal.contains(&kind)); - } + fn outcome_kinds_classify_satisfaction_retry_and_terminal() { + assert!(TransportPublishOutcomeKind::Accepted.counts_toward_satisfaction()); + assert!(TransportPublishOutcomeKind::SkippedAlreadyAccepted.counts_toward_satisfaction()); + assert!(TransportPublishOutcomeKind::Timeout.is_retryable()); + assert!(TransportPublishOutcomeKind::Unavailable.is_terminal_failure()); + assert!(TransportPublishOutcomeKind::Deferred.is_terminal_failure()); } #[test] - fn publish_job_view_validation_covers_success_and_errors() { - let valid = job_view(); - valid.validate().expect("valid job view"); - - let mut empty_job_id = valid.clone(); - empty_job_id.job_id = " ".to_owned(); - assert!(matches!( - empty_job_id.validate(), - Err(PublishProxyProtocolError::EmptyJobId) - )); + fn job_view_validation_rejects_bad_identity() { + let job = TransportPublishJobView { + job_id: "job-1".to_owned(), + status: TransportPublishJobStatus::DeliverySatisfied, + terminal: true, + delivery_satisfied: true, + event_id: "0".repeat(64), + pubkey: "1".repeat(64), + event_kind: 30_402, + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com"), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + target_count: 1, + acknowledged_count: 1, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1, + completed_at_ms: Some(2), + last_error: None, + targets: Vec::new(), + }; - let mut invalid_event_id = valid.clone(); - invalid_event_id.event_id = "Z".repeat(64); + job.validate().expect("valid job"); + let mut invalid = job; + invalid.event_id = "bad".to_owned(); assert!(matches!( - invalid_event_id.validate(), - Err(PublishProxyProtocolError::InvalidHexField { + invalid.validate(), + Err(TransportPublishProtocolError::InvalidHexField { field: "event_id", .. }) )); + } - let mut invalid_pubkey = valid.clone(); - invalid_pubkey.pubkey = "1".repeat(63); - assert!(matches!( - invalid_pubkey.validate(), - Err(PublishProxyProtocolError::InvalidHexField { - field: "pubkey", - .. - }) - )); - - let mut invalid_kind = valid.clone(); - invalid_kind.event_kind = u16::MAX as u32 + 1; - assert!(matches!( - invalid_kind.validate(), - Err(PublishProxyProtocolError::InvalidKind(_)) - )); - - let mut invalid_quorum = valid; - invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 }; - assert!(matches!( - invalid_quorum.validate(), - Err(PublishProxyProtocolError::InvalidQuorum) - )); + #[test] + fn serde_round_trip_preserves_preview_target() { + let request = TransportPublishEventRequest { + event: event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::reticulum_preview( + "reticulum:preview", + TransportPublishPreviewBehavior::DeferDeliveryPlans, + ), + ]), + delivery_policy: TransportPublishDeliveryPolicy::All, + idempotency_key: None, + timeout_ms: None, + }; + let encoded = serde_json::to_string(&request).expect("encode"); + assert!(encoded.contains("\"transport_kind\":\"reticulum\"")); + assert!(encoded.contains("\"preview_behavior\":\"defer_delivery_plans\"")); + let decoded: TransportPublishEventRequest = + serde_json::from_str(encoded.as_str()).expect("decode"); + assert_eq!(decoded, request); } }