lib

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

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 }