tangle


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

commit caed34f071bd8b995d0804b113713be39d126d2a
parent 7e62f9971ce65a1522a0586ed9671bd65ecb086c
Author: triesap <tyson@radroots.org>
Date:   Sun, 14 Jun 2026 08:28:38 -0700

nip11: advertise runtime limits

Diffstat:
Mcrates/tangle/tests/version.rs | 9+++++++++
Mcrates/tangle_runtime/src/nip11.rs | 141++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/tangle_runtime/src/server.rs | 16+++++++++++++---
Mcrates/tangle_runtime/tests/base_relay_v2.rs | 102+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/tangle_runtime/tests/phase2_acceptance_targets.rs | 2+-
5 files changed, 240 insertions(+), 30 deletions(-)

diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs @@ -189,8 +189,17 @@ fn tangle_run_starts_server_and_stays_alive_until_shutdown() { assert_eq!(ready_value["status"], "ready"); assert_eq!(ready_value["checks"]["pocket_storage"], "ready"); assert_eq!(nip11_value["name"], "tangle"); + assert_eq!(nip11_value["limitation"]["max_message_length"], 1_048_576); + assert_eq!(nip11_value["limitation"]["max_subscriptions"], 64); + assert_eq!(nip11_value["limitation"]["max_filters"], 10); + assert_eq!(nip11_value["limitation"]["max_limit"], 500); + assert_eq!(nip11_value["limitation"]["max_subid_length"], 64); + assert_eq!(nip11_value["limitation"]["max_event_tags"], 200); + assert_eq!(nip11_value["limitation"]["max_content_length"], 65_536); + assert_eq!(nip11_value["limitation"]["auth_required"], false); assert_eq!(nip11_value["limitation"]["payment_required"], false); assert_eq!(nip11_value["limitation"]["restricted_writes"], true); + assert_eq!(nip11_value["limitation"]["default_limit"], 100); assert!( nip11_value["supported_nips"] .as_array() diff --git a/crates/tangle_runtime/src/nip11.rs b/crates/tangle_runtime/src/nip11.rs @@ -1,6 +1,9 @@ #![forbid(unsafe_code)] -use crate::errors::BaseRelayError; +use crate::{ + config::{BaseRelayRuntimeConfig, BaseRelayRuntimeLimitsConfig}, + errors::BaseRelayError, +}; use axum::{ Json, Router, extract::State, @@ -22,6 +25,7 @@ pub struct BaseRelayInfoConfig { contact: Option<String>, icon: Option<String>, groups: GroupRuntimeConfig, + limits: BaseRelayRuntimeLimitsConfig, software: String, version: String, payment_required: bool, @@ -31,7 +35,7 @@ pub struct BaseRelayInfoConfig { impl BaseRelayInfoConfig { pub fn new( name: impl Into<String>, - groups: GroupRuntimeConfig, + runtime: &BaseRelayRuntimeConfig, ) -> Result<Self, BaseRelayError> { let name = name.into(); if name.trim().is_empty() { @@ -42,7 +46,8 @@ impl BaseRelayInfoConfig { description: None, contact: None, icon: None, - groups, + groups: runtime.groups().clone(), + limits: runtime.limits(), software: crate::TANGLE_RELAY_SOFTWARE.to_owned(), version: crate::TANGLE_RELAY_VERSION.to_owned(), payment_required: false, @@ -82,8 +87,17 @@ impl BaseRelayInfoConfig { software: self.software.clone(), version: self.version.clone(), limitation: BaseRelayInfoLimitationDocument { + max_message_length: self.limits.max_message_length(), + max_subscriptions: self.limits.max_subscriptions_per_connection(), + max_filters: self.limits.max_filters_per_request(), + max_limit: self.limits.max_limit(), + max_subid_length: self.limits.max_subid_length(), + max_event_tags: self.limits.max_event_tags(), + max_content_length: self.limits.max_content_length(), + auth_required: false, payment_required: self.payment_required, restricted_writes: self.restricted_writes, + default_limit: self.limits.default_limit(), }, }) } @@ -114,8 +128,17 @@ impl BaseRelayInfoDocument { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BaseRelayInfoLimitationDocument { + pub max_message_length: usize, + pub max_subscriptions: usize, + pub max_filters: usize, + pub max_limit: u64, + pub max_subid_length: usize, + pub max_event_tags: usize, + pub max_content_length: usize, + pub auth_required: bool, pub payment_required: bool, pub restricted_writes: bool, + pub default_limit: u64, } pub fn base_relay_info_router(document: BaseRelayInfoDocument) -> Router { @@ -189,21 +212,23 @@ fn accepts_nostr_json(value: Option<&HeaderValue>) -> bool { #[cfg(test)] mod tests { use super::{BaseRelayInfoConfig, base_relay_info_router}; + use crate::config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}; use axum::body::to_bytes; use http::{Request, StatusCode, header}; + use serde_json::{Value, json}; 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) + let config = runtime_config(enabled_groups()); + let disabled_config = runtime_config(json!({"enabled": false})); + let document = BaseRelayInfoConfig::new("tangle", &config) .expect("config") .with_description("Tangle v2 relay") .build_document() .expect("document"); - let disabled = BaseRelayInfoConfig::new("tangle", disabled_groups()) + let disabled = BaseRelayInfoConfig::new("tangle", &disabled_config) .expect("config") .build_document() .expect("disabled"); @@ -213,13 +238,25 @@ mod tests { assert!(document.supported_nips.contains(&70)); assert!(document.relay_self().is_some()); assert_eq!(document.description.as_deref(), Some("Tangle v2 relay")); + assert_eq!(document.limitation.max_message_length, 1_048_576); + assert_eq!(document.limitation.max_subscriptions, 64); + assert_eq!(document.limitation.max_filters, 10); + assert_eq!(document.limitation.max_limit, 500); + assert_eq!(document.limitation.max_subid_length, 64); + assert_eq!(document.limitation.max_event_tags, 200); + assert_eq!(document.limitation.max_content_length, 65_536); + assert!(!document.limitation.auth_required); + assert!(!document.limitation.payment_required); + assert!(document.limitation.restricted_writes); + assert_eq!(document.limitation.default_limit, 100); 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()) + let config = runtime_config(enabled_groups()); + let document = BaseRelayInfoConfig::new("tangle", &config) .expect("config") .build_document() .expect("document"); @@ -280,25 +317,85 @@ mod tests { assert_eq!(rejected.status(), StatusCode::NOT_FOUND); } - fn enabled_groups() -> tangle_groups::GroupRuntimeConfig { + fn enabled_groups() -> Value { 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") + json!({ + "enabled": true, + "canonical_relay_url": "wss://relay.radroots.test", + "relay_secret": "7".repeat(64), + "owner_pubkeys": [owner.as_str()] + }) } - fn disabled_groups() -> tangle_groups::GroupRuntimeConfig { - parse_group_runtime_config_json(r#"{"enabled": false}"#).expect("groups") + fn runtime_config(groups: Value) -> BaseRelayRuntimeConfig { + parse_base_relay_runtime_config_json( + &json!({ + "server": { + "listen_addr": "127.0.0.1:0", + "relay_url": "wss://relay.radroots.test" + }, + "pocket": { + "data_directory": "runtime/pocket", + "map_size_bytes": 1073741824_u64, + "reader_slots": 128, + "sync_policy": "flush_on_shutdown" + }, + "groups": groups, + "auth": { + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 + }, + "limits": { + "max_message_length": 1048576, + "max_subid_length": 64, + "max_subscriptions_per_connection": 64, + "max_filters_per_request": 10, + "max_tag_values_per_filter": 100, + "max_limit": 500, + "default_limit": 100, + "max_event_tags": 200, + "max_content_length": 65536, + "broadcast_channel_capacity": 4096, + "per_connection_outbound_queue": 256 + }, + "rate_limits": { + "auth": { + "per_pubkey": {"window_seconds": 60, "max_hits": 30}, + "failures": {"window_seconds": 300, "max_hits": 5} + }, + "event": { + "per_pubkey": {"window_seconds": 60, "max_hits": 120}, + "per_kind": {"window_seconds": 60, "max_hits": 1000} + }, + "group": { + "write_per_pubkey": {"window_seconds": 60, "max_hits": 60}, + "write_per_group": {"window_seconds": 60, "max_hits": 90}, + "write_per_kind": {"window_seconds": 60, "max_hits": 300}, + "join_flow": {"window_seconds": 300, "max_hits": 10} + }, + "req": { + "per_ip": {"window_seconds": 60, "max_hits": 600}, + "per_connection": {"window_seconds": 60, "max_hits": 120}, + "per_pubkey": {"window_seconds": 60, "max_hits": 240}, + "per_group": {"window_seconds": 60, "max_hits": 240}, + "per_kind": {"window_seconds": 60, "max_hits": 500}, + "broad": {"window_seconds": 60, "max_hits": 30} + }, + "count": { + "per_ip": {"window_seconds": 60, "max_hits": 300}, + "per_connection": {"window_seconds": 60, "max_hits": 60}, + "per_pubkey": {"window_seconds": 60, "max_hits": 120}, + "per_group": {"window_seconds": 60, "max_hits": 120}, + "per_kind": {"window_seconds": 60, "max_hits": 240}, + "broad": {"window_seconds": 60, "max_hits": 20} + } + } + }) + .to_string(), + ) + .expect("runtime config") } } diff --git a/crates/tangle_runtime/src/server.rs b/crates/tangle_runtime/src/server.rs @@ -64,8 +64,7 @@ pub async fn serve_listener_until_shutdown( .local_addr() .map_err(|error| BaseRelayError::error(error.to_string()))?; let relay_url = runtime.config().relay_url().to_owned(); - let info = - BaseRelayInfoConfig::new("tangle", runtime.config().groups().clone())?.build_document()?; + let info = BaseRelayInfoConfig::new("tangle", runtime.config())?.build_document()?; let readiness = runtime.readiness_state().clone(); let limits = runtime.limits(); let metrics = runtime.metrics().clone(); @@ -392,7 +391,7 @@ mod tests { async fn tangle_http_router_serves_nip11_health_and_ready_routes() { let root = temp_root("http-router"); let config = runtime_config(&root); - let info = BaseRelayInfoConfig::new("tangle", config.groups().clone()) + let info = BaseRelayInfoConfig::new("tangle", &config) .expect("info config") .build_document() .expect("info"); @@ -468,6 +467,17 @@ mod tests { let nip11_body = to_bytes(nip11.into_body(), usize::MAX).await.expect("body"); let nip11_value = serde_json::from_slice::<serde_json::Value>(&nip11_body).expect("json"); assert_eq!(nip11_value["name"], "tangle"); + assert_eq!(nip11_value["limitation"]["max_message_length"], 1_048_576); + assert_eq!(nip11_value["limitation"]["max_subscriptions"], 64); + assert_eq!(nip11_value["limitation"]["max_filters"], 10); + assert_eq!(nip11_value["limitation"]["max_limit"], 500); + assert_eq!(nip11_value["limitation"]["max_subid_length"], 64); + assert_eq!(nip11_value["limitation"]["max_event_tags"], 200); + assert_eq!(nip11_value["limitation"]["max_content_length"], 65_536); + assert_eq!(nip11_value["limitation"]["auth_required"], false); + assert_eq!(nip11_value["limitation"]["payment_required"], false); + assert_eq!(nip11_value["limitation"]["restricted_writes"], true); + assert_eq!(nip11_value["limitation"]["default_limit"], 100); assert!( nip11_value["supported_nips"] .as_array() diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs @@ -15,6 +15,7 @@ use tangle_protocol::{ filter_from_value, parse_client_message, parse_event_json, }; use tangle_runtime::{ + config::{BaseRelayRuntimeConfig, parse_base_relay_runtime_config_json}, groups::{GroupCheckpointStatus, validate_group_extra_tables}, nip11::{BASE_RELAY_SUPPORTED_NIPS, BaseRelayInfoConfig}, relay::{ @@ -75,12 +76,13 @@ fn public_relay_smoke_stores_queries_counts_and_fans_out() { #[test] fn nip11_integration_reports_group_contracts() { - let groups = group_config(); - let document = BaseRelayInfoConfig::new("tangle", groups) + let config = runtime_config(true); + let disabled_config = runtime_config(false); + let document = BaseRelayInfoConfig::new("tangle", &config) .expect("config") .build_document() .expect("document"); - let disabled = BaseRelayInfoConfig::new("tangle", GroupRuntimeConfig::disabled()) + let disabled = BaseRelayInfoConfig::new("tangle", &disabled_config) .expect("config") .build_document() .expect("disabled"); @@ -94,6 +96,17 @@ fn nip11_integration_reports_group_contracts() { assert!(!document.supported_nips.contains(&77)); assert!(!document.supported_nips.contains(&99)); assert!(document.relay_self().is_some()); + assert_eq!(document.limitation.max_message_length, 1_048_576); + assert_eq!(document.limitation.max_subscriptions, 64); + assert_eq!(document.limitation.max_filters, 10); + assert_eq!(document.limitation.max_limit, 500); + assert_eq!(document.limitation.max_subid_length, 64); + assert_eq!(document.limitation.max_event_tags, 200); + assert_eq!(document.limitation.max_content_length, 65_536); + assert!(!document.limitation.auth_required); + assert!(!document.limitation.payment_required); + assert!(document.limitation.restricted_writes); + assert_eq!(document.limitation.default_limit, 100); assert!(!disabled.supported_nips.contains(&29)); assert!(disabled.relay_self().is_none()); } @@ -1006,7 +1019,8 @@ fn delete_and_secondary_privacy_surfaces_are_read_gated_or_absent() { 1, ); - let document = BaseRelayInfoConfig::new("tangle", group_config()) + let config = runtime_config(true); + let document = BaseRelayInfoConfig::new("tangle", &config) .expect("config") .build_document() .expect("document"); @@ -1950,6 +1964,86 @@ fn group_config() -> GroupRuntimeConfig { tangle_v2_group_config(FixtureKey::Owner, &[FixtureKey::Admin]).expect("groups") } +fn runtime_config(groups_enabled: bool) -> BaseRelayRuntimeConfig { + let groups = if groups_enabled { + serde_json::json!({ + "enabled": true, + "canonical_relay_url": TANGLE_V2_RELAY_URL, + "relay_secret": TANGLE_V2_RELAY_SECRET_HEX, + "owner_pubkeys": [FixtureKey::Owner.public_key().as_str()], + "admin_pubkeys": [FixtureKey::Admin.public_key().as_str()] + }) + } else { + serde_json::json!({"enabled": false}) + }; + parse_base_relay_runtime_config_json( + &serde_json::json!({ + "server": { + "listen_addr": "127.0.0.1:0", + "relay_url": TANGLE_V2_RELAY_URL + }, + "pocket": { + "data_directory": "runtime/pocket", + "map_size_bytes": 1073741824_u64, + "reader_slots": 128, + "sync_policy": "flush_on_shutdown" + }, + "groups": groups, + "auth": { + "challenge_ttl_seconds": 300, + "created_at_skew_seconds": 600 + }, + "limits": { + "max_message_length": 1048576, + "max_subid_length": 64, + "max_subscriptions_per_connection": 64, + "max_filters_per_request": 10, + "max_tag_values_per_filter": 100, + "max_limit": 500, + "default_limit": 100, + "max_event_tags": 200, + "max_content_length": 65536, + "broadcast_channel_capacity": 4096, + "per_connection_outbound_queue": 256 + }, + "rate_limits": { + "auth": { + "per_pubkey": {"window_seconds": 60, "max_hits": 30}, + "failures": {"window_seconds": 300, "max_hits": 5} + }, + "event": { + "per_pubkey": {"window_seconds": 60, "max_hits": 120}, + "per_kind": {"window_seconds": 60, "max_hits": 1000} + }, + "group": { + "write_per_pubkey": {"window_seconds": 60, "max_hits": 60}, + "write_per_group": {"window_seconds": 60, "max_hits": 90}, + "write_per_kind": {"window_seconds": 60, "max_hits": 300}, + "join_flow": {"window_seconds": 300, "max_hits": 10} + }, + "req": { + "per_ip": {"window_seconds": 60, "max_hits": 600}, + "per_connection": {"window_seconds": 60, "max_hits": 120}, + "per_pubkey": {"window_seconds": 60, "max_hits": 240}, + "per_group": {"window_seconds": 60, "max_hits": 240}, + "per_kind": {"window_seconds": 60, "max_hits": 500}, + "broad": {"window_seconds": 60, "max_hits": 30} + }, + "count": { + "per_ip": {"window_seconds": 60, "max_hits": 300}, + "per_connection": {"window_seconds": 60, "max_hits": 60}, + "per_pubkey": {"window_seconds": 60, "max_hits": 120}, + "per_group": {"window_seconds": 60, "max_hits": 120}, + "per_kind": {"window_seconds": 60, "max_hits": 240}, + "broad": {"window_seconds": 60, "max_hits": 20} + } + } + }) + .to_string(), + ) + .expect("runtime config") +} + fn group_config_with_public_join() -> GroupRuntimeConfig { parse_group_runtime_config_json(&format!( r#"{{ diff --git a/crates/tangle_runtime/tests/phase2_acceptance_targets.rs b/crates/tangle_runtime/tests/phase2_acceptance_targets.rs @@ -291,7 +291,7 @@ fn protected_events_require_author_auth_before_nip70_is_advertised() { let root = temp_root("acceptance-nip70"); let _ = std::fs::remove_dir_all(&root); let config = runtime_config(&root, SocketAddr::from(([127, 0, 0, 1], 0))); - let document = BaseRelayInfoConfig::new("tangle", config.groups().clone()) + let document = BaseRelayInfoConfig::new("tangle", &config) .expect("info config") .build_document() .expect("document");