tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

nip11.rs (23201B)


      1 #![forbid(unsafe_code)]
      2 
      3 use crate::{
      4     config::{BaseRelayRuntimeConfig, BaseRelayRuntimeLimitsConfig, TenantRuntimeConfig},
      5     errors::BaseRelayError,
      6 };
      7 use axum::{
      8     Json, Router,
      9     extract::State,
     10     response::{IntoResponse, Response},
     11     routing::get,
     12 };
     13 use http::{HeaderMap, HeaderValue, StatusCode, header};
     14 use serde::{Deserialize, Serialize};
     15 use tangle_crypto::RelaySigner;
     16 use tangle_groups::GroupRuntimeConfig;
     17 use tangle_protocol::PublicKeyHex;
     18 
     19 const ALWAYS_SUPPORTED_NIPS: [u16; 5] = [1, 11, 42, 45, 70];
     20 const GROUP_SUPPORTED_NIP: u16 = 29;
     21 
     22 pub fn supported_nips_for_runtime(runtime: &BaseRelayRuntimeConfig) -> Vec<u16> {
     23     supported_nips_for_group_capability(runtime.groups().enabled())
     24 }
     25 
     26 pub fn supported_nips_for_group_capability(groups_enabled: bool) -> Vec<u16> {
     27     let mut supported_nips = ALWAYS_SUPPORTED_NIPS.to_vec();
     28     if groups_enabled {
     29         supported_nips.push(GROUP_SUPPORTED_NIP);
     30         supported_nips.sort_unstable();
     31     }
     32     supported_nips
     33 }
     34 
     35 #[derive(Debug, Clone, PartialEq, Eq)]
     36 pub struct BaseRelayInfoConfig {
     37     name: String,
     38     description: Option<String>,
     39     contact: Option<String>,
     40     icon: Option<String>,
     41     groups: GroupRuntimeConfig,
     42     limits: BaseRelayRuntimeLimitsConfig,
     43     software: String,
     44     version: String,
     45     payment_required: bool,
     46     restricted_writes: bool,
     47     supported_nips: Vec<u16>,
     48 }
     49 
     50 impl BaseRelayInfoConfig {
     51     pub fn new(
     52         name: impl Into<String>,
     53         runtime: &BaseRelayRuntimeConfig,
     54     ) -> Result<Self, BaseRelayError> {
     55         let name = name.into();
     56         if name.trim().is_empty() {
     57             return Err(BaseRelayError::invalid("relay name must not be empty"));
     58         }
     59         Ok(Self {
     60             name,
     61             description: None,
     62             contact: None,
     63             icon: None,
     64             groups: runtime.groups().clone(),
     65             limits: runtime.limits(),
     66             software: crate::TANGLE_RELAY_SOFTWARE.to_owned(),
     67             version: crate::TANGLE_RELAY_VERSION.to_owned(),
     68             payment_required: false,
     69             restricted_writes: true,
     70             supported_nips: supported_nips_for_runtime(runtime),
     71         })
     72     }
     73 
     74     pub fn from_tenant_config(tenant: &TenantRuntimeConfig) -> Result<Self, BaseRelayError> {
     75         let mut config = Self {
     76             name: tenant.info().name().to_owned(),
     77             description: tenant.info().description().map(str::to_owned),
     78             contact: tenant.info().contact().map(str::to_owned),
     79             icon: tenant.info().icon().map(str::to_owned),
     80             groups: tenant.groups().clone(),
     81             limits: tenant.limits(),
     82             software: crate::TANGLE_RELAY_SOFTWARE.to_owned(),
     83             version: crate::TANGLE_RELAY_VERSION.to_owned(),
     84             payment_required: false,
     85             restricted_writes: true,
     86             supported_nips: supported_nips_for_group_capability(tenant.groups().enabled()),
     87         };
     88         if config.name.trim().is_empty() {
     89             return Err(BaseRelayError::invalid("relay name must not be empty"));
     90         }
     91         config.name = config.name.trim().to_owned();
     92         Ok(config)
     93     }
     94 
     95     pub fn with_description(mut self, description: impl Into<String>) -> Self {
     96         self.description = Some(description.into());
     97         self
     98     }
     99 
    100     pub fn with_contact(mut self, contact: impl Into<String>) -> Self {
    101         self.contact = Some(contact.into());
    102         self
    103     }
    104 
    105     pub fn with_icon(mut self, icon: impl Into<String>) -> Self {
    106         self.icon = Some(icon.into());
    107         self
    108     }
    109 
    110     pub fn build_document(&self) -> Result<BaseRelayInfoDocument, BaseRelayError> {
    111         let relay_self = relay_self_from_groups(&self.groups)?;
    112         Ok(BaseRelayInfoDocument {
    113             name: self.name.clone(),
    114             description: self.description.clone(),
    115             contact: self.contact.clone(),
    116             icon: self.icon.clone(),
    117             relay_self: relay_self.map(|pubkey| pubkey.as_str().to_owned()),
    118             supported_nips: self.supported_nips.clone(),
    119             software: self.software.clone(),
    120             version: self.version.clone(),
    121             limitation: BaseRelayInfoLimitationDocument {
    122                 max_message_length: self.limits.max_message_length(),
    123                 max_subscriptions: self.limits.max_subscriptions_per_connection(),
    124                 max_filters: self.limits.max_filters_per_request(),
    125                 max_limit: self.limits.max_limit(),
    126                 max_query_complexity: self.limits.max_query_complexity(),
    127                 max_subid_length: self.limits.max_subid_length(),
    128                 max_event_tags: self.limits.max_event_tags(),
    129                 max_content_length: self.limits.max_content_length(),
    130                 auth_required: false,
    131                 payment_required: self.payment_required,
    132                 restricted_writes: self.restricted_writes,
    133                 default_limit: self.limits.default_limit(),
    134             },
    135             retention: BaseRelayInfoRetentionDocument::tangle_default(),
    136         })
    137     }
    138 }
    139 
    140 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    141 pub struct BaseRelayInfoDocument {
    142     pub name: String,
    143     #[serde(skip_serializing_if = "Option::is_none")]
    144     pub description: Option<String>,
    145     #[serde(skip_serializing_if = "Option::is_none")]
    146     pub contact: Option<String>,
    147     #[serde(skip_serializing_if = "Option::is_none")]
    148     pub icon: Option<String>,
    149     #[serde(rename = "self", skip_serializing_if = "Option::is_none")]
    150     pub relay_self: Option<String>,
    151     pub supported_nips: Vec<u16>,
    152     pub software: String,
    153     pub version: String,
    154     pub limitation: BaseRelayInfoLimitationDocument,
    155     pub retention: BaseRelayInfoRetentionDocument,
    156 }
    157 
    158 impl BaseRelayInfoDocument {
    159     pub fn relay_self(&self) -> Option<&str> {
    160         self.relay_self.as_deref()
    161     }
    162 }
    163 
    164 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    165 pub struct BaseRelayInfoLimitationDocument {
    166     pub max_message_length: usize,
    167     pub max_subscriptions: usize,
    168     pub max_filters: usize,
    169     pub max_limit: u64,
    170     pub max_query_complexity: usize,
    171     pub max_subid_length: usize,
    172     pub max_event_tags: usize,
    173     pub max_content_length: usize,
    174     pub auth_required: bool,
    175     pub payment_required: bool,
    176     pub restricted_writes: bool,
    177     pub default_limit: u64,
    178 }
    179 
    180 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    181 pub struct BaseRelayInfoRetentionDocument {
    182     pub accepted_events: String,
    183     pub relay_generated_events: String,
    184     pub group_visibility: String,
    185     pub physical_erasure: bool,
    186     pub compaction_guarantee: bool,
    187 }
    188 
    189 impl BaseRelayInfoRetentionDocument {
    190     fn tangle_default() -> Self {
    191         Self {
    192             accepted_events: "accepted events are retained in canonical storage without a time-based expiration policy".to_owned(),
    193             relay_generated_events: "relay-generated group state events are retained with their source events".to_owned(),
    194             group_visibility: "private and hidden group policy gates visibility without implying physical deletion".to_owned(),
    195             physical_erasure: false,
    196             compaction_guarantee: false,
    197         }
    198     }
    199 }
    200 
    201 pub fn base_relay_info_router(document: BaseRelayInfoDocument) -> Router {
    202     Router::new()
    203         .route("/", get(base_relay_info))
    204         .with_state(document)
    205 }
    206 
    207 async fn base_relay_info(
    208     State(document): State<BaseRelayInfoDocument>,
    209     headers: HeaderMap,
    210 ) -> Response {
    211     base_relay_info_response(document, headers)
    212 }
    213 
    214 pub fn base_relay_info_response(document: BaseRelayInfoDocument, headers: HeaderMap) -> Response {
    215     if !accepts_nostr_json(headers.get(header::ACCEPT)) {
    216         return (
    217             StatusCode::NOT_FOUND,
    218             "relay information requires application/nostr+json",
    219         )
    220             .into_response();
    221     }
    222     (
    223         StatusCode::OK,
    224         [
    225             (
    226                 header::CONTENT_TYPE,
    227                 HeaderValue::from_static("application/nostr+json"),
    228             ),
    229             (
    230                 header::ACCESS_CONTROL_ALLOW_ORIGIN,
    231                 HeaderValue::from_static("*"),
    232             ),
    233             (
    234                 header::ACCESS_CONTROL_ALLOW_HEADERS,
    235                 HeaderValue::from_static("*"),
    236             ),
    237             (
    238                 header::ACCESS_CONTROL_ALLOW_METHODS,
    239                 HeaderValue::from_static("*"),
    240             ),
    241         ],
    242         Json(document),
    243     )
    244         .into_response()
    245 }
    246 
    247 fn relay_self_from_groups(
    248     groups: &GroupRuntimeConfig,
    249 ) -> Result<Option<PublicKeyHex>, BaseRelayError> {
    250     groups
    251         .relay_secret()
    252         .map(|secret| RelaySigner::from_secret_hex(secret.expose_for_signing()))
    253         .transpose()
    254         .map(|signer| signer.map(|signer| signer.public_key().clone()))
    255         .map_err(BaseRelayError::invalid)
    256 }
    257 
    258 fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool {
    259     value
    260         .and_then(|value| value.to_str().ok())
    261         .is_some_and(|value| {
    262             value.split(',').any(|item| {
    263                 let item = item.trim();
    264                 item == "*/*" || item.starts_with("application/nostr+json")
    265             })
    266         })
    267 }
    268 
    269 #[cfg(test)]
    270 mod tests {
    271     use super::{BaseRelayInfoConfig, base_relay_info_response, base_relay_info_router};
    272     use crate::config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json};
    273     use axum::body::to_bytes;
    274     use http::{HeaderMap, HeaderValue, Request, StatusCode, header};
    275     use serde_json::{Value, json};
    276     use tangle_crypto::RelaySigner;
    277     use tower::ServiceExt;
    278 
    279     #[test]
    280     fn nip11_builder_reports_groups_and_relay_self_only_when_configured() {
    281         let config = runtime_config(enabled_groups());
    282         let disabled_config = runtime_config(json!({"enabled": false}));
    283         let document = BaseRelayInfoConfig::new("tangle", &config)
    284             .expect("config")
    285             .with_description("Tangle v2 relay")
    286             .build_document()
    287             .expect("document");
    288         let disabled = BaseRelayInfoConfig::new("tangle", &disabled_config)
    289             .expect("config")
    290             .build_document()
    291             .expect("disabled");
    292 
    293         assert_eq!(document.supported_nips, vec![1, 11, 29, 42, 45, 70]);
    294         assert!(document.relay_self().is_some());
    295         assert_eq!(document.description.as_deref(), Some("Tangle v2 relay"));
    296         assert_eq!(document.limitation.max_message_length, 1_048_576);
    297         assert_eq!(document.limitation.max_subscriptions, 64);
    298         assert_eq!(document.limitation.max_filters, 10);
    299         assert_eq!(document.limitation.max_limit, 500);
    300         assert_eq!(document.limitation.max_query_complexity, 2_048);
    301         assert_eq!(document.limitation.max_subid_length, 64);
    302         assert_eq!(document.limitation.max_event_tags, 200);
    303         assert_eq!(document.limitation.max_content_length, 65_536);
    304         assert!(!document.limitation.auth_required);
    305         assert!(!document.limitation.payment_required);
    306         assert!(document.limitation.restricted_writes);
    307         assert_eq!(document.limitation.default_limit, 100);
    308         assert_eq!(
    309             document.retention.accepted_events,
    310             "accepted events are retained in canonical storage without a time-based expiration policy"
    311         );
    312         assert_eq!(
    313             document.retention.group_visibility,
    314             "private and hidden group policy gates visibility without implying physical deletion"
    315         );
    316         assert!(!document.retention.physical_erasure);
    317         assert!(!document.retention.compaction_guarantee);
    318         assert_eq!(disabled.supported_nips, vec![1, 11, 42, 45, 70]);
    319         assert!(disabled.relay_self().is_none());
    320     }
    321 
    322     #[tokio::test]
    323     async fn nip11_preserves_chorus_relay_information_parity() {
    324         let config = runtime_config(enabled_groups());
    325         let disabled_config = runtime_config(json!({"enabled": false}));
    326         let document = BaseRelayInfoConfig::new("tangle", &config)
    327             .expect("config")
    328             .with_description("Tangle relay")
    329             .with_contact("ops@radroots.test")
    330             .with_icon("https://relay.radroots.test/icon.png")
    331             .build_document()
    332             .expect("document");
    333         let disabled = BaseRelayInfoConfig::new("tangle", &disabled_config)
    334             .expect("disabled config")
    335             .build_document()
    336             .expect("disabled");
    337         let relay_self = RelaySigner::from_secret_hex(&"7".repeat(64))
    338             .expect("relay signer")
    339             .public_key()
    340             .clone();
    341 
    342         assert_eq!(document.name, "tangle");
    343         assert_eq!(document.description.as_deref(), Some("Tangle relay"));
    344         assert_eq!(document.contact.as_deref(), Some("ops@radroots.test"));
    345         assert_eq!(
    346             document.icon.as_deref(),
    347             Some("https://relay.radroots.test/icon.png")
    348         );
    349         assert_eq!(document.relay_self(), Some(relay_self.as_str()));
    350         assert_eq!(document.supported_nips, vec![1, 11, 29, 42, 45, 70]);
    351         for absent in [50, 77, 86, 98, 99] {
    352             assert!(!document.supported_nips.contains(&absent));
    353         }
    354         assert_eq!(disabled.supported_nips, vec![1, 11, 42, 45, 70]);
    355         assert!(disabled.relay_self().is_none());
    356         assert_eq!(document.software, crate::TANGLE_RELAY_SOFTWARE);
    357         assert_eq!(document.version, crate::TANGLE_RELAY_VERSION);
    358         assert_eq!(document.limitation.max_message_length, 1_048_576);
    359         assert_eq!(document.limitation.max_subscriptions, 64);
    360         assert_eq!(document.limitation.max_filters, 10);
    361         assert_eq!(document.limitation.max_limit, 500);
    362         assert_eq!(document.limitation.max_query_complexity, 2_048);
    363         assert_eq!(document.limitation.max_subid_length, 64);
    364         assert_eq!(document.limitation.max_event_tags, 200);
    365         assert_eq!(document.limitation.max_content_length, 65_536);
    366         assert!(!document.limitation.auth_required);
    367         assert!(!document.limitation.payment_required);
    368         assert!(document.limitation.restricted_writes);
    369         assert_eq!(document.limitation.default_limit, 100);
    370         assert_eq!(
    371             document.retention.accepted_events,
    372             "accepted events are retained in canonical storage without a time-based expiration policy"
    373         );
    374         assert_eq!(
    375             document.retention.relay_generated_events,
    376             "relay-generated group state events are retained with their source events"
    377         );
    378         assert_eq!(
    379             document.retention.group_visibility,
    380             "private and hidden group policy gates visibility without implying physical deletion"
    381         );
    382         assert!(!document.retention.physical_erasure);
    383         assert!(!document.retention.compaction_guarantee);
    384 
    385         let mut headers = HeaderMap::new();
    386         headers.insert(
    387             header::ACCEPT,
    388             HeaderValue::from_static("application/nostr+json; q=1"),
    389         );
    390         let response = base_relay_info_response(document.clone(), headers);
    391         assert_eq!(response.status(), StatusCode::OK);
    392         assert_eq!(
    393             response.headers().get(header::CONTENT_TYPE).expect("type"),
    394             "application/nostr+json"
    395         );
    396         assert_eq!(
    397             response
    398                 .headers()
    399                 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
    400                 .expect("origin"),
    401             "*"
    402         );
    403         assert_eq!(
    404             response
    405                 .headers()
    406                 .get(header::ACCESS_CONTROL_ALLOW_HEADERS)
    407                 .expect("headers"),
    408             "*"
    409         );
    410         assert_eq!(
    411             response
    412                 .headers()
    413                 .get(header::ACCESS_CONTROL_ALLOW_METHODS)
    414                 .expect("methods"),
    415             "*"
    416         );
    417         let body = to_bytes(response.into_body(), usize::MAX)
    418             .await
    419             .expect("body");
    420         let value = serde_json::from_slice::<Value>(&body).expect("json");
    421         assert_eq!(value["software"], crate::TANGLE_RELAY_SOFTWARE);
    422         assert_eq!(value["version"], crate::TANGLE_RELAY_VERSION);
    423         assert_eq!(value["supported_nips"], json!([1, 11, 29, 42, 45, 70]));
    424         assert_eq!(value["retention"]["physical_erasure"], false);
    425         assert_eq!(value["retention"]["compaction_guarantee"], false);
    426 
    427         let rejected = base_relay_info_response(document, HeaderMap::new());
    428         assert_eq!(rejected.status(), StatusCode::NOT_FOUND);
    429     }
    430 
    431     #[tokio::test]
    432     async fn nip11_router_serves_nostr_json_only_for_nostr_accept() {
    433         let config = runtime_config(enabled_groups());
    434         let document = BaseRelayInfoConfig::new("tangle", &config)
    435             .expect("config")
    436             .build_document()
    437             .expect("document");
    438         let response = base_relay_info_router(document.clone())
    439             .oneshot(
    440                 Request::builder()
    441                     .uri("/")
    442                     .header(header::ACCEPT, "application/nostr+json")
    443                     .body(axum::body::Body::empty())
    444                     .expect("request"),
    445             )
    446             .await
    447             .expect("response");
    448 
    449         assert_eq!(response.status(), StatusCode::OK);
    450         assert_eq!(
    451             response.headers().get(header::CONTENT_TYPE).expect("type"),
    452             "application/nostr+json"
    453         );
    454         assert_eq!(
    455             response
    456                 .headers()
    457                 .get(header::ACCESS_CONTROL_ALLOW_ORIGIN)
    458                 .expect("origin"),
    459             "*"
    460         );
    461         assert_eq!(
    462             response
    463                 .headers()
    464                 .get(header::ACCESS_CONTROL_ALLOW_HEADERS)
    465                 .expect("headers"),
    466             "*"
    467         );
    468         assert_eq!(
    469             response
    470                 .headers()
    471                 .get(header::ACCESS_CONTROL_ALLOW_METHODS)
    472                 .expect("methods"),
    473             "*"
    474         );
    475         let body = to_bytes(response.into_body(), usize::MAX)
    476             .await
    477             .expect("body");
    478         let value = serde_json::from_slice::<serde_json::Value>(&body).expect("json");
    479         assert_eq!(value["name"], document.name);
    480         assert!(value["self"].as_str().is_some());
    481         assert_eq!(value["retention"]["physical_erasure"], false);
    482         assert_eq!(value["retention"]["compaction_guarantee"], false);
    483         assert_eq!(
    484             value["retention"]["group_visibility"],
    485             "private and hidden group policy gates visibility without implying physical deletion"
    486         );
    487 
    488         let rejected = base_relay_info_router(document)
    489             .oneshot(
    490                 Request::builder()
    491                     .uri("/")
    492                     .body(axum::body::Body::empty())
    493                     .expect("request"),
    494             )
    495             .await
    496             .expect("response");
    497 
    498         assert_eq!(rejected.status(), StatusCode::NOT_FOUND);
    499     }
    500 
    501     fn enabled_groups() -> Value {
    502         let owner = RelaySigner::from_secret_hex(&"8".repeat(64))
    503             .expect("owner")
    504             .public_key()
    505             .clone();
    506         json!({
    507             "enabled": true,
    508             "canonical_relay_url": "wss://relay.radroots.test",
    509             "relay_secret": "7".repeat(64),
    510             "owner_pubkeys": [owner.as_str()]
    511         })
    512     }
    513 
    514     fn runtime_config(groups: Value) -> BaseRelayRuntimeConfig {
    515         parse_base_relay_runtime_config_json(
    516             &json!({
    517                 "server": {
    518                     "listen_addr": "127.0.0.1:0",
    519                     "relay_url": "wss://relay.radroots.test"
    520                 },
    521                 "pocket": {
    522                     "data_directory": "runtime/pocket",
    523                     "sync_policy": "flush_on_shutdown",
    524                     "query": {
    525                       "allow_scraping": false,
    526                       "allow_scrape_if_limited_to": 100,
    527                       "allow_scrape_if_max_seconds": 3600
    528                     }
    529                 },
    530                 "groups": groups,
    531                 "auth": {
    532                     "challenge_ttl_seconds": 300,
    533                     "created_at_skew_seconds": 600
    534                 },
    535                 "limits": {
    536                     "max_message_length": 1048576,
    537                     "max_subid_length": 64,
    538                     "max_subscriptions_per_connection": 64,
    539                     "max_filters_per_request": 10,
    540                     "max_tag_values_per_filter": 100,
    541                     "max_query_complexity": 2048,
    542                     "max_limit": 500,
    543                     "default_limit": 100,
    544                     "max_event_tags": 200,
    545                     "max_content_length": 65536,
    546                     "broadcast_channel_capacity": 4096,
    547                     "per_connection_outbound_queue": 256
    548                 },
    549                 "rate_limits": {
    550                     "auth": {
    551                         "per_ip": {"window_seconds": 60, "max_hits": 120},
    552                         "per_pubkey": {"window_seconds": 60, "max_hits": 30},
    553                         "failures": {"window_seconds": 300, "max_hits": 5},
    554                     "failures_per_ip": {"window_seconds": 300, "max_hits": 20}
    555                     },
    556                     "event": {
    557                         "per_ip": {"window_seconds": 60, "max_hits": 600},
    558                         "per_pubkey": {"window_seconds": 60, "max_hits": 120},
    559                         "per_kind": {"window_seconds": 60, "max_hits": 1000}
    560                     },
    561                     "group": {
    562                         "write_per_ip": {"window_seconds": 60, "max_hits": 300},
    563                         "write_per_pubkey": {"window_seconds": 60, "max_hits": 60},
    564                         "write_per_group": {"window_seconds": 60, "max_hits": 90},
    565                         "write_per_kind": {"window_seconds": 60, "max_hits": 300},
    566                         "join_flow": {"window_seconds": 300, "max_hits": 10},
    567                     "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30}
    568                     },
    569                     "req": {
    570                         "per_ip": {"window_seconds": 60, "max_hits": 600},
    571                         "per_connection": {"window_seconds": 60, "max_hits": 120},
    572                         "per_pubkey": {"window_seconds": 60, "max_hits": 240},
    573                         "per_group": {"window_seconds": 60, "max_hits": 240},
    574                         "per_kind": {"window_seconds": 60, "max_hits": 500},
    575                         "broad": {"window_seconds": 60, "max_hits": 30}
    576                     },
    577                     "count": {
    578                         "per_ip": {"window_seconds": 60, "max_hits": 300},
    579                         "per_connection": {"window_seconds": 60, "max_hits": 60},
    580                         "per_pubkey": {"window_seconds": 60, "max_hits": 120},
    581                         "per_group": {"window_seconds": 60, "max_hits": 120},
    582                         "per_kind": {"window_seconds": 60, "max_hits": 240},
    583                         "broad": {"window_seconds": 60, "max_hits": 20}
    584                     }
    585                 }
    586             })
    587             .to_string(),
    588         )
    589         .expect("runtime config")
    590     }
    591 }