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:
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::{