lib.rs (17976B)
1 #![cfg_attr(not(feature = "std"), no_std)] 2 #![forbid(unsafe_code)] 3 4 #[cfg(not(feature = "std"))] 5 extern crate alloc; 6 7 #[cfg(not(feature = "std"))] 8 use alloc::{string::String, vec::Vec}; 9 #[cfg(feature = "std")] 10 use std::{string::String, vec::Vec}; 11 12 use core::fmt; 13 14 pub const API_VERSION: &str = "radrootsd.publish_proxy.v1"; 15 pub const DAEMON_NAME: &str = "radrootsd"; 16 pub const METHOD_CAPABILITIES: &str = "publish.capabilities"; 17 pub const METHOD_EVENT: &str = "publish.event"; 18 pub const METHOD_JOB_GET: &str = "publish.job.get"; 19 pub const METHOD_JOB_LIST: &str = "publish.job.list"; 20 pub const METHOD_RELAYS_RESOLVE: &str = "publish.relays.resolve"; 21 22 #[derive(Clone, Debug, PartialEq, Eq)] 23 pub enum PublishProxyProtocolError { 24 InvalidHexField { 25 field: &'static str, 26 expected_len: usize, 27 }, 28 InvalidKind(u32), 29 EmptyTag { 30 index: usize, 31 }, 32 EmptyIdempotencyKey, 33 EmptyRelayUrl { 34 index: usize, 35 }, 36 RelayLimitExceeded { 37 max: usize, 38 actual: usize, 39 }, 40 InvalidQuorum, 41 EmptyPrincipalId, 42 EmptyJobId, 43 } 44 45 impl fmt::Display for PublishProxyProtocolError { 46 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 match self { 48 Self::InvalidHexField { 49 field, 50 expected_len, 51 } => write!(f, "{field} must be {expected_len} lowercase hex characters"), 52 Self::InvalidKind(kind) => write!(f, "event kind {kind} exceeds publish proxy range"), 53 Self::EmptyTag { index } => write!(f, "tag {index} must not be empty"), 54 Self::EmptyIdempotencyKey => f.write_str("idempotency key must not be empty"), 55 Self::EmptyRelayUrl { index } => write!(f, "relay URL {index} must not be empty"), 56 Self::RelayLimitExceeded { max, actual } => { 57 write!(f, "relay count {actual} exceeds limit {max}") 58 } 59 Self::InvalidQuorum => f.write_str("delivery quorum must be greater than zero"), 60 Self::EmptyPrincipalId => f.write_str("principal id must not be empty"), 61 Self::EmptyJobId => f.write_str("job id must not be empty"), 62 } 63 } 64 } 65 66 #[cfg(feature = "std")] 67 impl std::error::Error for PublishProxyProtocolError {} 68 69 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 70 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 71 #[derive(Clone, Debug, PartialEq, Eq)] 72 pub struct SignedNostrEventWire { 73 pub id: String, 74 pub pubkey: String, 75 pub created_at: u64, 76 pub kind: u32, 77 pub tags: Vec<Vec<String>>, 78 pub content: String, 79 pub sig: String, 80 } 81 82 impl SignedNostrEventWire { 83 pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { 84 validate_lower_hex("id", self.id.as_str(), 64)?; 85 validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; 86 validate_lower_hex("sig", self.sig.as_str(), 128)?; 87 if self.kind > u16::MAX as u32 { 88 return Err(PublishProxyProtocolError::InvalidKind(self.kind)); 89 } 90 for (index, tag) in self.tags.iter().enumerate() { 91 if tag.is_empty() { 92 return Err(PublishProxyProtocolError::EmptyTag { index }); 93 } 94 } 95 Ok(()) 96 } 97 } 98 99 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 100 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 101 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 102 pub enum PublishRelayPolicy { 103 ExplicitOnly, 104 RequestThenAuthorWriteThenDaemonDefault, 105 AuthorWriteThenDaemonDefault, 106 DaemonDefaultOnly, 107 } 108 109 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 110 #[cfg_attr(feature = "serde", serde(tag = "mode", rename_all = "snake_case"))] 111 #[derive(Clone, Debug, PartialEq, Eq)] 112 pub enum PublishDeliveryPolicy { 113 Any, 114 All, 115 Quorum { quorum: usize }, 116 } 117 118 impl PublishDeliveryPolicy { 119 pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { 120 if matches!(self, Self::Quorum { quorum: 0 }) { 121 Err(PublishProxyProtocolError::InvalidQuorum) 122 } else { 123 Ok(()) 124 } 125 } 126 127 pub fn required_ack_count(&self, relay_count: usize) -> usize { 128 match self { 129 Self::Any => usize::from(relay_count > 0), 130 Self::All => relay_count, 131 Self::Quorum { quorum } => *quorum, 132 } 133 } 134 } 135 136 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 137 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 138 #[derive(Clone, Debug, PartialEq, Eq)] 139 pub struct PublishEventRequest { 140 pub event: SignedNostrEventWire, 141 #[cfg_attr(feature = "serde", serde(default))] 142 pub relays: Vec<String>, 143 pub relay_policy: PublishRelayPolicy, 144 pub delivery_policy: PublishDeliveryPolicy, 145 #[cfg_attr( 146 feature = "serde", 147 serde(default, skip_serializing_if = "Option::is_none") 148 )] 149 pub idempotency_key: Option<String>, 150 #[cfg_attr( 151 feature = "serde", 152 serde(default, skip_serializing_if = "Option::is_none") 153 )] 154 pub timeout_ms: Option<u64>, 155 } 156 157 impl PublishEventRequest { 158 pub fn validate(&self, max_relays: usize) -> Result<(), PublishProxyProtocolError> { 159 self.event.validate()?; 160 self.delivery_policy.validate()?; 161 if self.relays.len() > max_relays { 162 return Err(PublishProxyProtocolError::RelayLimitExceeded { 163 max: max_relays, 164 actual: self.relays.len(), 165 }); 166 } 167 for (index, relay) in self.relays.iter().enumerate() { 168 if relay.trim().is_empty() { 169 return Err(PublishProxyProtocolError::EmptyRelayUrl { index }); 170 } 171 } 172 if self 173 .idempotency_key 174 .as_ref() 175 .is_some_and(|key| key.trim().is_empty()) 176 { 177 return Err(PublishProxyProtocolError::EmptyIdempotencyKey); 178 } 179 Ok(()) 180 } 181 } 182 183 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 184 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 185 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 186 pub enum PublishJobStatus { 187 Accepted, 188 Publishing, 189 DeliverySatisfied, 190 DeliveryUnsatisfiedRetryable, 191 DeliveryUnsatisfiedTerminal, 192 Rejected, 193 } 194 195 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 196 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 197 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 198 pub enum PublishRelayOutcomeKind { 199 Accepted, 200 DuplicateAccepted, 201 Blocked, 202 RateLimited, 203 Invalid, 204 PowRequired, 205 Restricted, 206 AuthRequired, 207 Muted, 208 Unsupported, 209 PaymentRequired, 210 Error, 211 Timeout, 212 ConnectionFailed, 213 RelayUrlRejected, 214 SkippedAlreadyAccepted, 215 Unknown, 216 } 217 218 impl PublishRelayOutcomeKind { 219 pub fn counts_toward_quorum(self) -> bool { 220 matches!( 221 self, 222 Self::Accepted | Self::DuplicateAccepted | Self::SkippedAlreadyAccepted 223 ) 224 } 225 226 pub fn is_retryable(self) -> bool { 227 matches!( 228 self, 229 Self::RateLimited 230 | Self::PowRequired 231 | Self::AuthRequired 232 | Self::Error 233 | Self::Timeout 234 | Self::ConnectionFailed 235 | Self::Unknown 236 ) 237 } 238 239 pub fn is_terminal_failure(self) -> bool { 240 matches!( 241 self, 242 Self::Blocked 243 | Self::Invalid 244 | Self::Restricted 245 | Self::Muted 246 | Self::Unsupported 247 | Self::PaymentRequired 248 | Self::RelayUrlRejected 249 ) 250 } 251 } 252 253 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 254 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 255 #[derive(Clone, Debug, PartialEq, Eq)] 256 pub struct PublishRelayOutcome { 257 pub relay_url: String, 258 pub source: PublishRelaySource, 259 pub attempted: bool, 260 pub outcome_kind: PublishRelayOutcomeKind, 261 #[cfg_attr( 262 feature = "serde", 263 serde(default, skip_serializing_if = "Option::is_none") 264 )] 265 pub message: Option<String>, 266 #[cfg_attr( 267 feature = "serde", 268 serde(default, skip_serializing_if = "Option::is_none") 269 )] 270 pub latency_ms: Option<u64>, 271 } 272 273 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 274 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 275 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 276 pub enum PublishRelaySource { 277 Request, 278 AuthorWrite, 279 DaemonDefault, 280 } 281 282 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 283 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 284 #[derive(Clone, Debug, PartialEq, Eq)] 285 pub struct PublishJobView { 286 pub job_id: String, 287 pub status: PublishJobStatus, 288 pub terminal: bool, 289 pub delivery_satisfied: bool, 290 pub event_id: String, 291 pub pubkey: String, 292 pub event_kind: u32, 293 pub relay_policy: PublishRelayPolicy, 294 pub delivery_policy: PublishDeliveryPolicy, 295 pub relay_count: usize, 296 pub acknowledged_count: usize, 297 pub retryable_count: usize, 298 pub terminal_count: usize, 299 pub requested_at_ms: i64, 300 #[cfg_attr( 301 feature = "serde", 302 serde(default, skip_serializing_if = "Option::is_none") 303 )] 304 pub completed_at_ms: Option<i64>, 305 #[cfg_attr( 306 feature = "serde", 307 serde(default, skip_serializing_if = "Option::is_none") 308 )] 309 pub last_error: Option<String>, 310 #[cfg_attr(feature = "serde", serde(default))] 311 pub relays: Vec<PublishRelayOutcome>, 312 } 313 314 impl PublishJobView { 315 pub fn validate(&self) -> Result<(), PublishProxyProtocolError> { 316 if self.job_id.trim().is_empty() { 317 return Err(PublishProxyProtocolError::EmptyJobId); 318 } 319 validate_lower_hex("event_id", self.event_id.as_str(), 64)?; 320 validate_lower_hex("pubkey", self.pubkey.as_str(), 64)?; 321 if self.event_kind > u16::MAX as u32 { 322 return Err(PublishProxyProtocolError::InvalidKind(self.event_kind)); 323 } 324 self.delivery_policy.validate() 325 } 326 } 327 328 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 329 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 330 #[derive(Clone, Debug, PartialEq, Eq)] 331 pub struct PublishEventResponse { 332 pub deduplicated: bool, 333 pub job: PublishJobView, 334 } 335 336 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 337 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 338 #[derive(Clone, Debug, PartialEq, Eq)] 339 pub struct PublishCapabilities { 340 pub daemon: String, 341 pub api_version: String, 342 pub transports: Vec<String>, 343 pub methods: Vec<String>, 344 pub auth: PublishAuthCapabilities, 345 pub publish: PublishSurfaceCapabilities, 346 } 347 348 impl PublishCapabilities { 349 pub fn v1(max_event_bytes: usize, max_relays_per_request: usize) -> Self { 350 Self { 351 daemon: DAEMON_NAME.to_owned(), 352 api_version: API_VERSION.to_owned(), 353 transports: vec!["jsonrpc_http".to_owned()], 354 methods: vec![ 355 METHOD_CAPABILITIES.to_owned(), 356 METHOD_EVENT.to_owned(), 357 METHOD_JOB_GET.to_owned(), 358 METHOD_JOB_LIST.to_owned(), 359 METHOD_RELAYS_RESOLVE.to_owned(), 360 ], 361 auth: PublishAuthCapabilities { 362 mode: "scoped_bearer_token".to_owned(), 363 }, 364 publish: PublishSurfaceCapabilities { 365 signed_event_ingress: true, 366 server_side_user_signing: false, 367 max_event_bytes, 368 max_relays_per_request, 369 delivery_policies: vec![ 370 PublishDeliveryPolicyName::Any, 371 PublishDeliveryPolicyName::Quorum, 372 PublishDeliveryPolicyName::All, 373 ], 374 relay_policies: vec![ 375 PublishRelayPolicy::ExplicitOnly, 376 PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, 377 PublishRelayPolicy::AuthorWriteThenDaemonDefault, 378 PublishRelayPolicy::DaemonDefaultOnly, 379 ], 380 }, 381 } 382 } 383 } 384 385 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 386 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 387 #[derive(Clone, Debug, PartialEq, Eq)] 388 pub struct PublishAuthCapabilities { 389 pub mode: String, 390 } 391 392 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 393 #[cfg_attr(feature = "serde", serde(deny_unknown_fields))] 394 #[derive(Clone, Debug, PartialEq, Eq)] 395 pub struct PublishSurfaceCapabilities { 396 pub signed_event_ingress: bool, 397 pub server_side_user_signing: bool, 398 pub max_event_bytes: usize, 399 pub max_relays_per_request: usize, 400 pub delivery_policies: Vec<PublishDeliveryPolicyName>, 401 pub relay_policies: Vec<PublishRelayPolicy>, 402 } 403 404 #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] 405 #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] 406 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 407 pub enum PublishDeliveryPolicyName { 408 Any, 409 Quorum, 410 All, 411 } 412 413 fn validate_lower_hex( 414 field: &'static str, 415 value: &str, 416 expected_len: usize, 417 ) -> Result<(), PublishProxyProtocolError> { 418 if value.len() == expected_len 419 && value 420 .as_bytes() 421 .iter() 422 .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) 423 { 424 Ok(()) 425 } else { 426 Err(PublishProxyProtocolError::InvalidHexField { 427 field, 428 expected_len, 429 }) 430 } 431 } 432 433 #[cfg(test)] 434 mod tests { 435 use super::*; 436 437 fn event() -> SignedNostrEventWire { 438 SignedNostrEventWire { 439 id: "0".repeat(64), 440 pubkey: "1".repeat(64), 441 created_at: 1_700_000_000, 442 kind: 30_402, 443 tags: vec![vec!["d".to_owned(), "listing-1".to_owned()]], 444 content: "{\"name\":\"carrots\"}".to_owned(), 445 sig: "2".repeat(128), 446 } 447 } 448 449 #[test] 450 fn signed_event_wire_uses_pubkey_and_rejects_author() { 451 let value = serde_json::to_value(event()).expect("serialize"); 452 assert!(value.get("pubkey").is_some()); 453 assert!(value.get("author").is_none()); 454 455 let err = serde_json::from_value::<SignedNostrEventWire>(serde_json::json!({ 456 "id": "0".repeat(64), 457 "author": "1".repeat(64), 458 "created_at": 1_700_000_000u64, 459 "kind": 30402u32, 460 "tags": [["d", "listing-1"]], 461 "content": "{}", 462 "sig": "2".repeat(128) 463 })) 464 .expect_err("author must not be accepted"); 465 let message = err.to_string(); 466 assert!(message.contains("author")); 467 assert!(message.contains("pubkey")); 468 } 469 470 #[test] 471 fn signed_event_validation_rejects_malformed_fields() { 472 let mut invalid_id = event(); 473 invalid_id.id = "A".repeat(64); 474 assert!(matches!( 475 invalid_id.validate(), 476 Err(PublishProxyProtocolError::InvalidHexField { field: "id", .. }) 477 )); 478 479 let mut invalid_kind = event(); 480 invalid_kind.kind = u16::MAX as u32 + 1; 481 assert!(matches!( 482 invalid_kind.validate(), 483 Err(PublishProxyProtocolError::InvalidKind(_)) 484 )); 485 486 let mut empty_tag = event(); 487 empty_tag.tags = vec![Vec::new()]; 488 assert!(matches!( 489 empty_tag.validate(), 490 Err(PublishProxyProtocolError::EmptyTag { index: 0 }) 491 )); 492 } 493 494 #[test] 495 fn publish_request_validation_covers_policy_and_relay_limits() { 496 let request = PublishEventRequest { 497 event: event(), 498 relays: vec!["wss://relay.example.com".to_owned()], 499 relay_policy: PublishRelayPolicy::RequestThenAuthorWriteThenDaemonDefault, 500 delivery_policy: PublishDeliveryPolicy::Quorum { quorum: 1 }, 501 idempotency_key: Some("key-1".to_owned()), 502 timeout_ms: Some(10_000), 503 }; 504 request.validate(1).expect("valid request"); 505 assert_eq!(request.delivery_policy.required_ack_count(3), 1); 506 507 let mut too_many = request.clone(); 508 too_many.relays.push("wss://relay-2.example.com".to_owned()); 509 assert!(matches!( 510 too_many.validate(1), 511 Err(PublishProxyProtocolError::RelayLimitExceeded { max: 1, actual: 2 }) 512 )); 513 514 let mut invalid_quorum = request; 515 invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 }; 516 assert!(matches!( 517 invalid_quorum.validate(1), 518 Err(PublishProxyProtocolError::InvalidQuorum) 519 )); 520 } 521 522 #[test] 523 fn capabilities_match_publish_proxy_v1_surface() { 524 let capabilities = PublishCapabilities::v1(65_536, 20); 525 let value = serde_json::to_value(&capabilities).expect("capabilities"); 526 assert_eq!(value["daemon"], DAEMON_NAME); 527 assert_eq!(value["api_version"], API_VERSION); 528 assert_eq!(value["auth"]["mode"], "scoped_bearer_token"); 529 assert_eq!(value["publish"]["server_side_user_signing"], false); 530 assert!( 531 value["methods"] 532 .as_array() 533 .expect("methods") 534 .iter() 535 .any(|method| method == METHOD_EVENT) 536 ); 537 } 538 539 #[test] 540 fn outcome_kind_semantics_cover_daemon_results() { 541 assert!(PublishRelayOutcomeKind::SkippedAlreadyAccepted.counts_toward_quorum()); 542 assert!(PublishRelayOutcomeKind::AuthRequired.is_retryable()); 543 assert!(PublishRelayOutcomeKind::RelayUrlRejected.is_terminal_failure()); 544 assert!(PublishRelayOutcomeKind::Muted.is_terminal_failure()); 545 assert!(PublishRelayOutcomeKind::PaymentRequired.is_terminal_failure()); 546 } 547 }