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 }