tangle


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

config.rs (70610B)


      1 #![forbid(unsafe_code)]
      2 
      3 use crate::{
      4     errors::BaseRelayError,
      5     rate_limits::{
      6         TangleAuthRateLimitConfig, TangleEventRateLimitConfig, TangleGroupRateLimitConfig,
      7         TangleQueryRateLimitConfig, TangleRateLimitConfig, TangleRateLimitRule,
      8     },
      9     relay::{
     10         auth::BaseAuthState,
     11         core::{BaseRelay, BaseRelayLimitSettings, BaseRelayLimits},
     12     },
     13     tenant::{CanonicalHost, TenantId, TenantRelayUrl, TenantSchema},
     14 };
     15 use serde::Deserialize;
     16 use std::{
     17     collections::BTreeSet,
     18     net::SocketAddr,
     19     path::{Component, Path, PathBuf},
     20 };
     21 use tangle_crypto::RelaySigner;
     22 use tangle_groups::GroupRuntimeConfig;
     23 use tangle_protocol::{PublicKeyHex, SubscriptionId};
     24 use tangle_store_pocket::{PocketQueryConfig, PocketStoreConfig, PocketSyncPolicy};
     25 
     26 const MAX_POCKET_QUERY_SCRAPE_WINDOW_SECONDS: u64 = 86_400;
     27 
     28 #[derive(Debug, Clone, PartialEq, Eq)]
     29 pub struct TangleHostRuntimeConfig {
     30     listen_addr: SocketAddr,
     31     tenant_config_dir: PathBuf,
     32     limits: TangleHostLimitsConfig,
     33     ops: TangleHostOpsConfig,
     34     trusted_proxy: TangleTrustedProxyConfig,
     35     tracing: BaseRelayTracingConfig,
     36 }
     37 
     38 #[derive(Debug, Clone, PartialEq, Eq)]
     39 pub struct TangleHostRuntimeConfigSet {
     40     host: TangleHostRuntimeConfig,
     41     tenants: Vec<TenantRuntimeConfig>,
     42 }
     43 
     44 impl TangleHostRuntimeConfigSet {
     45     pub fn new(
     46         host: TangleHostRuntimeConfig,
     47         tenants: Vec<TenantRuntimeConfig>,
     48     ) -> Result<Self, BaseRelayError> {
     49         validate_tenant_config_set(&tenants)?;
     50         Ok(Self { host, tenants })
     51     }
     52 
     53     pub fn host(&self) -> &TangleHostRuntimeConfig {
     54         &self.host
     55     }
     56 
     57     pub fn tenants(&self) -> &[TenantRuntimeConfig] {
     58         &self.tenants
     59     }
     60 
     61     pub fn active_tenants(&self) -> impl Iterator<Item = &TenantRuntimeConfig> {
     62         self.tenants.iter().filter(|tenant| !tenant.inactive())
     63     }
     64 }
     65 
     66 impl TangleHostRuntimeConfig {
     67     pub fn listen_addr(&self) -> SocketAddr {
     68         self.listen_addr
     69     }
     70 
     71     pub fn tenant_config_dir(&self) -> &std::path::Path {
     72         &self.tenant_config_dir
     73     }
     74 
     75     pub fn limits(&self) -> TangleHostLimitsConfig {
     76         self.limits
     77     }
     78 
     79     pub fn ops(&self) -> TangleHostOpsConfig {
     80         self.ops
     81     }
     82 
     83     pub fn trusted_proxy(&self) -> &TangleTrustedProxyConfig {
     84         &self.trusted_proxy
     85     }
     86 
     87     pub fn tracing(&self) -> &BaseRelayTracingConfig {
     88         &self.tracing
     89     }
     90 }
     91 
     92 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
     93 pub struct TangleHostLimitsConfig {
     94     max_total_connections: usize,
     95     max_total_subscriptions: usize,
     96     tenant_startup_concurrency: usize,
     97 }
     98 
     99 impl TangleHostLimitsConfig {
    100     pub fn new(
    101         max_total_connections: usize,
    102         max_total_subscriptions: usize,
    103         tenant_startup_concurrency: usize,
    104     ) -> Result<Self, BaseRelayError> {
    105         require_positive("limits.max_total_connections", max_total_connections)?;
    106         require_positive("limits.max_total_subscriptions", max_total_subscriptions)?;
    107         require_positive(
    108             "limits.tenant_startup_concurrency",
    109             tenant_startup_concurrency,
    110         )?;
    111         Ok(Self {
    112             max_total_connections,
    113             max_total_subscriptions,
    114             tenant_startup_concurrency,
    115         })
    116     }
    117 
    118     pub fn max_total_connections(self) -> usize {
    119         self.max_total_connections
    120     }
    121 
    122     pub fn max_total_subscriptions(self) -> usize {
    123         self.max_total_subscriptions
    124     }
    125 
    126     pub fn tenant_startup_concurrency(self) -> usize {
    127         self.tenant_startup_concurrency
    128     }
    129 }
    130 
    131 impl Default for TangleHostLimitsConfig {
    132     fn default() -> Self {
    133         Self::new(10_000, 25_000, 4).expect("default host limits are valid")
    134     }
    135 }
    136 
    137 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    138 pub struct TangleHostOpsConfig {
    139     enabled: bool,
    140     expose_tenant_inventory: bool,
    141 }
    142 
    143 impl TangleHostOpsConfig {
    144     pub fn new(enabled: bool, expose_tenant_inventory: bool) -> Self {
    145         Self {
    146             enabled,
    147             expose_tenant_inventory,
    148         }
    149     }
    150 
    151     pub fn enabled(self) -> bool {
    152         self.enabled
    153     }
    154 
    155     pub fn expose_tenant_inventory(self) -> bool {
    156         self.expose_tenant_inventory
    157     }
    158 }
    159 
    160 impl Default for TangleHostOpsConfig {
    161     fn default() -> Self {
    162         Self::new(true, true)
    163     }
    164 }
    165 
    166 #[derive(Debug, Clone, PartialEq, Eq)]
    167 pub struct TangleTrustedProxyConfig {
    168     enabled: bool,
    169     trusted_peers: Vec<String>,
    170 }
    171 
    172 impl TangleTrustedProxyConfig {
    173     pub fn new(enabled: bool, trusted_peers: Vec<String>) -> Result<Self, BaseRelayError> {
    174         for peer in &trusted_peers {
    175             if peer.trim().is_empty() || peer.trim() != peer {
    176                 return Err(BaseRelayError::invalid(
    177                     "trusted_proxy.trusted_peers entries must not be empty or padded",
    178                 ));
    179             }
    180         }
    181         Ok(Self {
    182             enabled,
    183             trusted_peers,
    184         })
    185     }
    186 
    187     pub fn enabled(&self) -> bool {
    188         self.enabled
    189     }
    190 
    191     pub fn trusted_peers(&self) -> &[String] {
    192         &self.trusted_peers
    193     }
    194 }
    195 
    196 impl Default for TangleTrustedProxyConfig {
    197     fn default() -> Self {
    198         Self::new(false, Vec::new()).expect("default trusted proxy config is valid")
    199     }
    200 }
    201 
    202 #[derive(Debug, Clone, PartialEq, Eq)]
    203 pub struct TenantRelayInfoConfig {
    204     name: String,
    205     description: Option<String>,
    206     contact: Option<String>,
    207     icon: Option<String>,
    208 }
    209 
    210 impl TenantRelayInfoConfig {
    211     pub fn new(
    212         name: impl Into<String>,
    213         description: Option<String>,
    214         contact: Option<String>,
    215         icon: Option<String>,
    216     ) -> Result<Self, BaseRelayError> {
    217         let name = name.into();
    218         if name.trim().is_empty() || name.trim() != name {
    219             return Err(BaseRelayError::invalid(
    220                 "info.name must not be empty or padded",
    221             ));
    222         }
    223         Ok(Self {
    224             name,
    225             description: validate_optional_text("info.description", description)?,
    226             contact: validate_optional_text("info.contact", contact)?,
    227             icon: validate_optional_text("info.icon", icon)?,
    228         })
    229     }
    230 
    231     pub fn name(&self) -> &str {
    232         &self.name
    233     }
    234 
    235     pub fn description(&self) -> Option<&str> {
    236         self.description.as_deref()
    237     }
    238 
    239     pub fn contact(&self) -> Option<&str> {
    240         self.contact.as_deref()
    241     }
    242 
    243     pub fn icon(&self) -> Option<&str> {
    244         self.icon.as_deref()
    245     }
    246 }
    247 
    248 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    249 pub struct TenantBackupExportConfig {
    250     backup_enabled: bool,
    251     export_enabled: bool,
    252 }
    253 
    254 impl TenantBackupExportConfig {
    255     pub fn new(backup_enabled: bool, export_enabled: bool) -> Self {
    256         Self {
    257             backup_enabled,
    258             export_enabled,
    259         }
    260     }
    261 
    262     pub fn backup_enabled(self) -> bool {
    263         self.backup_enabled
    264     }
    265 
    266     pub fn export_enabled(self) -> bool {
    267         self.export_enabled
    268     }
    269 }
    270 
    271 impl Default for TenantBackupExportConfig {
    272     fn default() -> Self {
    273         Self::new(true, true)
    274     }
    275 }
    276 
    277 #[derive(Debug, Clone, PartialEq, Eq)]
    278 pub struct TenantRuntimeConfig {
    279     tenant_id: TenantId,
    280     tenant_schema: TenantSchema,
    281     host: CanonicalHost,
    282     relay_url: TenantRelayUrl,
    283     inactive: bool,
    284     info: TenantRelayInfoConfig,
    285     pocket: PocketStoreConfig,
    286     pocket_query: PocketQueryConfig,
    287     groups: GroupRuntimeConfig,
    288     auth_ttl_seconds: u64,
    289     auth_created_at_skew_seconds: u64,
    290     limits: BaseRelayRuntimeLimitsConfig,
    291     rate_limits: TangleRateLimitConfig,
    292     backup_export: TenantBackupExportConfig,
    293 }
    294 
    295 impl TenantRuntimeConfig {
    296     pub fn tenant_id(&self) -> &TenantId {
    297         &self.tenant_id
    298     }
    299 
    300     pub fn tenant_schema(&self) -> &TenantSchema {
    301         &self.tenant_schema
    302     }
    303 
    304     pub fn host(&self) -> &CanonicalHost {
    305         &self.host
    306     }
    307 
    308     pub fn relay_url(&self) -> &TenantRelayUrl {
    309         &self.relay_url
    310     }
    311 
    312     pub fn inactive(&self) -> bool {
    313         self.inactive
    314     }
    315 
    316     pub fn info(&self) -> &TenantRelayInfoConfig {
    317         &self.info
    318     }
    319 
    320     pub fn pocket_config(&self) -> &PocketStoreConfig {
    321         &self.pocket
    322     }
    323 
    324     pub fn pocket_query_config(&self) -> PocketQueryConfig {
    325         self.pocket_query
    326     }
    327 
    328     pub fn groups(&self) -> &GroupRuntimeConfig {
    329         &self.groups
    330     }
    331 
    332     pub fn relay_self_pubkey(&self) -> Result<Option<PublicKeyHex>, BaseRelayError> {
    333         self.groups
    334             .relay_secret()
    335             .map(|secret| RelaySigner::from_secret_hex(secret.expose_for_signing()))
    336             .transpose()
    337             .map(|signer| signer.map(|signer| signer.public_key().clone()))
    338             .map_err(BaseRelayError::invalid)
    339     }
    340 
    341     pub fn auth_ttl_seconds(&self) -> u64 {
    342         self.auth_ttl_seconds
    343     }
    344 
    345     pub fn auth_created_at_skew_seconds(&self) -> u64 {
    346         self.auth_created_at_skew_seconds
    347     }
    348 
    349     pub fn limits(&self) -> BaseRelayRuntimeLimitsConfig {
    350         self.limits
    351     }
    352 
    353     pub fn rate_limits(&self) -> TangleRateLimitConfig {
    354         self.rate_limits
    355     }
    356 
    357     pub fn backup_export(&self) -> TenantBackupExportConfig {
    358         self.backup_export
    359     }
    360 
    361     pub fn to_base_relay_runtime_config(
    362         &self,
    363         listen_addr: SocketAddr,
    364         tracing: BaseRelayTracingConfig,
    365     ) -> BaseRelayRuntimeConfig {
    366         BaseRelayRuntimeConfig {
    367             listen_addr,
    368             relay_url: self.relay_url.as_str().to_owned(),
    369             pocket: self.pocket.clone(),
    370             pocket_query: self.pocket_query,
    371             groups: self.groups.clone(),
    372             auth_ttl_seconds: self.auth_ttl_seconds,
    373             auth_created_at_skew_seconds: self.auth_created_at_skew_seconds,
    374             limits: self.limits,
    375             rate_limits: self.rate_limits,
    376             tracing,
    377         }
    378     }
    379 }
    380 
    381 #[derive(Debug, Clone, PartialEq, Eq)]
    382 pub struct BaseRelayRuntimeConfig {
    383     listen_addr: SocketAddr,
    384     relay_url: String,
    385     pocket: PocketStoreConfig,
    386     pocket_query: PocketQueryConfig,
    387     groups: GroupRuntimeConfig,
    388     auth_ttl_seconds: u64,
    389     auth_created_at_skew_seconds: u64,
    390     limits: BaseRelayRuntimeLimitsConfig,
    391     rate_limits: TangleRateLimitConfig,
    392     tracing: BaseRelayTracingConfig,
    393 }
    394 
    395 impl BaseRelayRuntimeConfig {
    396     pub fn listen_addr(&self) -> SocketAddr {
    397         self.listen_addr
    398     }
    399 
    400     pub fn relay_url(&self) -> &str {
    401         &self.relay_url
    402     }
    403 
    404     pub fn pocket_config(&self) -> &PocketStoreConfig {
    405         &self.pocket
    406     }
    407 
    408     pub fn pocket_query_config(&self) -> PocketQueryConfig {
    409         self.pocket_query
    410     }
    411 
    412     pub fn groups(&self) -> &GroupRuntimeConfig {
    413         &self.groups
    414     }
    415 
    416     pub fn auth_ttl_seconds(&self) -> u64 {
    417         self.auth_ttl_seconds
    418     }
    419 
    420     pub fn auth_created_at_skew_seconds(&self) -> u64 {
    421         self.auth_created_at_skew_seconds
    422     }
    423 
    424     pub fn limits(&self) -> BaseRelayRuntimeLimitsConfig {
    425         self.limits
    426     }
    427 
    428     pub fn rate_limits(&self) -> TangleRateLimitConfig {
    429         self.rate_limits
    430     }
    431 
    432     pub fn tracing(&self) -> &BaseRelayTracingConfig {
    433         &self.tracing
    434     }
    435 
    436     pub fn open_relay(&self) -> Result<BaseRelay, BaseRelayError> {
    437         BaseRelay::open_with_groups(
    438             &self.pocket,
    439             self.limits.base_relay_limits()?,
    440             &self.groups,
    441             self.pocket_query,
    442         )
    443     }
    444 
    445     pub fn auth_state(&self) -> Result<BaseAuthState, BaseRelayError> {
    446         BaseAuthState::new(
    447             self.relay_url.clone(),
    448             self.auth_ttl_seconds,
    449             self.auth_created_at_skew_seconds,
    450         )
    451     }
    452 }
    453 
    454 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    455 pub enum BaseRelayTracingFormat {
    456     Compact,
    457     Json,
    458 }
    459 
    460 impl BaseRelayTracingFormat {
    461     pub fn as_str(self) -> &'static str {
    462         match self {
    463             Self::Compact => "compact",
    464             Self::Json => "json",
    465         }
    466     }
    467 }
    468 
    469 #[derive(Debug, Clone, PartialEq, Eq)]
    470 pub struct BaseRelayTracingConfig {
    471     enabled: bool,
    472     filter: String,
    473     format: BaseRelayTracingFormat,
    474 }
    475 
    476 impl BaseRelayTracingConfig {
    477     pub fn new(
    478         enabled: bool,
    479         filter: impl Into<String>,
    480         format: BaseRelayTracingFormat,
    481     ) -> Result<Self, BaseRelayError> {
    482         let filter = filter.into();
    483         if filter.trim().is_empty() {
    484             return Err(BaseRelayError::invalid(
    485                 "observability.tracing.filter must not be empty",
    486             ));
    487         }
    488         Ok(Self {
    489             enabled,
    490             filter: filter.trim().to_owned(),
    491             format,
    492         })
    493     }
    494 
    495     pub fn enabled(&self) -> bool {
    496         self.enabled
    497     }
    498 
    499     pub fn filter(&self) -> &str {
    500         &self.filter
    501     }
    502 
    503     pub fn format(&self) -> BaseRelayTracingFormat {
    504         self.format
    505     }
    506 }
    507 
    508 impl Default for BaseRelayTracingConfig {
    509     fn default() -> Self {
    510         Self::new(
    511             true,
    512             "info,tangle=info,tangle_runtime=info,tangle_groups=info,tangle_store_pocket=info",
    513             BaseRelayTracingFormat::Json,
    514         )
    515         .expect("default tracing config is valid")
    516     }
    517 }
    518 
    519 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    520 pub struct BaseRelayRuntimeLimitsConfig {
    521     max_message_length: usize,
    522     max_subid_length: usize,
    523     max_subscriptions_per_connection: usize,
    524     max_filters_per_request: usize,
    525     max_tag_values_per_filter: usize,
    526     max_query_complexity: usize,
    527     max_limit: u64,
    528     default_limit: u64,
    529     max_event_tags: usize,
    530     max_content_length: usize,
    531     broadcast_channel_capacity: usize,
    532     per_connection_outbound_queue: usize,
    533 }
    534 
    535 impl BaseRelayRuntimeLimitsConfig {
    536     fn from_document(document: BaseRelayRuntimeLimitsDocument) -> Result<Self, BaseRelayError> {
    537         require_positive("limits.max_message_length", document.max_message_length)?;
    538         require_positive("limits.max_subid_length", document.max_subid_length)?;
    539         require_positive(
    540             "limits.max_subscriptions_per_connection",
    541             document.max_subscriptions_per_connection,
    542         )?;
    543         require_positive(
    544             "limits.max_filters_per_request",
    545             document.max_filters_per_request,
    546         )?;
    547         require_positive(
    548             "limits.max_tag_values_per_filter",
    549             document.max_tag_values_per_filter,
    550         )?;
    551         require_positive("limits.max_query_complexity", document.max_query_complexity)?;
    552         require_positive_u64("limits.max_limit", document.max_limit)?;
    553         require_positive_u64("limits.default_limit", document.default_limit)?;
    554         require_positive("limits.max_event_tags", document.max_event_tags)?;
    555         require_positive("limits.max_content_length", document.max_content_length)?;
    556         require_positive(
    557             "limits.broadcast_channel_capacity",
    558             document.broadcast_channel_capacity,
    559         )?;
    560         require_positive(
    561             "limits.per_connection_outbound_queue",
    562             document.per_connection_outbound_queue,
    563         )?;
    564         if document.max_subid_length > SubscriptionId::MAX_LENGTH {
    565             return Err(BaseRelayError::invalid(format!(
    566                 "limits.max_subid_length must be less than or equal to {}",
    567                 SubscriptionId::MAX_LENGTH
    568             )));
    569         }
    570         if document.default_limit > document.max_limit {
    571             return Err(BaseRelayError::invalid(
    572                 "limits.default_limit must be less than or equal to limits.max_limit",
    573             ));
    574         }
    575         Ok(Self {
    576             max_message_length: document.max_message_length,
    577             max_subid_length: document.max_subid_length,
    578             max_subscriptions_per_connection: document.max_subscriptions_per_connection,
    579             max_filters_per_request: document.max_filters_per_request,
    580             max_tag_values_per_filter: document.max_tag_values_per_filter,
    581             max_query_complexity: document.max_query_complexity,
    582             max_limit: document.max_limit,
    583             default_limit: document.default_limit,
    584             max_event_tags: document.max_event_tags,
    585             max_content_length: document.max_content_length,
    586             broadcast_channel_capacity: document.broadcast_channel_capacity,
    587             per_connection_outbound_queue: document.per_connection_outbound_queue,
    588         })
    589     }
    590 
    591     pub fn max_message_length(self) -> usize {
    592         self.max_message_length
    593     }
    594 
    595     pub fn max_subid_length(self) -> usize {
    596         self.max_subid_length
    597     }
    598 
    599     pub fn max_subscriptions_per_connection(self) -> usize {
    600         self.max_subscriptions_per_connection
    601     }
    602 
    603     pub fn max_filters_per_request(self) -> usize {
    604         self.max_filters_per_request
    605     }
    606 
    607     pub fn max_tag_values_per_filter(self) -> usize {
    608         self.max_tag_values_per_filter
    609     }
    610 
    611     pub fn max_query_complexity(self) -> usize {
    612         self.max_query_complexity
    613     }
    614 
    615     pub fn max_limit(self) -> u64 {
    616         self.max_limit
    617     }
    618 
    619     pub fn default_limit(self) -> u64 {
    620         self.default_limit
    621     }
    622 
    623     pub fn max_event_tags(self) -> usize {
    624         self.max_event_tags
    625     }
    626 
    627     pub fn max_content_length(self) -> usize {
    628         self.max_content_length
    629     }
    630 
    631     pub fn broadcast_channel_capacity(self) -> usize {
    632         self.broadcast_channel_capacity
    633     }
    634 
    635     pub fn per_connection_outbound_queue(self) -> usize {
    636         self.per_connection_outbound_queue
    637     }
    638 
    639     pub fn base_relay_limits(self) -> Result<BaseRelayLimits, BaseRelayError> {
    640         BaseRelayLimits::new(BaseRelayLimitSettings {
    641             max_pending_events: self.per_connection_outbound_queue,
    642             max_subscription_id_length: self.max_subid_length,
    643             max_subscriptions: self.max_subscriptions_per_connection,
    644             max_filters_per_request: self.max_filters_per_request,
    645             max_tag_values_per_filter: self.max_tag_values_per_filter,
    646             max_query_complexity: self.max_query_complexity,
    647             max_event_tags: self.max_event_tags,
    648             max_content_length: self.max_content_length,
    649             max_limit: self.max_limit,
    650             default_limit: self.default_limit,
    651         })
    652     }
    653 }
    654 
    655 #[derive(Debug, Deserialize)]
    656 #[serde(deny_unknown_fields)]
    657 struct TangleHostRuntimeConfigDocument {
    658     listen_addr: String,
    659     tenant_config_dir: String,
    660     #[serde(default)]
    661     limits: TangleHostLimitsConfigDocument,
    662     #[serde(default)]
    663     ops: TangleHostOpsConfigDocument,
    664     #[serde(default)]
    665     trusted_proxy: TangleTrustedProxyConfigDocument,
    666     #[serde(default)]
    667     observability: BaseRelayObservabilityConfigDocument,
    668 }
    669 
    670 #[derive(Debug, Deserialize)]
    671 #[serde(deny_unknown_fields)]
    672 struct TangleHostLimitsConfigDocument {
    673     max_total_connections: usize,
    674     max_total_subscriptions: usize,
    675     tenant_startup_concurrency: usize,
    676 }
    677 
    678 impl Default for TangleHostLimitsConfigDocument {
    679     fn default() -> Self {
    680         let defaults = TangleHostLimitsConfig::default();
    681         Self {
    682             max_total_connections: defaults.max_total_connections(),
    683             max_total_subscriptions: defaults.max_total_subscriptions(),
    684             tenant_startup_concurrency: defaults.tenant_startup_concurrency(),
    685         }
    686     }
    687 }
    688 
    689 #[derive(Debug, Deserialize)]
    690 #[serde(deny_unknown_fields)]
    691 struct TangleHostOpsConfigDocument {
    692     enabled: bool,
    693     expose_tenant_inventory: bool,
    694 }
    695 
    696 impl Default for TangleHostOpsConfigDocument {
    697     fn default() -> Self {
    698         let defaults = TangleHostOpsConfig::default();
    699         Self {
    700             enabled: defaults.enabled(),
    701             expose_tenant_inventory: defaults.expose_tenant_inventory(),
    702         }
    703     }
    704 }
    705 
    706 #[derive(Debug, Deserialize)]
    707 #[serde(deny_unknown_fields)]
    708 struct TangleTrustedProxyConfigDocument {
    709     enabled: bool,
    710     #[serde(default)]
    711     trusted_peers: Vec<String>,
    712 }
    713 
    714 impl Default for TangleTrustedProxyConfigDocument {
    715     fn default() -> Self {
    716         let defaults = TangleTrustedProxyConfig::default();
    717         Self {
    718             enabled: defaults.enabled(),
    719             trusted_peers: defaults.trusted_peers().to_vec(),
    720         }
    721     }
    722 }
    723 
    724 #[derive(Debug, Deserialize)]
    725 #[serde(deny_unknown_fields)]
    726 struct TenantRuntimeConfigDocument {
    727     tenant_id: String,
    728     tenant_schema: String,
    729     host: String,
    730     relay_url: String,
    731     #[serde(default)]
    732     inactive: bool,
    733     info: TenantRelayInfoConfigDocument,
    734     pocket: TenantPocketConfigDocument,
    735     pocket_query: BaseRelayPocketQueryConfigDocument,
    736     groups: serde_json::Value,
    737     auth: BaseRelayAuthConfigDocument,
    738     limits: BaseRelayRuntimeLimitsDocument,
    739     rate_limits: BaseRelayRateLimitsDocument,
    740     #[serde(default)]
    741     backup_export: TenantBackupExportConfigDocument,
    742 }
    743 
    744 #[derive(Debug, Deserialize)]
    745 #[serde(deny_unknown_fields)]
    746 struct TenantRelayInfoConfigDocument {
    747     name: String,
    748     description: Option<String>,
    749     contact: Option<String>,
    750     icon: Option<String>,
    751 }
    752 
    753 #[derive(Debug, Deserialize)]
    754 #[serde(deny_unknown_fields)]
    755 struct TenantPocketConfigDocument {
    756     data_directory: String,
    757     sync_policy: BaseRelayPocketSyncPolicyDocument,
    758 }
    759 
    760 #[derive(Debug, Deserialize)]
    761 #[serde(deny_unknown_fields)]
    762 struct TenantBackupExportConfigDocument {
    763     backup_enabled: bool,
    764     export_enabled: bool,
    765 }
    766 
    767 impl Default for TenantBackupExportConfigDocument {
    768     fn default() -> Self {
    769         let defaults = TenantBackupExportConfig::default();
    770         Self {
    771             backup_enabled: defaults.backup_enabled(),
    772             export_enabled: defaults.export_enabled(),
    773         }
    774     }
    775 }
    776 
    777 #[derive(Debug, Deserialize)]
    778 #[serde(deny_unknown_fields)]
    779 struct BaseRelayRuntimeConfigDocument {
    780     server: BaseRelayServerConfigDocument,
    781     pocket: BaseRelayPocketConfigDocument,
    782     groups: serde_json::Value,
    783     auth: BaseRelayAuthConfigDocument,
    784     limits: BaseRelayRuntimeLimitsDocument,
    785     rate_limits: BaseRelayRateLimitsDocument,
    786     #[serde(default)]
    787     observability: BaseRelayObservabilityConfigDocument,
    788 }
    789 
    790 #[derive(Debug, Deserialize)]
    791 #[serde(deny_unknown_fields)]
    792 struct BaseRelayServerConfigDocument {
    793     listen_addr: String,
    794     relay_url: String,
    795 }
    796 
    797 #[derive(Debug, Deserialize)]
    798 #[serde(deny_unknown_fields)]
    799 struct BaseRelayPocketConfigDocument {
    800     data_directory: String,
    801     sync_policy: BaseRelayPocketSyncPolicyDocument,
    802     query: BaseRelayPocketQueryConfigDocument,
    803 }
    804 
    805 #[derive(Debug, Deserialize)]
    806 #[serde(deny_unknown_fields)]
    807 struct BaseRelayPocketQueryConfigDocument {
    808     allow_scraping: bool,
    809     allow_scrape_if_limited_to: u32,
    810     allow_scrape_if_max_seconds: u64,
    811 }
    812 
    813 #[derive(Debug, Clone, Copy, Deserialize)]
    814 #[serde(rename_all = "snake_case")]
    815 enum BaseRelayPocketSyncPolicyDocument {
    816     FlushOnWrite,
    817     FlushOnShutdown,
    818 }
    819 
    820 #[derive(Debug, Deserialize)]
    821 #[serde(deny_unknown_fields)]
    822 struct BaseRelayAuthConfigDocument {
    823     challenge_ttl_seconds: u64,
    824     created_at_skew_seconds: u64,
    825 }
    826 
    827 #[derive(Debug, Deserialize)]
    828 #[serde(deny_unknown_fields)]
    829 struct BaseRelayRuntimeLimitsDocument {
    830     max_message_length: usize,
    831     max_subid_length: usize,
    832     max_subscriptions_per_connection: usize,
    833     max_filters_per_request: usize,
    834     max_tag_values_per_filter: usize,
    835     max_query_complexity: usize,
    836     max_limit: u64,
    837     default_limit: u64,
    838     max_event_tags: usize,
    839     max_content_length: usize,
    840     broadcast_channel_capacity: usize,
    841     per_connection_outbound_queue: usize,
    842 }
    843 
    844 #[derive(Debug, Deserialize)]
    845 #[serde(deny_unknown_fields)]
    846 struct BaseRelayRateLimitsDocument {
    847     auth: BaseRelayAuthRateLimitsDocument,
    848     event: BaseRelayEventRateLimitsDocument,
    849     group: BaseRelayGroupRateLimitsDocument,
    850     req: BaseRelayQueryRateLimitsDocument,
    851     count: BaseRelayQueryRateLimitsDocument,
    852 }
    853 
    854 #[derive(Debug, Deserialize)]
    855 #[serde(deny_unknown_fields)]
    856 struct BaseRelayAuthRateLimitsDocument {
    857     per_ip: BaseRelayRateLimitRuleDocument,
    858     per_pubkey: BaseRelayRateLimitRuleDocument,
    859     failures: BaseRelayRateLimitRuleDocument,
    860     failures_per_ip: BaseRelayRateLimitRuleDocument,
    861 }
    862 
    863 #[derive(Debug, Deserialize)]
    864 #[serde(deny_unknown_fields)]
    865 struct BaseRelayEventRateLimitsDocument {
    866     per_ip: BaseRelayRateLimitRuleDocument,
    867     per_pubkey: BaseRelayRateLimitRuleDocument,
    868     per_kind: BaseRelayRateLimitRuleDocument,
    869 }
    870 
    871 #[derive(Debug, Deserialize)]
    872 #[serde(deny_unknown_fields)]
    873 struct BaseRelayGroupRateLimitsDocument {
    874     write_per_ip: BaseRelayRateLimitRuleDocument,
    875     write_per_pubkey: BaseRelayRateLimitRuleDocument,
    876     write_per_group: BaseRelayRateLimitRuleDocument,
    877     write_per_kind: BaseRelayRateLimitRuleDocument,
    878     join_flow: BaseRelayRateLimitRuleDocument,
    879     join_flow_per_ip: BaseRelayRateLimitRuleDocument,
    880 }
    881 
    882 #[derive(Debug, Deserialize)]
    883 #[serde(deny_unknown_fields)]
    884 struct BaseRelayQueryRateLimitsDocument {
    885     per_ip: BaseRelayRateLimitRuleDocument,
    886     per_connection: BaseRelayRateLimitRuleDocument,
    887     per_pubkey: BaseRelayRateLimitRuleDocument,
    888     per_group: BaseRelayRateLimitRuleDocument,
    889     per_kind: BaseRelayRateLimitRuleDocument,
    890     broad: BaseRelayRateLimitRuleDocument,
    891 }
    892 
    893 #[derive(Debug, Clone, Copy, Deserialize)]
    894 #[serde(deny_unknown_fields)]
    895 struct BaseRelayRateLimitRuleDocument {
    896     window_seconds: u64,
    897     max_hits: u64,
    898 }
    899 
    900 #[derive(Debug, Default, Deserialize)]
    901 #[serde(deny_unknown_fields)]
    902 struct BaseRelayObservabilityConfigDocument {
    903     #[serde(default)]
    904     tracing: BaseRelayTracingConfigDocument,
    905 }
    906 
    907 #[derive(Debug, Default, Deserialize)]
    908 #[serde(deny_unknown_fields)]
    909 struct BaseRelayTracingConfigDocument {
    910     enabled: Option<bool>,
    911     filter: Option<String>,
    912     format: Option<BaseRelayTracingFormatDocument>,
    913 }
    914 
    915 #[derive(Debug, Clone, Copy, Deserialize)]
    916 #[serde(rename_all = "snake_case")]
    917 enum BaseRelayTracingFormatDocument {
    918     Compact,
    919     Json,
    920 }
    921 
    922 pub fn parse_tangle_host_runtime_config_json(
    923     raw: &str,
    924 ) -> Result<TangleHostRuntimeConfig, BaseRelayError> {
    925     reject_legacy_single_relay_config(raw)?;
    926     let document =
    927         serde_json::from_str::<TangleHostRuntimeConfigDocument>(raw).map_err(|error| {
    928             BaseRelayError::invalid(format!(
    929                 "tangle host runtime config JSON is invalid: {error}"
    930             ))
    931         })?;
    932     let listen_addr = document
    933         .listen_addr
    934         .parse::<SocketAddr>()
    935         .map_err(|error| BaseRelayError::invalid(format!("listen_addr is invalid: {error}")))?;
    936     if document.tenant_config_dir.trim().is_empty()
    937         || document.tenant_config_dir.trim() != document.tenant_config_dir
    938     {
    939         return Err(BaseRelayError::invalid(
    940             "tenant_config_dir must not be empty or padded",
    941         ));
    942     }
    943     Ok(TangleHostRuntimeConfig {
    944         listen_addr,
    945         tenant_config_dir: PathBuf::from(document.tenant_config_dir),
    946         limits: TangleHostLimitsConfig::new(
    947             document.limits.max_total_connections,
    948             document.limits.max_total_subscriptions,
    949             document.limits.tenant_startup_concurrency,
    950         )?,
    951         ops: TangleHostOpsConfig::new(document.ops.enabled, document.ops.expose_tenant_inventory),
    952         trusted_proxy: TangleTrustedProxyConfig::new(
    953             document.trusted_proxy.enabled,
    954             document.trusted_proxy.trusted_peers,
    955         )?,
    956         tracing: base_relay_tracing_config_from_document(document.observability.tracing)?,
    957     })
    958 }
    959 
    960 pub fn parse_tenant_runtime_config_json(raw: &str) -> Result<TenantRuntimeConfig, BaseRelayError> {
    961     reject_legacy_single_relay_config(raw)?;
    962     let document = serde_json::from_str::<TenantRuntimeConfigDocument>(raw).map_err(|error| {
    963         BaseRelayError::invalid(format!("tenant runtime config JSON is invalid: {error}"))
    964     })?;
    965     let tenant_id = TenantId::new(document.tenant_id)?;
    966     let tenant_schema = TenantSchema::new(document.tenant_schema)?;
    967     let host = CanonicalHost::new(document.host)?;
    968     let relay_url = TenantRelayUrl::new(document.relay_url)?;
    969     let pocket = PocketStoreConfig::new(
    970         PathBuf::from(document.pocket.data_directory),
    971         match document.pocket.sync_policy {
    972             BaseRelayPocketSyncPolicyDocument::FlushOnWrite => PocketSyncPolicy::FlushOnWrite,
    973             BaseRelayPocketSyncPolicyDocument::FlushOnShutdown => PocketSyncPolicy::FlushOnShutdown,
    974         },
    975     )
    976     .map_err(|error| BaseRelayError::invalid(error.to_string()))?;
    977     let groups_raw = serde_json::to_string(&document.groups).map_err(|error| {
    978         BaseRelayError::invalid(format!("groups config JSON is invalid: {error}"))
    979     })?;
    980     let groups = tangle_groups::parse_group_runtime_config_json(&groups_raw)
    981         .map_err(|error| BaseRelayError::invalid(error.to_string()))?;
    982     if let Some(group_relay_url) = groups.canonical_relay_url()
    983         && group_relay_url.as_str() != relay_url.as_str()
    984     {
    985         return Err(BaseRelayError::invalid(
    986             "groups.canonical_relay_url must match relay_url",
    987         ));
    988     }
    989     let limits = BaseRelayRuntimeLimitsConfig::from_document(document.limits)?;
    990     let pocket_query = pocket_query_config_from_document(document.pocket_query, limits)?;
    991     if document.auth.created_at_skew_seconds == 0 {
    992         return Err(BaseRelayError::invalid(
    993             "auth.created_at_skew_seconds must be greater than zero",
    994         ));
    995     }
    996     Ok(TenantRuntimeConfig {
    997         tenant_id,
    998         tenant_schema,
    999         host,
   1000         relay_url,
   1001         inactive: document.inactive,
   1002         info: TenantRelayInfoConfig::new(
   1003             document.info.name,
   1004             document.info.description,
   1005             document.info.contact,
   1006             document.info.icon,
   1007         )?,
   1008         pocket,
   1009         pocket_query,
   1010         groups,
   1011         auth_ttl_seconds: document.auth.challenge_ttl_seconds,
   1012         auth_created_at_skew_seconds: document.auth.created_at_skew_seconds,
   1013         limits,
   1014         rate_limits: base_relay_rate_limits_from_document(document.rate_limits)?,
   1015         backup_export: TenantBackupExportConfig::new(
   1016             document.backup_export.backup_enabled,
   1017             document.backup_export.export_enabled,
   1018         ),
   1019     })
   1020 }
   1021 
   1022 fn reject_legacy_single_relay_config(raw: &str) -> Result<(), BaseRelayError> {
   1023     let value = serde_json::from_str::<serde_json::Value>(raw)
   1024         .map_err(|error| BaseRelayError::invalid(format!("config JSON is invalid: {error}")))?;
   1025     if value
   1026         .as_object()
   1027         .is_some_and(|object| object.contains_key("server"))
   1028     {
   1029         return Err(BaseRelayError::invalid(
   1030             "legacy single-relay config is not supported",
   1031         ));
   1032     }
   1033     Ok(())
   1034 }
   1035 
   1036 pub fn parse_base_relay_runtime_config_json(
   1037     raw: &str,
   1038 ) -> Result<BaseRelayRuntimeConfig, BaseRelayError> {
   1039     let document =
   1040         serde_json::from_str::<BaseRelayRuntimeConfigDocument>(raw).map_err(|error| {
   1041             BaseRelayError::invalid(format!(
   1042                 "base relay runtime config JSON is invalid: {error}"
   1043             ))
   1044         })?;
   1045     let listen_addr = document
   1046         .server
   1047         .listen_addr
   1048         .parse::<SocketAddr>()
   1049         .map_err(|error| {
   1050             BaseRelayError::invalid(format!("server.listen_addr is invalid: {error}"))
   1051         })?;
   1052     let pocket_document = document.pocket;
   1053     let pocket = PocketStoreConfig::new(
   1054         PathBuf::from(pocket_document.data_directory),
   1055         match pocket_document.sync_policy {
   1056             BaseRelayPocketSyncPolicyDocument::FlushOnWrite => PocketSyncPolicy::FlushOnWrite,
   1057             BaseRelayPocketSyncPolicyDocument::FlushOnShutdown => PocketSyncPolicy::FlushOnShutdown,
   1058         },
   1059     )
   1060     .map_err(|error| BaseRelayError::invalid(error.to_string()))?;
   1061     let groups_raw = serde_json::to_string(&document.groups).map_err(|error| {
   1062         BaseRelayError::invalid(format!("groups config JSON is invalid: {error}"))
   1063     })?;
   1064     let groups = tangle_groups::parse_group_runtime_config_json(&groups_raw)
   1065         .map_err(|error| BaseRelayError::invalid(error.to_string()))?;
   1066     let limits = BaseRelayRuntimeLimitsConfig::from_document(document.limits)?;
   1067     let pocket_query = pocket_query_config_from_document(pocket_document.query, limits)?;
   1068     let rate_limits = base_relay_rate_limits_from_document(document.rate_limits)?;
   1069     if document.auth.created_at_skew_seconds == 0 {
   1070         return Err(BaseRelayError::invalid(
   1071             "auth.created_at_skew_seconds must be greater than zero",
   1072         ));
   1073     }
   1074     let tracing = base_relay_tracing_config_from_document(document.observability.tracing)?;
   1075     Ok(BaseRelayRuntimeConfig {
   1076         listen_addr,
   1077         relay_url: document.server.relay_url,
   1078         pocket,
   1079         pocket_query,
   1080         groups,
   1081         auth_ttl_seconds: document.auth.challenge_ttl_seconds,
   1082         auth_created_at_skew_seconds: document.auth.created_at_skew_seconds,
   1083         limits,
   1084         rate_limits,
   1085         tracing,
   1086     })
   1087 }
   1088 
   1089 fn pocket_query_config_from_document(
   1090     document: BaseRelayPocketQueryConfigDocument,
   1091     limits: BaseRelayRuntimeLimitsConfig,
   1092 ) -> Result<PocketQueryConfig, BaseRelayError> {
   1093     if u64::from(document.allow_scrape_if_limited_to) > limits.max_limit() {
   1094         return Err(BaseRelayError::invalid(
   1095             "pocket.query.allow_scrape_if_limited_to must be less than or equal to limits.max_limit",
   1096         ));
   1097     }
   1098     if document.allow_scrape_if_max_seconds > MAX_POCKET_QUERY_SCRAPE_WINDOW_SECONDS {
   1099         return Err(BaseRelayError::invalid(format!(
   1100             "pocket.query.allow_scrape_if_max_seconds must be less than or equal to {MAX_POCKET_QUERY_SCRAPE_WINDOW_SECONDS}"
   1101         )));
   1102     }
   1103     Ok(PocketQueryConfig::new(
   1104         document.allow_scraping,
   1105         document.allow_scrape_if_limited_to,
   1106         document.allow_scrape_if_max_seconds,
   1107     ))
   1108 }
   1109 
   1110 fn require_positive(field: &str, value: usize) -> Result<(), BaseRelayError> {
   1111     if value == 0 {
   1112         return Err(BaseRelayError::invalid(format!(
   1113             "{field} must be greater than zero"
   1114         )));
   1115     }
   1116     Ok(())
   1117 }
   1118 
   1119 fn require_positive_u64(field: &str, value: u64) -> Result<(), BaseRelayError> {
   1120     if value == 0 {
   1121         return Err(BaseRelayError::invalid(format!(
   1122             "{field} must be greater than zero"
   1123         )));
   1124     }
   1125     Ok(())
   1126 }
   1127 
   1128 fn base_relay_rate_limits_from_document(
   1129     document: BaseRelayRateLimitsDocument,
   1130 ) -> Result<TangleRateLimitConfig, BaseRelayError> {
   1131     Ok(TangleRateLimitConfig::new(
   1132         TangleAuthRateLimitConfig::new(
   1133             base_relay_rate_limit_rule_from_document(
   1134                 "rate_limits.auth.per_ip",
   1135                 document.auth.per_ip,
   1136             )?,
   1137             base_relay_rate_limit_rule_from_document(
   1138                 "rate_limits.auth.per_pubkey",
   1139                 document.auth.per_pubkey,
   1140             )?,
   1141             base_relay_rate_limit_rule_from_document(
   1142                 "rate_limits.auth.failures",
   1143                 document.auth.failures,
   1144             )?,
   1145             base_relay_rate_limit_rule_from_document(
   1146                 "rate_limits.auth.failures_per_ip",
   1147                 document.auth.failures_per_ip,
   1148             )?,
   1149         ),
   1150         TangleEventRateLimitConfig::new(
   1151             base_relay_rate_limit_rule_from_document(
   1152                 "rate_limits.event.per_ip",
   1153                 document.event.per_ip,
   1154             )?,
   1155             base_relay_rate_limit_rule_from_document(
   1156                 "rate_limits.event.per_pubkey",
   1157                 document.event.per_pubkey,
   1158             )?,
   1159             base_relay_rate_limit_rule_from_document(
   1160                 "rate_limits.event.per_kind",
   1161                 document.event.per_kind,
   1162             )?,
   1163         ),
   1164         TangleGroupRateLimitConfig::new(
   1165             base_relay_rate_limit_rule_from_document(
   1166                 "rate_limits.group.write_per_ip",
   1167                 document.group.write_per_ip,
   1168             )?,
   1169             base_relay_rate_limit_rule_from_document(
   1170                 "rate_limits.group.write_per_pubkey",
   1171                 document.group.write_per_pubkey,
   1172             )?,
   1173             base_relay_rate_limit_rule_from_document(
   1174                 "rate_limits.group.write_per_group",
   1175                 document.group.write_per_group,
   1176             )?,
   1177             base_relay_rate_limit_rule_from_document(
   1178                 "rate_limits.group.write_per_kind",
   1179                 document.group.write_per_kind,
   1180             )?,
   1181             base_relay_rate_limit_rule_from_document(
   1182                 "rate_limits.group.join_flow",
   1183                 document.group.join_flow,
   1184             )?,
   1185             base_relay_rate_limit_rule_from_document(
   1186                 "rate_limits.group.join_flow_per_ip",
   1187                 document.group.join_flow_per_ip,
   1188             )?,
   1189         ),
   1190         base_relay_query_rate_limits_from_document("rate_limits.req", document.req)?,
   1191         base_relay_query_rate_limits_from_document("rate_limits.count", document.count)?,
   1192     ))
   1193 }
   1194 
   1195 fn base_relay_query_rate_limits_from_document(
   1196     field: &str,
   1197     document: BaseRelayQueryRateLimitsDocument,
   1198 ) -> Result<TangleQueryRateLimitConfig, BaseRelayError> {
   1199     Ok(TangleQueryRateLimitConfig::new(
   1200         base_relay_rate_limit_rule_from_document(&format!("{field}.per_ip"), document.per_ip)?,
   1201         base_relay_rate_limit_rule_from_document(
   1202             &format!("{field}.per_connection"),
   1203             document.per_connection,
   1204         )?,
   1205         base_relay_rate_limit_rule_from_document(
   1206             &format!("{field}.per_pubkey"),
   1207             document.per_pubkey,
   1208         )?,
   1209         base_relay_rate_limit_rule_from_document(
   1210             &format!("{field}.per_group"),
   1211             document.per_group,
   1212         )?,
   1213         base_relay_rate_limit_rule_from_document(&format!("{field}.per_kind"), document.per_kind)?,
   1214         base_relay_rate_limit_rule_from_document(&format!("{field}.broad"), document.broad)?,
   1215     ))
   1216 }
   1217 
   1218 fn base_relay_rate_limit_rule_from_document(
   1219     field: &str,
   1220     document: BaseRelayRateLimitRuleDocument,
   1221 ) -> Result<TangleRateLimitRule, BaseRelayError> {
   1222     require_positive_u64(&format!("{field}.window_seconds"), document.window_seconds)?;
   1223     require_positive_u64(&format!("{field}.max_hits"), document.max_hits)?;
   1224     TangleRateLimitRule::new(document.window_seconds, document.max_hits)
   1225 }
   1226 
   1227 fn base_relay_tracing_config_from_document(
   1228     document: BaseRelayTracingConfigDocument,
   1229 ) -> Result<BaseRelayTracingConfig, BaseRelayError> {
   1230     BaseRelayTracingConfig::new(
   1231         document.enabled.unwrap_or(true),
   1232         document.filter.unwrap_or_else(|| {
   1233             "info,tangle=info,tangle_runtime=info,tangle_groups=info,tangle_store_pocket=info"
   1234                 .to_owned()
   1235         }),
   1236         match document
   1237             .format
   1238             .unwrap_or(BaseRelayTracingFormatDocument::Json)
   1239         {
   1240             BaseRelayTracingFormatDocument::Compact => BaseRelayTracingFormat::Compact,
   1241             BaseRelayTracingFormatDocument::Json => BaseRelayTracingFormat::Json,
   1242         },
   1243     )
   1244 }
   1245 
   1246 fn validate_optional_text(
   1247     field: &str,
   1248     value: Option<String>,
   1249 ) -> Result<Option<String>, BaseRelayError> {
   1250     if let Some(value) = value {
   1251         if value.trim().is_empty() || value.trim() != value {
   1252             return Err(BaseRelayError::invalid(format!(
   1253                 "{field} must not be empty or padded"
   1254             )));
   1255         }
   1256         Ok(Some(value))
   1257     } else {
   1258         Ok(None)
   1259     }
   1260 }
   1261 
   1262 fn validate_tenant_config_set(tenants: &[TenantRuntimeConfig]) -> Result<(), BaseRelayError> {
   1263     if tenants.iter().all(TenantRuntimeConfig::inactive) {
   1264         return Err(BaseRelayError::invalid(
   1265             "at least one active tenant is required",
   1266         ));
   1267     }
   1268     let mut tenant_ids = BTreeSet::new();
   1269     let mut tenant_schemas = BTreeSet::new();
   1270     let mut hosts = BTreeSet::new();
   1271     let mut relay_urls = BTreeSet::new();
   1272     let mut relay_self_pubkeys = BTreeSet::new();
   1273     let mut store_paths = BTreeSet::new();
   1274     for tenant in tenants {
   1275         insert_unique("tenant_id", tenant.tenant_id().as_str(), &mut tenant_ids)?;
   1276         insert_unique(
   1277             "tenant_schema",
   1278             tenant.tenant_schema().as_str(),
   1279             &mut tenant_schemas,
   1280         )?;
   1281         insert_unique("host", tenant.host().as_str(), &mut hosts)?;
   1282         insert_unique("relay_url", tenant.relay_url().as_str(), &mut relay_urls)?;
   1283         if let Some(pubkey) = tenant.relay_self_pubkey()? {
   1284             insert_unique(
   1285                 "relay self pubkey",
   1286                 pubkey.as_str(),
   1287                 &mut relay_self_pubkeys,
   1288             )?;
   1289         }
   1290         let store_path = canonical_path_key(tenant.pocket_config().data_directory());
   1291         insert_unique("pocket data directory", &store_path, &mut store_paths)?;
   1292     }
   1293     Ok(())
   1294 }
   1295 
   1296 fn insert_unique(
   1297     field: &str,
   1298     value: impl Into<String>,
   1299     values: &mut BTreeSet<String>,
   1300 ) -> Result<(), BaseRelayError> {
   1301     let value = value.into();
   1302     if values.insert(value.clone()) {
   1303         Ok(())
   1304     } else {
   1305         Err(BaseRelayError::invalid(format!(
   1306             "duplicate tenant {field}: {value}"
   1307         )))
   1308     }
   1309 }
   1310 
   1311 fn canonical_path_key(path: &Path) -> String {
   1312     let mut normalized = PathBuf::new();
   1313     for component in path.components() {
   1314         match component {
   1315             Component::CurDir => {}
   1316             Component::ParentDir => {
   1317                 normalized.pop();
   1318             }
   1319             Component::Normal(part) => normalized.push(part),
   1320             Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
   1321         }
   1322     }
   1323     normalized.to_string_lossy().into_owned()
   1324 }
   1325 
   1326 #[cfg(test)]
   1327 mod tests {
   1328     use super::{
   1329         BaseRelayTracingFormat, TangleHostRuntimeConfigSet, TenantRuntimeConfig,
   1330         parse_base_relay_runtime_config_json, parse_tangle_host_runtime_config_json,
   1331         parse_tenant_runtime_config_json,
   1332     };
   1333     use serde_json::{Value, json};
   1334     use std::path::Path;
   1335     use tangle_store_pocket::PocketSyncPolicy;
   1336 
   1337     #[test]
   1338     fn tangle_host_runtime_config_parses_v1_mvp_example() {
   1339         let config = parse_tangle_host_runtime_config_json(include_str!(
   1340             "../../../config/tangle.host.example.json"
   1341         ))
   1342         .expect("host config");
   1343 
   1344         assert_eq!(config.listen_addr().to_string(), "0.0.0.0:7000");
   1345         assert_eq!(config.tenant_config_dir(), Path::new("tenants"));
   1346         assert_eq!(config.limits().max_total_connections(), 10_000);
   1347         assert_eq!(config.limits().max_total_subscriptions(), 25_000);
   1348         assert_eq!(config.limits().tenant_startup_concurrency(), 4);
   1349         assert!(config.ops().enabled());
   1350         assert!(config.ops().expose_tenant_inventory());
   1351         assert!(!config.trusted_proxy().enabled());
   1352         assert!(config.trusted_proxy().trusted_peers().is_empty());
   1353         assert!(config.tracing().enabled());
   1354         assert_eq!(config.tracing().format(), BaseRelayTracingFormat::Json);
   1355     }
   1356 
   1357     #[test]
   1358     fn tenant_runtime_config_parses_v1_mvp_example() {
   1359         let config = parse_tenant_runtime_config_json(include_str!(
   1360             "../../../config/tenants/farmers_market.example.json"
   1361         ))
   1362         .expect("tenant config");
   1363 
   1364         assert_eq!(config.tenant_id().as_str(), "farmers-market");
   1365         assert_eq!(config.tenant_schema().as_str(), "farmers_market");
   1366         assert_eq!(config.host().as_str(), "relay.radroots.test");
   1367         assert_eq!(config.relay_url().as_str(), "wss://relay.radroots.test");
   1368         assert!(!config.inactive());
   1369         assert_eq!(config.info().name(), "Radroots Farmers Market");
   1370         assert_eq!(
   1371             config.info().description(),
   1372             Some("Tangle virtual relay tenant for the Radroots farmers market")
   1373         );
   1374         assert_eq!(
   1375             config.pocket_config().data_directory(),
   1376             Path::new("runtime/tenants/farmers_market/pocket")
   1377         );
   1378         assert_eq!(
   1379             config.pocket_config().sync_policy(),
   1380             PocketSyncPolicy::FlushOnShutdown
   1381         );
   1382         assert!(config.groups().enabled());
   1383         assert_eq!(config.auth_ttl_seconds(), 300);
   1384         assert_eq!(config.auth_created_at_skew_seconds(), 600);
   1385         assert_eq!(config.limits().max_subscriptions_per_connection(), 64);
   1386         assert_eq!(config.rate_limits().auth().per_ip().max_hits(), 120);
   1387         assert!(config.backup_export().backup_enabled());
   1388         assert!(config.backup_export().export_enabled());
   1389         assert!(config.relay_self_pubkey().expect("relay self").is_some());
   1390     }
   1391 
   1392     #[test]
   1393     fn tangle_v1_mvp_config_rejects_legacy_single_relay_shape() {
   1394         let raw = include_str!("../../../config/tangle.example.json");
   1395 
   1396         assert_eq!(
   1397             parse_tangle_host_runtime_config_json(raw)
   1398                 .expect_err("legacy host config")
   1399                 .prefixed_message(),
   1400             "invalid: legacy single-relay config is not supported"
   1401         );
   1402         assert_eq!(
   1403             parse_tenant_runtime_config_json(raw)
   1404                 .expect_err("legacy tenant config")
   1405                 .prefixed_message(),
   1406             "invalid: legacy single-relay config is not supported"
   1407         );
   1408     }
   1409 
   1410     #[test]
   1411     fn tangle_host_runtime_config_set_rejects_invalid_tenant_sets() {
   1412         let host = parse_tangle_host_runtime_config_json(include_str!(
   1413             "../../../config/tangle.host.example.json"
   1414         ))
   1415         .expect("host config");
   1416         let first = tenant_from_value(first_tenant_value());
   1417         let second = tenant_from_value(second_tenant_value());
   1418         TangleHostRuntimeConfigSet::new(host.clone(), vec![first.clone(), second.clone()])
   1419             .expect("unique tenants");
   1420 
   1421         assert!(
   1422             TangleHostRuntimeConfigSet::new(
   1423                 host.clone(),
   1424                 vec![first.clone(), mutate_second("tenant_id", "farmers-market")]
   1425             )
   1426             .expect_err("tenant id")
   1427             .prefixed_message()
   1428             .contains("duplicate tenant tenant_id")
   1429         );
   1430         assert!(
   1431             TangleHostRuntimeConfigSet::new(
   1432                 host.clone(),
   1433                 vec![
   1434                     first.clone(),
   1435                     mutate_second("tenant_schema", "farmers_market")
   1436                 ]
   1437             )
   1438             .expect_err("schema")
   1439             .prefixed_message()
   1440             .contains("duplicate tenant tenant_schema")
   1441         );
   1442         assert!(
   1443             TangleHostRuntimeConfigSet::new(
   1444                 host.clone(),
   1445                 vec![first.clone(), mutate_second("host", "relay.radroots.test")]
   1446             )
   1447             .expect_err("host")
   1448             .prefixed_message()
   1449             .contains("duplicate tenant host")
   1450         );
   1451         assert!(
   1452             TangleHostRuntimeConfigSet::new(
   1453                 host.clone(),
   1454                 vec![
   1455                     first.clone(),
   1456                     mutate_second("relay_url", "wss://relay.radroots.test")
   1457                 ]
   1458             )
   1459             .expect_err("relay url")
   1460             .prefixed_message()
   1461             .contains("duplicate tenant relay_url")
   1462         );
   1463         assert!(
   1464             TangleHostRuntimeConfigSet::new(
   1465                 host.clone(),
   1466                 vec![first.clone(), second_with_group_secret("7")]
   1467             )
   1468             .expect_err("relay self")
   1469             .prefixed_message()
   1470             .contains("duplicate tenant relay self pubkey")
   1471         );
   1472         assert!(
   1473             TangleHostRuntimeConfigSet::new(
   1474                 host.clone(),
   1475                 vec![
   1476                     first.clone(),
   1477                     second_with_store_path("./runtime/tenants/farmers_market/pocket")
   1478                 ]
   1479             )
   1480             .expect_err("store")
   1481             .prefixed_message()
   1482             .contains("duplicate tenant pocket data directory")
   1483         );
   1484         assert_eq!(
   1485             TangleHostRuntimeConfigSet::new(
   1486                 host,
   1487                 vec![inactive_first_tenant(), inactive_second_tenant()]
   1488             )
   1489             .expect_err("active tenants")
   1490             .prefixed_message(),
   1491             "invalid: at least one active tenant is required"
   1492         );
   1493     }
   1494 
   1495     fn first_tenant_value() -> Value {
   1496         serde_json::from_str(include_str!(
   1497             "../../../config/tenants/farmers_market.example.json"
   1498         ))
   1499         .expect("tenant json")
   1500     }
   1501 
   1502     fn second_tenant_value() -> Value {
   1503         let mut value = first_tenant_value();
   1504         value["tenant_id"] = json!("seed-coop");
   1505         value["tenant_schema"] = json!("seed_coop");
   1506         value["host"] = json!("seed.relay.radroots.test");
   1507         value["relay_url"] = json!("wss://seed.relay.radroots.test");
   1508         value["pocket"]["data_directory"] = json!("runtime/tenants/seed_coop/pocket");
   1509         value["groups"]["canonical_relay_url"] = json!("wss://seed.relay.radroots.test");
   1510         value["groups"]["relay_secret"] =
   1511             json!("8888888888888888888888888888888888888888888888888888888888888888");
   1512         value
   1513     }
   1514 
   1515     fn tenant_from_value(value: Value) -> TenantRuntimeConfig {
   1516         parse_tenant_runtime_config_json(&value.to_string()).expect("tenant")
   1517     }
   1518 
   1519     fn mutate_second(field: &str, field_value: &str) -> TenantRuntimeConfig {
   1520         let mut value = second_tenant_value();
   1521         value[field] = json!(field_value);
   1522         if field == "relay_url" {
   1523             value["groups"]["canonical_relay_url"] = json!(field_value);
   1524         }
   1525         tenant_from_value(value)
   1526     }
   1527 
   1528     fn second_with_group_secret(nibble: &str) -> TenantRuntimeConfig {
   1529         let mut value = second_tenant_value();
   1530         value["groups"]["relay_secret"] = json!(nibble.repeat(64));
   1531         tenant_from_value(value)
   1532     }
   1533 
   1534     fn second_with_store_path(path: &str) -> TenantRuntimeConfig {
   1535         let mut value = second_tenant_value();
   1536         value["pocket"]["data_directory"] = json!(path);
   1537         tenant_from_value(value)
   1538     }
   1539 
   1540     fn inactive_first_tenant() -> TenantRuntimeConfig {
   1541         let mut value = first_tenant_value();
   1542         value["inactive"] = json!(true);
   1543         tenant_from_value(value)
   1544     }
   1545 
   1546     fn inactive_second_tenant() -> TenantRuntimeConfig {
   1547         let mut value = second_tenant_value();
   1548         value["inactive"] = json!(true);
   1549         tenant_from_value(value)
   1550     }
   1551 
   1552     #[test]
   1553     fn base_relay_runtime_config_parses_v2_production_example() {
   1554         let config = parse_base_relay_runtime_config_json(include_str!(
   1555             "../../../config/tangle.example.json"
   1556         ))
   1557         .expect("config");
   1558 
   1559         assert_eq!(config.listen_addr().to_string(), "0.0.0.0:7000");
   1560         assert_eq!(config.relay_url(), "wss://relay.radroots.test");
   1561         assert_eq!(
   1562             config.pocket_config().data_directory(),
   1563             Path::new("runtime/pocket")
   1564         );
   1565         assert_eq!(
   1566             config.pocket_config().sync_policy(),
   1567             PocketSyncPolicy::FlushOnShutdown
   1568         );
   1569         assert!(!config.pocket_query_config().allow_scraping());
   1570         assert_eq!(
   1571             config.pocket_query_config().allow_scrape_if_limited_to(),
   1572             100
   1573         );
   1574         assert_eq!(
   1575             config.pocket_query_config().allow_scrape_if_max_seconds(),
   1576             3_600
   1577         );
   1578         assert!(config.groups().enabled());
   1579         assert!(!config.groups().policy().public_join());
   1580         assert!(!config.groups().policy().invites_enabled());
   1581         assert_eq!(config.auth_ttl_seconds(), 300);
   1582         assert_eq!(config.auth_created_at_skew_seconds(), 600);
   1583         assert_eq!(config.limits().max_message_length(), 1_048_576);
   1584         assert_eq!(config.limits().max_subid_length(), 64);
   1585         assert_eq!(config.limits().max_subscriptions_per_connection(), 64);
   1586         assert_eq!(config.limits().max_filters_per_request(), 10);
   1587         assert_eq!(config.limits().max_tag_values_per_filter(), 100);
   1588         assert_eq!(config.limits().max_query_complexity(), 2_048);
   1589         assert_eq!(config.limits().max_limit(), 500);
   1590         assert_eq!(config.limits().default_limit(), 100);
   1591         assert_eq!(config.limits().max_event_tags(), 200);
   1592         assert_eq!(config.limits().max_content_length(), 65_536);
   1593         assert_eq!(config.limits().broadcast_channel_capacity(), 4_096);
   1594         assert_eq!(config.limits().per_connection_outbound_queue(), 256);
   1595         assert_eq!(config.rate_limits().auth().per_ip().max_hits(), 120);
   1596         assert_eq!(config.rate_limits().auth().per_pubkey().max_hits(), 30);
   1597         assert_eq!(config.rate_limits().auth().failures().max_hits(), 5);
   1598         assert_eq!(config.rate_limits().auth().failures_per_ip().max_hits(), 20);
   1599         assert_eq!(config.rate_limits().event().per_ip().max_hits(), 600);
   1600         assert_eq!(config.rate_limits().event().per_pubkey().max_hits(), 120);
   1601         assert_eq!(config.rate_limits().event().per_kind().max_hits(), 1_000);
   1602         assert_eq!(config.rate_limits().group().write_per_ip().max_hits(), 300);
   1603         assert_eq!(
   1604             config.rate_limits().group().write_per_pubkey().max_hits(),
   1605             60
   1606         );
   1607         assert_eq!(
   1608             config.rate_limits().group().write_per_group().max_hits(),
   1609             90
   1610         );
   1611         assert_eq!(
   1612             config.rate_limits().group().write_per_kind().max_hits(),
   1613             300
   1614         );
   1615         assert_eq!(config.rate_limits().group().join_flow().max_hits(), 10);
   1616         assert_eq!(
   1617             config.rate_limits().group().join_flow_per_ip().max_hits(),
   1618             30
   1619         );
   1620         assert_eq!(config.rate_limits().req().per_ip().max_hits(), 600);
   1621         assert_eq!(config.rate_limits().req().per_connection().max_hits(), 120);
   1622         assert_eq!(config.rate_limits().req().per_pubkey().max_hits(), 240);
   1623         assert_eq!(config.rate_limits().req().per_group().max_hits(), 240);
   1624         assert_eq!(config.rate_limits().req().per_kind().max_hits(), 500);
   1625         assert_eq!(config.rate_limits().req().broad().max_hits(), 30);
   1626         assert_eq!(config.rate_limits().count().per_ip().max_hits(), 300);
   1627         assert_eq!(config.rate_limits().count().per_connection().max_hits(), 60);
   1628         assert_eq!(config.rate_limits().count().per_pubkey().max_hits(), 120);
   1629         assert_eq!(config.rate_limits().count().per_group().max_hits(), 120);
   1630         assert_eq!(config.rate_limits().count().per_kind().max_hits(), 240);
   1631         assert_eq!(config.rate_limits().count().broad().max_hits(), 20);
   1632         assert!(config.tracing().enabled());
   1633         assert_eq!(config.tracing().format(), BaseRelayTracingFormat::Json);
   1634         config.auth_state().expect("auth");
   1635     }
   1636 
   1637     #[test]
   1638     fn base_relay_runtime_config_rejects_zero_auth_skew() {
   1639         let raw = r#"{
   1640             "server": {
   1641                 "listen_addr": "127.0.0.1:0",
   1642                 "relay_url": "wss://relay.radroots.test"
   1643             },
   1644             "pocket": {
   1645                 "data_directory": "runtime/pocket",
   1646                 "sync_policy": "flush_on_shutdown",
   1647                 "query": {
   1648                   "allow_scraping": false,
   1649                   "allow_scrape_if_limited_to": 100,
   1650                   "allow_scrape_if_max_seconds": 3600
   1651                 }
   1652             },
   1653             "groups": {
   1654                 "enabled": false
   1655             },
   1656             "auth": {
   1657                 "challenge_ttl_seconds": 300,
   1658                 "created_at_skew_seconds": 0
   1659             },
   1660             "limits": {
   1661                 "max_message_length": 1048576,
   1662                 "max_subid_length": 64,
   1663                 "max_subscriptions_per_connection": 64,
   1664                 "max_filters_per_request": 10,
   1665                 "max_tag_values_per_filter": 100,
   1666                 "max_query_complexity": 2048,
   1667                 "max_limit": 500,
   1668                 "default_limit": 100,
   1669                 "max_event_tags": 200,
   1670                 "max_content_length": 65536,
   1671                 "broadcast_channel_capacity": 4096,
   1672                 "per_connection_outbound_queue": 256
   1673             },
   1674             "rate_limits": {
   1675                 "auth": {
   1676                     "per_ip": {"window_seconds": 60, "max_hits": 120},
   1677                     "per_pubkey": {"window_seconds": 60, "max_hits": 30},
   1678                     "failures": {"window_seconds": 300, "max_hits": 5},
   1679                     "failures_per_ip": {"window_seconds": 300, "max_hits": 20}
   1680                 },
   1681                 "event": {
   1682                     "per_ip": {"window_seconds": 60, "max_hits": 600},
   1683                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
   1684                     "per_kind": {"window_seconds": 60, "max_hits": 1000}
   1685                 },
   1686                 "group": {
   1687                     "write_per_ip": {"window_seconds": 60, "max_hits": 300},
   1688                     "write_per_pubkey": {"window_seconds": 60, "max_hits": 60},
   1689                     "write_per_group": {"window_seconds": 60, "max_hits": 90},
   1690                     "write_per_kind": {"window_seconds": 60, "max_hits": 300},
   1691                     "join_flow": {"window_seconds": 300, "max_hits": 10},
   1692                     "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30}
   1693                 },
   1694                 "req": {
   1695                     "per_ip": {"window_seconds": 60, "max_hits": 600},
   1696                     "per_connection": {"window_seconds": 60, "max_hits": 120},
   1697                     "per_pubkey": {"window_seconds": 60, "max_hits": 240},
   1698                     "per_group": {"window_seconds": 60, "max_hits": 240},
   1699                     "per_kind": {"window_seconds": 60, "max_hits": 500},
   1700                     "broad": {"window_seconds": 60, "max_hits": 30}
   1701                 },
   1702                 "count": {
   1703                     "per_ip": {"window_seconds": 60, "max_hits": 300},
   1704                     "per_connection": {"window_seconds": 60, "max_hits": 60},
   1705                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
   1706                     "per_group": {"window_seconds": 60, "max_hits": 120},
   1707                     "per_kind": {"window_seconds": 60, "max_hits": 240},
   1708                     "broad": {"window_seconds": 60, "max_hits": 20}
   1709                 }
   1710             }
   1711         }"#;
   1712 
   1713         assert_eq!(
   1714             parse_base_relay_runtime_config_json(raw)
   1715                 .expect_err("zero skew")
   1716                 .prefixed_message(),
   1717             "invalid: auth.created_at_skew_seconds must be greater than zero"
   1718         );
   1719     }
   1720 
   1721     #[test]
   1722     fn base_relay_runtime_config_rejects_unknown_fields() {
   1723         let unknown_top_level = r#"{
   1724             "server": {
   1725                 "listen_addr": "127.0.0.1:0",
   1726                 "relay_url": "wss://relay.radroots.test"
   1727             },
   1728             "pocket": {
   1729                 "data_directory": "runtime/pocket",
   1730                 "sync_policy": "flush_on_shutdown",
   1731                 "query": {
   1732                   "allow_scraping": false,
   1733                   "allow_scrape_if_limited_to": 100,
   1734                   "allow_scrape_if_max_seconds": 3600
   1735                 }
   1736             },
   1737             "groups": {
   1738                 "enabled": false
   1739             },
   1740             "auth": {
   1741                 "challenge_ttl_seconds": 300,
   1742                 "created_at_skew_seconds": 600
   1743             },
   1744             "limits": {
   1745                 "max_message_length": 1048576,
   1746                 "max_subid_length": 64,
   1747                 "max_subscriptions_per_connection": 64,
   1748                 "max_filters_per_request": 10,
   1749                 "max_tag_values_per_filter": 100,
   1750                 "max_query_complexity": 2048,
   1751                 "max_limit": 500,
   1752                 "default_limit": 100,
   1753                 "max_event_tags": 200,
   1754                 "max_content_length": 65536,
   1755                 "broadcast_channel_capacity": 4096,
   1756                 "per_connection_outbound_queue": 256
   1757             },
   1758             "rate_limits": {
   1759                 "auth": {
   1760                     "per_ip": {"window_seconds": 60, "max_hits": 120},
   1761                     "per_pubkey": {"window_seconds": 60, "max_hits": 30},
   1762                     "failures": {"window_seconds": 300, "max_hits": 5},
   1763                     "failures_per_ip": {"window_seconds": 300, "max_hits": 20}
   1764                 },
   1765                 "event": {
   1766                     "per_ip": {"window_seconds": 60, "max_hits": 600},
   1767                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
   1768                     "per_kind": {"window_seconds": 60, "max_hits": 1000}
   1769                 },
   1770                 "group": {
   1771                     "write_per_ip": {"window_seconds": 60, "max_hits": 300},
   1772                     "write_per_pubkey": {"window_seconds": 60, "max_hits": 60},
   1773                     "write_per_group": {"window_seconds": 60, "max_hits": 90},
   1774                     "write_per_kind": {"window_seconds": 60, "max_hits": 300},
   1775                     "join_flow": {"window_seconds": 300, "max_hits": 10},
   1776                     "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30}
   1777                 },
   1778                 "req": {
   1779                     "per_ip": {"window_seconds": 60, "max_hits": 600},
   1780                     "per_connection": {"window_seconds": 60, "max_hits": 120},
   1781                     "per_pubkey": {"window_seconds": 60, "max_hits": 240},
   1782                     "per_group": {"window_seconds": 60, "max_hits": 240},
   1783                     "per_kind": {"window_seconds": 60, "max_hits": 500},
   1784                     "broad": {"window_seconds": 60, "max_hits": 30}
   1785                 },
   1786                 "count": {
   1787                     "per_ip": {"window_seconds": 60, "max_hits": 300},
   1788                     "per_connection": {"window_seconds": 60, "max_hits": 60},
   1789                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
   1790                     "per_group": {"window_seconds": 60, "max_hits": 120},
   1791                     "per_kind": {"window_seconds": 60, "max_hits": 240},
   1792                     "broad": {"window_seconds": 60, "max_hits": 20}
   1793                 }
   1794             },
   1795             "ignored": true
   1796         }"#;
   1797         assert!(
   1798             parse_base_relay_runtime_config_json(unknown_top_level)
   1799                 .expect_err("unknown top-level field")
   1800                 .prefixed_message()
   1801                 .contains("unknown field `ignored`")
   1802         );
   1803 
   1804         let unknown_nested = r#"{
   1805             "server": {
   1806                 "listen_addr": "127.0.0.1:0",
   1807                 "relay_url": "wss://relay.radroots.test"
   1808             },
   1809             "pocket": {
   1810                 "data_directory": "runtime/pocket",
   1811                 "sync_policy": "flush_on_shutdown",
   1812                 "query": {
   1813                   "allow_scraping": false,
   1814                   "allow_scrape_if_limited_to": 100,
   1815                   "allow_scrape_if_max_seconds": 3600
   1816                 }
   1817             },
   1818             "groups": {
   1819                 "enabled": false
   1820             },
   1821             "auth": {
   1822                 "challenge_ttl_seconds": 300,
   1823                 "created_at_skew_seconds": 600
   1824             },
   1825             "limits": {
   1826                 "max_message_length": 1048576,
   1827                 "max_subid_length": 64,
   1828                 "max_subscriptions_per_connection": 64,
   1829                 "max_filters_per_request": 10,
   1830                 "max_tag_values_per_filter": 100,
   1831                 "max_query_complexity": 2048,
   1832                 "max_limit": 500,
   1833                 "default_limit": 100,
   1834                 "max_event_tags": 200,
   1835                 "max_content_length": 65536,
   1836                 "broadcast_channel_capacity": 4096,
   1837                 "per_connection_outbound_queue": 256,
   1838                 "max_unimplemented_limit": 99
   1839             },
   1840             "rate_limits": {
   1841                 "auth": {
   1842                     "per_ip": {"window_seconds": 60, "max_hits": 120},
   1843                     "per_pubkey": {"window_seconds": 60, "max_hits": 30},
   1844                     "failures": {"window_seconds": 300, "max_hits": 5},
   1845                     "failures_per_ip": {"window_seconds": 300, "max_hits": 20}
   1846                 },
   1847                 "event": {
   1848                     "per_ip": {"window_seconds": 60, "max_hits": 600},
   1849                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
   1850                     "per_kind": {"window_seconds": 60, "max_hits": 1000}
   1851                 },
   1852                 "group": {
   1853                     "write_per_ip": {"window_seconds": 60, "max_hits": 300},
   1854                     "write_per_pubkey": {"window_seconds": 60, "max_hits": 60},
   1855                     "write_per_group": {"window_seconds": 60, "max_hits": 90},
   1856                     "write_per_kind": {"window_seconds": 60, "max_hits": 300},
   1857                     "join_flow": {"window_seconds": 300, "max_hits": 10},
   1858                     "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30}
   1859                 },
   1860                 "req": {
   1861                     "per_ip": {"window_seconds": 60, "max_hits": 600},
   1862                     "per_connection": {"window_seconds": 60, "max_hits": 120},
   1863                     "per_pubkey": {"window_seconds": 60, "max_hits": 240},
   1864                     "per_group": {"window_seconds": 60, "max_hits": 240},
   1865                     "per_kind": {"window_seconds": 60, "max_hits": 500},
   1866                     "broad": {"window_seconds": 60, "max_hits": 30}
   1867                 },
   1868                 "count": {
   1869                     "per_ip": {"window_seconds": 60, "max_hits": 300},
   1870                     "per_connection": {"window_seconds": 60, "max_hits": 60},
   1871                     "per_pubkey": {"window_seconds": 60, "max_hits": 120},
   1872                     "per_group": {"window_seconds": 60, "max_hits": 120},
   1873                     "per_kind": {"window_seconds": 60, "max_hits": 240},
   1874                     "broad": {"window_seconds": 60, "max_hits": 20}
   1875                 }
   1876             }
   1877         }"#;
   1878         assert!(
   1879             parse_base_relay_runtime_config_json(unknown_nested)
   1880                 .expect_err("unknown nested field")
   1881                 .prefixed_message()
   1882                 .contains("unknown field `max_unimplemented_limit`")
   1883         );
   1884     }
   1885 
   1886     #[test]
   1887     fn base_relay_runtime_config_rejects_removed_pocket_options() {
   1888         let raw = include_str!("../../../config/tangle.example.json").replace(
   1889             "    \"data_directory\": \"runtime/pocket\",\n",
   1890             "    \"data_directory\": \"runtime/pocket\",\n    \"map_size_bytes\": 1073741824,\n",
   1891         );
   1892         assert!(
   1893             parse_base_relay_runtime_config_json(&raw)
   1894                 .expect_err("removed map size")
   1895                 .prefixed_message()
   1896                 .contains("unknown field `map_size_bytes`")
   1897         );
   1898 
   1899         let raw = include_str!("../../../config/tangle.example.json").replace(
   1900             "    \"data_directory\": \"runtime/pocket\",\n",
   1901             "    \"data_directory\": \"runtime/pocket\",\n    \"reader_slots\": 128,\n",
   1902         );
   1903         assert!(
   1904             parse_base_relay_runtime_config_json(&raw)
   1905                 .expect_err("removed readers")
   1906                 .prefixed_message()
   1907                 .contains("unknown field `reader_slots`")
   1908         );
   1909     }
   1910 
   1911     #[test]
   1912     fn base_relay_runtime_config_validates_pocket_query_controls() {
   1913         let raw = include_str!("../../../config/tangle.example.json").replace(
   1914             "    \"allow_scrape_if_limited_to\": 100,\n",
   1915             "    \"allow_scrape_if_limited_to\": 501,\n",
   1916         );
   1917         assert_eq!(
   1918             parse_base_relay_runtime_config_json(&raw)
   1919                 .expect_err("query scrape limit")
   1920                 .prefixed_message(),
   1921             "invalid: pocket.query.allow_scrape_if_limited_to must be less than or equal to limits.max_limit"
   1922         );
   1923 
   1924         let raw = include_str!("../../../config/tangle.example.json").replace(
   1925             "    \"allow_scrape_if_max_seconds\": 3600\n",
   1926             "    \"allow_scrape_if_max_seconds\": 86401\n",
   1927         );
   1928         assert_eq!(
   1929             parse_base_relay_runtime_config_json(&raw)
   1930                 .expect_err("query scrape window")
   1931                 .prefixed_message(),
   1932             "invalid: pocket.query.allow_scrape_if_max_seconds must be less than or equal to 86400"
   1933         );
   1934     }
   1935 
   1936     #[test]
   1937     fn base_relay_runtime_config_requires_explicit_query_complexity() {
   1938         let raw = include_str!("../../../config/tangle.example.json")
   1939             .replace("    \"max_query_complexity\": 2048,\n", "");
   1940         assert!(
   1941             parse_base_relay_runtime_config_json(&raw)
   1942                 .expect_err("missing query complexity")
   1943                 .prefixed_message()
   1944                 .contains("missing field `max_query_complexity`")
   1945         );
   1946     }
   1947 
   1948     #[test]
   1949     fn base_relay_runtime_config_requires_ip_scoped_rate_limits() {
   1950         let raw = include_str!("../../../config/tangle.example.json").replace(
   1951             "      \"per_ip\": {\n        \"window_seconds\": 60,\n        \"max_hits\": 120\n      },\n",
   1952             "",
   1953         );
   1954         assert!(
   1955             parse_base_relay_runtime_config_json(&raw)
   1956                 .expect_err("missing auth ip")
   1957                 .prefixed_message()
   1958                 .contains("missing field `per_ip`")
   1959         );
   1960 
   1961         let raw = include_str!("../../../config/tangle.example.json").replace(
   1962             "      \"failures\": {\n        \"window_seconds\": 300,\n        \"max_hits\": 5\n      },\n      \"failures_per_ip\": {\n        \"window_seconds\": 300,\n        \"max_hits\": 20\n      }\n",
   1963             "      \"failures\": {\n        \"window_seconds\": 300,\n        \"max_hits\": 5\n      }\n",
   1964         );
   1965         assert!(
   1966             parse_base_relay_runtime_config_json(&raw)
   1967                 .expect_err("missing auth failure ip")
   1968                 .prefixed_message()
   1969                 .contains("missing field `failures_per_ip`")
   1970         );
   1971 
   1972         let raw = include_str!("../../../config/tangle.example.json").replace(
   1973             "      \"per_ip\": {\n        \"window_seconds\": 60,\n        \"max_hits\": 600\n      },\n",
   1974             "",
   1975         );
   1976         assert!(
   1977             parse_base_relay_runtime_config_json(&raw)
   1978                 .expect_err("missing event ip")
   1979                 .prefixed_message()
   1980                 .contains("missing field `per_ip`")
   1981         );
   1982 
   1983         let raw = include_str!("../../../config/tangle.example.json").replace(
   1984             "      \"write_per_ip\": {\n        \"window_seconds\": 60,\n        \"max_hits\": 300\n      },\n",
   1985             "",
   1986         );
   1987         assert!(
   1988             parse_base_relay_runtime_config_json(&raw)
   1989                 .expect_err("missing group write ip")
   1990                 .prefixed_message()
   1991                 .contains("missing field `write_per_ip`")
   1992         );
   1993 
   1994         let raw = include_str!("../../../config/tangle.example.json").replace(
   1995             "      \"join_flow\": {\n        \"window_seconds\": 300,\n        \"max_hits\": 10\n      },\n      \"join_flow_per_ip\": {\n        \"window_seconds\": 300,\n        \"max_hits\": 30\n      }\n",
   1996             "      \"join_flow\": {\n        \"window_seconds\": 300,\n        \"max_hits\": 10\n      }\n",
   1997         );
   1998         assert!(
   1999             parse_base_relay_runtime_config_json(&raw)
   2000                 .expect_err("missing group join ip")
   2001                 .prefixed_message()
   2002                 .contains("missing field `join_flow_per_ip`")
   2003         );
   2004     }
   2005 }