tangle


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

commit 4f86034e5fa0f2431329999277a47e0e1e4d46f8
parent 35db648b3cc9009a2194710f4b61d2443185e8cd
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 01:49:52 -0700

runtime: extract nip11 module

- move relay information documents into the nip11 module
- move the relay information router and Accept handling with NIP-11
- update integration tests to import the new NIP-11 surface directly
- verify formatting and focused tangle_runtime checks stay green

Diffstat:
Mcrates/tangle_runtime/src/base_relay.rs | 238++-----------------------------------------------------------------------------
Mcrates/tangle_runtime/src/lib.rs | 1+
Acrates/tangle_runtime/src/nip11.rs | 264+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 5+++--
4 files changed, 273 insertions(+), 235 deletions(-)

diff --git a/crates/tangle_runtime/src/base_relay.rs b/crates/tangle_runtime/src/base_relay.rs @@ -1,11 +1,6 @@ use crate::errors::{BaseRelayError, ok_accepted, ok_rejected}; -use axum::{ - Json, Router, - extract::State, - response::{IntoResponse, Response}, - routing::get, -}; -use http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::{Json, Router, extract::State, routing::get}; +use http::StatusCode; use serde::{Deserialize, Serialize}; use std::{collections::BTreeMap, collections::BTreeSet, str}; use tangle_crypto::{RelaySigner, verify_event_signature}; @@ -31,117 +26,6 @@ use tangle_store_pocket::{ TANGLE_GROUP_PROJECTION_TABLE, parse_pocket_event_json, parse_pocket_filter_json, }; -pub const BASE_RELAY_SUPPORTED_NIPS: [u16; 5] = [1, 11, 42, 45, 70]; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BaseRelayInfoConfig { - name: String, - description: Option<String>, - contact: Option<String>, - icon: Option<String>, - groups: GroupRuntimeConfig, - software: String, - version: String, - payment_required: bool, - restricted_writes: bool, -} - -impl BaseRelayInfoConfig { - pub fn new( - name: impl Into<String>, - groups: GroupRuntimeConfig, - ) -> Result<Self, BaseRelayError> { - let name = name.into(); - if name.trim().is_empty() { - return Err(BaseRelayError::invalid("relay name must not be empty")); - } - Ok(Self { - name, - description: None, - contact: None, - icon: None, - groups, - software: crate::TANGLE_RELAY_SOFTWARE.to_owned(), - version: crate::TANGLE_RELAY_VERSION.to_owned(), - payment_required: false, - restricted_writes: true, - }) - } - - pub fn with_description(mut self, description: impl Into<String>) -> Self { - self.description = Some(description.into()); - self - } - - pub fn with_contact(mut self, contact: impl Into<String>) -> Self { - self.contact = Some(contact.into()); - self - } - - pub fn with_icon(mut self, icon: impl Into<String>) -> Self { - self.icon = Some(icon.into()); - self - } - - pub fn build_document(&self) -> Result<BaseRelayInfoDocument, BaseRelayError> { - let relay_self = relay_self_from_groups(&self.groups)?; - let mut supported_nips = BASE_RELAY_SUPPORTED_NIPS.to_vec(); - if self.groups.enabled() { - supported_nips.push(29); - supported_nips.sort_unstable(); - } - Ok(BaseRelayInfoDocument { - name: self.name.clone(), - description: self.description.clone(), - contact: self.contact.clone(), - icon: self.icon.clone(), - relay_self: relay_self.map(|pubkey| pubkey.as_str().to_owned()), - supported_nips, - software: self.software.clone(), - version: self.version.clone(), - limitation: BaseRelayInfoLimitationDocument { - payment_required: self.payment_required, - restricted_writes: self.restricted_writes, - }, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct BaseRelayInfoDocument { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub contact: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub icon: Option<String>, - #[serde(rename = "self", skip_serializing_if = "Option::is_none")] - pub relay_self: Option<String>, - pub supported_nips: Vec<u16>, - pub software: String, - pub version: String, - pub limitation: BaseRelayInfoLimitationDocument, -} - -impl BaseRelayInfoDocument { - pub fn relay_self(&self) -> Option<&str> { - self.relay_self.as_deref() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct BaseRelayInfoLimitationDocument { - pub payment_required: bool, - pub restricted_writes: bool, -} - -pub fn base_relay_info_router(document: BaseRelayInfoDocument) -> Router { - Router::new() - .route("/", get(base_relay_info)) - .with_state(document) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BaseRelayReadinessCheckStatus { Ready, @@ -254,28 +138,6 @@ pub fn base_relay_ops_router(readiness: BaseRelayReadinessState) -> Router { .with_state(readiness) } -async fn base_relay_info( - State(document): State<BaseRelayInfoDocument>, - headers: HeaderMap, -) -> Response { - if !accepts_nostr_json(headers.get(header::ACCEPT)) { - return ( - StatusCode::NOT_FOUND, - "relay information requires application/nostr+json", - ) - .into_response(); - } - ( - StatusCode::OK, - [( - header::CONTENT_TYPE, - HeaderValue::from_static("application/nostr+json"), - )], - Json(document), - ) - .into_response() -} - async fn base_relay_healthz() -> Json<BaseRelayHealthDocument> { Json(BaseRelayHealthDocument { status: "ok".to_owned(), @@ -1355,28 +1217,6 @@ pub enum CloseResult { NotFound, } -fn relay_self_from_groups( - groups: &GroupRuntimeConfig, -) -> Result<Option<PublicKeyHex>, BaseRelayError> { - groups - .relay_secret() - .map(|secret| RelaySigner::from_secret_hex(secret.expose_for_signing())) - .transpose() - .map(|signer| signer.map(|signer| signer.public_key().clone())) - .map_err(BaseRelayError::invalid) -} - -fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool { - value - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| { - value.split(',').any(|item| { - let item = item.trim(); - item == "*/*" || item.starts_with("application/nostr+json") - }) - }) -} - fn tangle_event_to_pocket(event: &Event) -> Result<PocketOwnedEvent, BaseRelayError> { let raw = event_to_value(event).to_string(); parse_pocket_event_json(raw.as_bytes()).map_err(BaseRelayError::from) @@ -1405,11 +1245,11 @@ fn pocket_event_id(event_id: &EventId) -> Result<PocketEventId, BaseRelayError> #[cfg(test)] mod tests { use super::{ - BaseAuthState, BaseRelay, BaseRelayInfoConfig, BaseRelayReadinessCheckStatus, - BaseRelayReadinessState, CloseResult, base_relay_info_router, base_relay_ops_router, + BaseAuthState, BaseRelay, BaseRelayReadinessCheckStatus, BaseRelayReadinessState, + CloseResult, base_relay_ops_router, }; use axum::body::to_bytes; - use http::{Request, StatusCode, header}; + use http::{Request, StatusCode}; use tangle_crypto::RelaySigner; use tangle_groups::{ GroupId, KIND_GROUP_ADMINS, KIND_GROUP_CREATE_GROUP, KIND_GROUP_CREATE_INVITE, @@ -1424,69 +1264,6 @@ mod tests { use tangle_store_pocket::{PocketStoreConfig, PocketSyncPolicy}; use tower::ServiceExt; - #[test] - fn nip11_builder_reports_groups_and_relay_self_only_when_configured() { - let groups = enabled_groups(); - let document = BaseRelayInfoConfig::new("tangle", groups) - .expect("config") - .with_description("Tangle v2 relay") - .build_document() - .expect("document"); - let disabled = BaseRelayInfoConfig::new("tangle", disabled_groups()) - .expect("config") - .build_document() - .expect("disabled"); - - assert!(document.supported_nips.contains(&29)); - assert!(document.supported_nips.contains(&45)); - assert!(document.relay_self().is_some()); - assert_eq!(document.description.as_deref(), Some("Tangle v2 relay")); - assert!(!disabled.supported_nips.contains(&29)); - assert!(disabled.relay_self().is_none()); - } - - #[tokio::test] - async fn nip11_router_serves_nostr_json_only_for_nostr_accept() { - let document = BaseRelayInfoConfig::new("tangle", enabled_groups()) - .expect("config") - .build_document() - .expect("document"); - let response = base_relay_info_router(document.clone()) - .oneshot( - Request::builder() - .uri("/") - .header(header::ACCEPT, "application/nostr+json") - .body(axum::body::Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE).expect("type"), - "application/nostr+json" - ); - let body = to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let value = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); - assert_eq!(value["name"], document.name); - assert!(value["self"].as_str().is_some()); - - let rejected = base_relay_info_router(document) - .oneshot( - Request::builder() - .uri("/") - .body(axum::body::Body::empty()) - .expect("request"), - ) - .await - .expect("response"); - - assert_eq!(rejected.status(), StatusCode::NOT_FOUND); - } - #[tokio::test] async fn base_relay_ops_router_reports_health_and_readiness() { let health = base_relay_ops_router(BaseRelayReadinessState::ready()) @@ -2423,11 +2200,6 @@ mod tests { .expect("config") } - fn enabled_groups() -> tangle_groups::GroupRuntimeConfig { - let owner = signer(7).public_key().clone(); - enabled_groups_for_owner(&owner) - } - fn enabled_groups_for_owner(owner: &PublicKeyHex) -> tangle_groups::GroupRuntimeConfig { parse_group_runtime_config_json(&format!( r#"{{ diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -4,6 +4,7 @@ pub mod base_relay; pub mod chorus_pocket; pub mod config; pub mod errors; +pub mod nip11; use std::{fmt, fs, path::Path, path::PathBuf}; diff --git a/crates/tangle_runtime/src/nip11.rs b/crates/tangle_runtime/src/nip11.rs @@ -0,0 +1,264 @@ +#![forbid(unsafe_code)] + +use crate::errors::BaseRelayError; +use axum::{ + Json, Router, + extract::State, + response::{IntoResponse, Response}, + routing::get, +}; +use http::{HeaderMap, HeaderValue, StatusCode, header}; +use serde::{Deserialize, Serialize}; +use tangle_crypto::RelaySigner; +use tangle_groups::GroupRuntimeConfig; +use tangle_protocol::PublicKeyHex; + +pub const BASE_RELAY_SUPPORTED_NIPS: [u16; 5] = [1, 11, 42, 45, 70]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BaseRelayInfoConfig { + name: String, + description: Option<String>, + contact: Option<String>, + icon: Option<String>, + groups: GroupRuntimeConfig, + software: String, + version: String, + payment_required: bool, + restricted_writes: bool, +} + +impl BaseRelayInfoConfig { + pub fn new( + name: impl Into<String>, + groups: GroupRuntimeConfig, + ) -> Result<Self, BaseRelayError> { + let name = name.into(); + if name.trim().is_empty() { + return Err(BaseRelayError::invalid("relay name must not be empty")); + } + Ok(Self { + name, + description: None, + contact: None, + icon: None, + groups, + software: crate::TANGLE_RELAY_SOFTWARE.to_owned(), + version: crate::TANGLE_RELAY_VERSION.to_owned(), + payment_required: false, + restricted_writes: true, + }) + } + + pub fn with_description(mut self, description: impl Into<String>) -> Self { + self.description = Some(description.into()); + self + } + + pub fn with_contact(mut self, contact: impl Into<String>) -> Self { + self.contact = Some(contact.into()); + self + } + + pub fn with_icon(mut self, icon: impl Into<String>) -> Self { + self.icon = Some(icon.into()); + self + } + + pub fn build_document(&self) -> Result<BaseRelayInfoDocument, BaseRelayError> { + let relay_self = relay_self_from_groups(&self.groups)?; + let mut supported_nips = BASE_RELAY_SUPPORTED_NIPS.to_vec(); + if self.groups.enabled() { + supported_nips.push(29); + supported_nips.sort_unstable(); + } + Ok(BaseRelayInfoDocument { + name: self.name.clone(), + description: self.description.clone(), + contact: self.contact.clone(), + icon: self.icon.clone(), + relay_self: relay_self.map(|pubkey| pubkey.as_str().to_owned()), + supported_nips, + software: self.software.clone(), + version: self.version.clone(), + limitation: BaseRelayInfoLimitationDocument { + payment_required: self.payment_required, + restricted_writes: self.restricted_writes, + }, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaseRelayInfoDocument { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub contact: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option<String>, + #[serde(rename = "self", skip_serializing_if = "Option::is_none")] + pub relay_self: Option<String>, + pub supported_nips: Vec<u16>, + pub software: String, + pub version: String, + pub limitation: BaseRelayInfoLimitationDocument, +} + +impl BaseRelayInfoDocument { + pub fn relay_self(&self) -> Option<&str> { + self.relay_self.as_deref() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BaseRelayInfoLimitationDocument { + pub payment_required: bool, + pub restricted_writes: bool, +} + +pub fn base_relay_info_router(document: BaseRelayInfoDocument) -> Router { + Router::new() + .route("/", get(base_relay_info)) + .with_state(document) +} + +async fn base_relay_info( + State(document): State<BaseRelayInfoDocument>, + headers: HeaderMap, +) -> Response { + if !accepts_nostr_json(headers.get(header::ACCEPT)) { + return ( + StatusCode::NOT_FOUND, + "relay information requires application/nostr+json", + ) + .into_response(); + } + ( + StatusCode::OK, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("application/nostr+json"), + )], + Json(document), + ) + .into_response() +} + +fn relay_self_from_groups( + groups: &GroupRuntimeConfig, +) -> Result<Option<PublicKeyHex>, BaseRelayError> { + groups + .relay_secret() + .map(|secret| RelaySigner::from_secret_hex(secret.expose_for_signing())) + .transpose() + .map(|signer| signer.map(|signer| signer.public_key().clone())) + .map_err(BaseRelayError::invalid) +} + +fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool { + value + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value.split(',').any(|item| { + let item = item.trim(); + item == "*/*" || item.starts_with("application/nostr+json") + }) + }) +} + +#[cfg(test)] +mod tests { + use super::{BaseRelayInfoConfig, base_relay_info_router}; + use axum::body::to_bytes; + use http::{Request, StatusCode, header}; + use tangle_crypto::RelaySigner; + use tangle_groups::parse_group_runtime_config_json; + use tower::ServiceExt; + + #[test] + fn nip11_builder_reports_groups_and_relay_self_only_when_configured() { + let groups = enabled_groups(); + let document = BaseRelayInfoConfig::new("tangle", groups) + .expect("config") + .with_description("Tangle v2 relay") + .build_document() + .expect("document"); + let disabled = BaseRelayInfoConfig::new("tangle", disabled_groups()) + .expect("config") + .build_document() + .expect("disabled"); + + assert!(document.supported_nips.contains(&29)); + assert!(document.supported_nips.contains(&45)); + assert!(document.relay_self().is_some()); + assert_eq!(document.description.as_deref(), Some("Tangle v2 relay")); + assert!(!disabled.supported_nips.contains(&29)); + assert!(disabled.relay_self().is_none()); + } + + #[tokio::test] + async fn nip11_router_serves_nostr_json_only_for_nostr_accept() { + let document = BaseRelayInfoConfig::new("tangle", enabled_groups()) + .expect("config") + .build_document() + .expect("document"); + let response = base_relay_info_router(document.clone()) + .oneshot( + Request::builder() + .uri("/") + .header(header::ACCEPT, "application/nostr+json") + .body(axum::body::Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).expect("type"), + "application/nostr+json" + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let value = serde_json::from_slice::<serde_json::Value>(&body).expect("json"); + assert_eq!(value["name"], document.name); + assert!(value["self"].as_str().is_some()); + + let rejected = base_relay_info_router(document) + .oneshot( + Request::builder() + .uri("/") + .body(axum::body::Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(rejected.status(), StatusCode::NOT_FOUND); + } + + fn enabled_groups() -> tangle_groups::GroupRuntimeConfig { + let owner = RelaySigner::from_secret_hex(&"8".repeat(64)) + .expect("owner") + .public_key() + .clone(); + parse_group_runtime_config_json(&format!( + r#"{{ + "enabled": true, + "canonical_relay_url": "wss://relay.radroots.test", + "relay_secret": "{}", + "owner_pubkeys": ["{}"] + }}"#, + "7".repeat(64), + owner.as_str() + )) + .expect("groups") + } + + fn disabled_groups() -> tangle_groups::GroupRuntimeConfig { + parse_group_runtime_config_json(r#"{"enabled": false}"#).expect("groups") + } +} diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -10,8 +10,9 @@ use tangle_protocol::{ Event, Filter, RawEventJson, RelayMessage, SubscriptionId, Tag, UnixTimestamp, filter_from_value, parse_client_message, parse_event_json, }; -use tangle_runtime::base_relay::{ - BASE_RELAY_SUPPORTED_NIPS, BaseAuthState, BaseRelay, BaseRelayInfoConfig, CloseResult, +use tangle_runtime::{ + base_relay::{BaseAuthState, BaseRelay, CloseResult}, + nip11::{BASE_RELAY_SUPPORTED_NIPS, BaseRelayInfoConfig}, }; use tangle_store_pocket::{PocketStoreConfig, PocketSyncPolicy}; use tangle_test_support::{