radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

config.rs (29044B)


      1 use crate::host_nostr::Metadata;
      2 use anyhow::{Context, Result, bail};
      3 use radroots_event::profile::{AuthoredProfile, Nip05Identifier};
      4 use serde::{Deserialize, Serialize};
      5 use std::path::{Path, PathBuf};
      6 
      7 #[cfg(not(test))]
      8 use super::paths::process_path_selection;
      9 use super::paths::{
     10     PathProfile, PathResolver, RadrootsdRuntimePaths, default_transport_publish_database_path,
     11     resolve_runtime_paths_with_resolver,
     12 };
     13 
     14 #[derive(Debug, Serialize, Deserialize, Clone)]
     15 pub struct NostrServiceConfig {
     16     pub logs_dir: String,
     17     #[serde(default)]
     18     pub relays: Vec<String>,
     19     #[serde(default)]
     20     pub nip89_identifier: Option<String>,
     21     #[serde(default)]
     22     pub nip89_extra_tags: Vec<Vec<String>>,
     23 }
     24 
     25 fn default_rpc_addr() -> String {
     26     "127.0.0.1:7070".to_string()
     27 }
     28 
     29 fn default_max_request_body_size() -> u32 {
     30     10 * 1024 * 1024
     31 }
     32 
     33 fn default_max_response_body_size() -> u32 {
     34     10 * 1024 * 1024
     35 }
     36 
     37 fn default_max_connections() -> u32 {
     38     100
     39 }
     40 
     41 fn default_max_subscriptions_per_connection() -> u32 {
     42     1024
     43 }
     44 
     45 fn default_message_buffer_capacity() -> u32 {
     46     1024
     47 }
     48 
     49 fn default_rpc_batch_request_limit() -> Option<u32> {
     50     Some(0)
     51 }
     52 
     53 fn default_nip46_session_ttl_secs() -> u64 {
     54     900
     55 }
     56 
     57 fn default_nip46_perms() -> Vec<String> {
     58     Vec::new()
     59 }
     60 
     61 fn default_nip46_public_jsonrpc_enabled() -> bool {
     62     false
     63 }
     64 
     65 fn default_transport_publish_enabled() -> bool {
     66     true
     67 }
     68 
     69 fn default_transport_publish_connect_timeout_secs() -> u64 {
     70     10
     71 }
     72 
     73 fn default_transport_publish_max_event_bytes() -> usize {
     74     128 * 1024
     75 }
     76 
     77 fn default_transport_publish_max_targets_per_request() -> usize {
     78     20
     79 }
     80 
     81 fn default_transport_publish_job_list_limit() -> usize {
     82     100
     83 }
     84 
     85 fn default_transport_publish_max_concurrent_publish_jobs() -> usize {
     86     8
     87 }
     88 
     89 fn default_nostr_relay_url_policy() -> NostrRelayUrlPolicy {
     90     NostrRelayUrlPolicy::Public
     91 }
     92 
     93 #[derive(Debug, Deserialize, Clone, Default)]
     94 struct RawServiceConfig {
     95     #[serde(default)]
     96     pub logs_dir: Option<String>,
     97     #[serde(default)]
     98     pub relays: Vec<String>,
     99     #[serde(default)]
    100     pub nip89_identifier: Option<String>,
    101     #[serde(default)]
    102     pub nip89_extra_tags: Vec<Vec<String>>,
    103 }
    104 
    105 impl RawServiceConfig {
    106     fn into_service_config(self, paths: &RadrootsdRuntimePaths) -> NostrServiceConfig {
    107         NostrServiceConfig {
    108             logs_dir: self
    109                 .logs_dir
    110                 .unwrap_or_else(|| paths.logs_dir.display().to_string()),
    111             relays: self.relays,
    112             nip89_identifier: self.nip89_identifier,
    113             nip89_extra_tags: self.nip89_extra_tags,
    114         }
    115     }
    116 }
    117 
    118 #[derive(Debug, Deserialize, Clone)]
    119 #[serde(deny_unknown_fields)]
    120 struct RawTransportPublishConfig {
    121     #[serde(default = "default_transport_publish_enabled")]
    122     pub enabled: bool,
    123     #[serde(default = "default_transport_publish_connect_timeout_secs")]
    124     pub connect_timeout_secs: u64,
    125     #[serde(default = "default_transport_publish_max_event_bytes")]
    126     pub max_event_bytes: usize,
    127     #[serde(default = "default_transport_publish_max_targets_per_request")]
    128     pub max_targets_per_request: usize,
    129     #[serde(default = "default_transport_publish_job_list_limit")]
    130     pub job_list_limit: usize,
    131     #[serde(default = "default_transport_publish_max_concurrent_publish_jobs")]
    132     pub max_concurrent_publish_jobs: usize,
    133     #[serde(default)]
    134     pub database_path: Option<PathBuf>,
    135     #[serde(default)]
    136     pub nostr: TransportPublishNostrConfig,
    137 }
    138 
    139 impl Default for RawTransportPublishConfig {
    140     fn default() -> Self {
    141         Self {
    142             enabled: default_transport_publish_enabled(),
    143             connect_timeout_secs: default_transport_publish_connect_timeout_secs(),
    144             max_event_bytes: default_transport_publish_max_event_bytes(),
    145             max_targets_per_request: default_transport_publish_max_targets_per_request(),
    146             job_list_limit: default_transport_publish_job_list_limit(),
    147             max_concurrent_publish_jobs: default_transport_publish_max_concurrent_publish_jobs(),
    148             database_path: None,
    149             nostr: TransportPublishNostrConfig::default(),
    150         }
    151     }
    152 }
    153 
    154 impl RawTransportPublishConfig {
    155     fn into_transport_publish_config(
    156         self,
    157         paths: &RadrootsdRuntimePaths,
    158     ) -> TransportPublishConfig {
    159         TransportPublishConfig {
    160             enabled: self.enabled,
    161             connect_timeout_secs: self.connect_timeout_secs,
    162             max_event_bytes: self.max_event_bytes,
    163             max_targets_per_request: self.max_targets_per_request,
    164             job_list_limit: self.job_list_limit,
    165             max_concurrent_publish_jobs: self.max_concurrent_publish_jobs,
    166             database_path: self
    167                 .database_path
    168                 .unwrap_or_else(|| paths.transport_publish_database_path.clone()),
    169             nostr: self.nostr,
    170         }
    171     }
    172 }
    173 
    174 #[derive(Debug, Deserialize, Clone)]
    175 #[serde(deny_unknown_fields)]
    176 struct RawConfiguration {
    177     #[serde(flatten)]
    178     pub service: RawServiceConfig,
    179     #[serde(default)]
    180     pub rpc: RpcConfig,
    181     #[serde(default)]
    182     pub rpc_addr: Option<String>,
    183     #[serde(default)]
    184     pub nip46: Nip46Config,
    185     #[serde(default)]
    186     pub transport_publish: RawTransportPublishConfig,
    187 }
    188 
    189 #[derive(Debug, Deserialize, Clone)]
    190 struct RawSettings {
    191     pub metadata: Metadata,
    192     pub config: RawConfiguration,
    193 }
    194 
    195 impl RawSettings {
    196     fn into_settings(self, paths: &RadrootsdRuntimePaths) -> Settings {
    197         Settings {
    198             metadata: self.metadata,
    199             config: Configuration {
    200                 service: self.config.service.into_service_config(paths),
    201                 rpc: self.config.rpc,
    202                 rpc_addr: self.config.rpc_addr,
    203                 nip46: self.config.nip46,
    204                 transport_publish: self
    205                     .config
    206                     .transport_publish
    207                     .into_transport_publish_config(paths),
    208             },
    209         }
    210     }
    211 }
    212 
    213 fn load_settings_from_path_with_resolver(
    214     path: &Path,
    215     resolver: &PathResolver,
    216     profile: PathProfile,
    217     repo_local_root: Option<&Path>,
    218 ) -> Result<Settings> {
    219     let source = std::fs::read_to_string(path)
    220         .with_context(|| format!("read configuration from {}", path.display()))?;
    221     let raw: RawSettings = toml::from_str(source.as_str())
    222         .with_context(|| format!("parse configuration from {}", path.display()))?;
    223     let paths = resolve_runtime_paths_with_resolver(resolver, profile, repo_local_root)?;
    224     let settings = raw.into_settings(&paths);
    225     settings.validate()?;
    226     Ok(settings)
    227 }
    228 
    229 #[cfg(not(test))]
    230 pub fn load_settings_from_path(path: impl AsRef<Path>) -> Result<Settings> {
    231     let path = path.as_ref();
    232     let (profile, repo_local_root) = process_path_selection()?;
    233     load_settings_from_path_with_resolver(
    234         path,
    235         &PathResolver::current(),
    236         profile,
    237         repo_local_root.as_deref(),
    238     )
    239 }
    240 
    241 #[derive(Debug, Serialize, Deserialize, Clone)]
    242 pub struct Nip46Config {
    243     #[serde(default = "default_nip46_session_ttl_secs")]
    244     pub session_ttl_secs: u64,
    245     #[serde(default = "default_nip46_perms")]
    246     pub perms: Vec<String>,
    247     #[serde(default = "default_nip46_public_jsonrpc_enabled")]
    248     pub public_jsonrpc_enabled: bool,
    249     #[serde(default)]
    250     pub nostrconnect_url: Option<String>,
    251 }
    252 
    253 impl Default for Nip46Config {
    254     fn default() -> Self {
    255         Self {
    256             session_ttl_secs: default_nip46_session_ttl_secs(),
    257             perms: default_nip46_perms(),
    258             public_jsonrpc_enabled: default_nip46_public_jsonrpc_enabled(),
    259             nostrconnect_url: None,
    260         }
    261     }
    262 }
    263 
    264 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
    265 #[serde(rename_all = "snake_case")]
    266 pub enum NostrRelayUrlPolicy {
    267     Public,
    268     Localhost,
    269 }
    270 
    271 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
    272 #[serde(deny_unknown_fields)]
    273 pub struct TransportPublishNostrConfig {
    274     #[serde(default = "default_nostr_relay_url_policy")]
    275     pub relay_url_policy: NostrRelayUrlPolicy,
    276     #[serde(default)]
    277     pub author_relay_discovery_relays: Vec<String>,
    278     #[serde(default)]
    279     pub daemon_default_relays: Vec<String>,
    280 }
    281 
    282 impl Default for TransportPublishNostrConfig {
    283     fn default() -> Self {
    284         Self {
    285             relay_url_policy: default_nostr_relay_url_policy(),
    286             author_relay_discovery_relays: Vec::new(),
    287             daemon_default_relays: Vec::new(),
    288         }
    289     }
    290 }
    291 
    292 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
    293 #[serde(deny_unknown_fields)]
    294 pub struct TransportPublishConfig {
    295     #[serde(default = "default_transport_publish_enabled")]
    296     pub enabled: bool,
    297     #[serde(default = "default_transport_publish_connect_timeout_secs")]
    298     pub connect_timeout_secs: u64,
    299     #[serde(default = "default_transport_publish_max_event_bytes")]
    300     pub max_event_bytes: usize,
    301     #[serde(default = "default_transport_publish_max_targets_per_request")]
    302     pub max_targets_per_request: usize,
    303     #[serde(default = "default_transport_publish_job_list_limit")]
    304     pub job_list_limit: usize,
    305     #[serde(default = "default_transport_publish_max_concurrent_publish_jobs")]
    306     pub max_concurrent_publish_jobs: usize,
    307     #[serde(default = "default_transport_publish_database_path")]
    308     pub database_path: PathBuf,
    309     #[serde(default)]
    310     pub nostr: TransportPublishNostrConfig,
    311 }
    312 
    313 impl Default for TransportPublishConfig {
    314     fn default() -> Self {
    315         Self {
    316             enabled: default_transport_publish_enabled(),
    317             connect_timeout_secs: default_transport_publish_connect_timeout_secs(),
    318             max_event_bytes: default_transport_publish_max_event_bytes(),
    319             max_targets_per_request: default_transport_publish_max_targets_per_request(),
    320             job_list_limit: default_transport_publish_job_list_limit(),
    321             max_concurrent_publish_jobs: default_transport_publish_max_concurrent_publish_jobs(),
    322             database_path: default_transport_publish_database_path(),
    323             nostr: TransportPublishNostrConfig::default(),
    324         }
    325     }
    326 }
    327 
    328 impl TransportPublishConfig {
    329     pub fn validate(&self) -> Result<()> {
    330         if self.max_event_bytes == 0 {
    331             bail!("transport_publish max_event_bytes must be greater than zero");
    332         }
    333         if self.max_targets_per_request == 0 {
    334             bail!("transport_publish max_targets_per_request must be greater than zero");
    335         }
    336         if self.job_list_limit == 0 {
    337             bail!("transport_publish job_list_limit must be greater than zero");
    338         }
    339         if self.max_concurrent_publish_jobs == 0 {
    340             bail!("transport_publish max_concurrent_publish_jobs must be greater than zero");
    341         }
    342         if self.connect_timeout_secs == 0 {
    343             bail!("transport_publish connect_timeout_secs must be greater than zero");
    344         }
    345         Ok(())
    346     }
    347 }
    348 
    349 #[derive(Debug, Serialize, Deserialize, Clone)]
    350 pub struct RpcConfig {
    351     #[serde(default = "default_rpc_addr")]
    352     pub addr: String,
    353     #[serde(default = "default_max_request_body_size")]
    354     pub max_request_body_size: u32,
    355     #[serde(default = "default_max_response_body_size")]
    356     pub max_response_body_size: u32,
    357     #[serde(default = "default_max_connections")]
    358     pub max_connections: u32,
    359     #[serde(default = "default_max_subscriptions_per_connection")]
    360     pub max_subscriptions_per_connection: u32,
    361     #[serde(default = "default_message_buffer_capacity")]
    362     pub message_buffer_capacity: u32,
    363     #[serde(default = "default_rpc_batch_request_limit")]
    364     pub batch_request_limit: Option<u32>,
    365 }
    366 
    367 impl Default for RpcConfig {
    368     fn default() -> Self {
    369         Self {
    370             addr: default_rpc_addr(),
    371             max_request_body_size: default_max_request_body_size(),
    372             max_response_body_size: default_max_response_body_size(),
    373             max_connections: default_max_connections(),
    374             max_subscriptions_per_connection: default_max_subscriptions_per_connection(),
    375             message_buffer_capacity: default_message_buffer_capacity(),
    376             batch_request_limit: default_rpc_batch_request_limit(),
    377         }
    378     }
    379 }
    380 
    381 #[derive(Debug, Serialize, Deserialize, Clone)]
    382 pub struct Configuration {
    383     #[serde(flatten)]
    384     pub service: NostrServiceConfig,
    385     #[serde(default)]
    386     pub rpc: RpcConfig,
    387     #[serde(default)]
    388     pub rpc_addr: Option<String>,
    389     #[serde(default)]
    390     pub nip46: Nip46Config,
    391     #[serde(default)]
    392     pub transport_publish: TransportPublishConfig,
    393 }
    394 
    395 impl Configuration {
    396     pub fn rpc_addr(&self) -> &str {
    397         self.rpc_addr.as_deref().unwrap_or(self.rpc.addr.as_str())
    398     }
    399 
    400     pub fn validate(&self) -> Result<()> {
    401         self.transport_publish.validate()?;
    402         Ok(())
    403     }
    404 }
    405 
    406 #[derive(Debug, Clone, Serialize, Deserialize)]
    407 pub struct Settings {
    408     pub metadata: Metadata,
    409     pub config: Configuration,
    410 }
    411 
    412 impl Settings {
    413     pub fn authored_profile(&self) -> Result<AuthoredProfile> {
    414         let metadata = &self.metadata;
    415         let name = metadata
    416             .name
    417             .as_deref()
    418             .ok_or_else(|| anyhow::anyhow!("metadata.name is required for the authored Profile"))?;
    419         let mut profile = AuthoredProfile::new(name.to_owned())
    420             .context("metadata.name is invalid for the authored Profile")?;
    421 
    422         if let Some(display_name) = metadata.display_name.as_ref() {
    423             profile = profile.with_display_name(display_name.clone());
    424         }
    425         if let Some(about) = metadata.about.as_ref() {
    426             profile = profile.with_about(about.clone());
    427         }
    428         if let Some(nip05) = metadata.nip05.as_deref() {
    429             let nip05 = Nip05Identifier::parse(nip05)
    430                 .context("metadata.nip05 is invalid for the authored Profile")?;
    431             profile = profile.with_nip05(nip05);
    432         }
    433 
    434         let media_fields = [
    435             metadata.picture.as_ref().map(|_| "picture"),
    436             metadata.banner.as_ref().map(|_| "banner"),
    437         ]
    438         .into_iter()
    439         .flatten()
    440         .collect::<Vec<_>>();
    441         if !media_fields.is_empty() {
    442             bail!(
    443                 "metadata.{} cannot be authored from URL-only configuration; Profile media requires a byte-verified Blossom descriptor",
    444                 media_fields.join(" and metadata.")
    445             );
    446         }
    447 
    448         let bot = match metadata.custom.get("bot") {
    449             Some(serde_json::Value::Bool(value)) => Some(*value),
    450             Some(_) => bail!("metadata.bot must be a Boolean for the authored Profile"),
    451             None => None,
    452         };
    453         if let Some(bot) = bot {
    454             profile = profile.with_bot(bot);
    455         }
    456 
    457         let mut unsupported_fields = Vec::new();
    458         if metadata.website.is_some() {
    459             unsupported_fields.push("website".to_owned());
    460         }
    461         if metadata.lud06.is_some() {
    462             unsupported_fields.push("lud06".to_owned());
    463         }
    464         if metadata.lud16.is_some() {
    465             unsupported_fields.push("lud16".to_owned());
    466         }
    467         unsupported_fields.extend(
    468             metadata
    469                 .custom
    470                 .keys()
    471                 .filter(|key| key.as_str() != "bot")
    472                 .cloned(),
    473         );
    474         if !unsupported_fields.is_empty() {
    475             bail!(
    476                 "metadata fields are not supported by the strict authored Profile contract: {}",
    477                 unsupported_fields.join(", ")
    478             );
    479         }
    480 
    481         Ok(profile)
    482     }
    483 
    484     pub fn validate(&self) -> Result<()> {
    485         self.config.validate()?;
    486         self.authored_profile()?;
    487         Ok(())
    488     }
    489 }
    490 
    491 #[cfg(test)]
    492 mod tests {
    493     use std::path::PathBuf;
    494 
    495     use super::NostrServiceConfig;
    496     use super::{
    497         Configuration, Nip46Config, NostrRelayUrlPolicy, RpcConfig, TransportPublishConfig,
    498         load_settings_from_path_with_resolver,
    499     };
    500     use crate::app::paths::{
    501         HostEnvironment, PathProfile, PathResolver, Platform, RuntimePathSelection,
    502     };
    503     use crate::app::paths::{
    504         RadrootsdRuntimeContractOutput, default_runtime_paths_for_process,
    505         resolve_runtime_paths_with_resolver, runtime_contract_with_selection,
    506     };
    507     use radroots_event::profile::Nip05Identifier;
    508     use serde_json::json;
    509 
    510     fn linux_resolver(home: &str) -> PathResolver {
    511         PathResolver::new(
    512             Platform::Linux,
    513             HostEnvironment {
    514                 home_dir: Some(PathBuf::from(home)),
    515                 ..HostEnvironment::default()
    516             },
    517         )
    518     }
    519 
    520     fn service_config() -> NostrServiceConfig {
    521         let paths = resolve_runtime_paths_with_resolver(
    522             &linux_resolver("/home/treesap"),
    523             PathProfile::InteractiveUser,
    524             None,
    525         )
    526         .expect("resolve interactive-user paths");
    527         NostrServiceConfig {
    528             logs_dir: paths.logs_dir.display().to_string(),
    529             relays: Vec::new(),
    530             nip89_identifier: Some("radrootsd".to_string()),
    531             nip89_extra_tags: Vec::new(),
    532         }
    533     }
    534 
    535     fn runtime_contract_with_resolver(
    536         resolver: &PathResolver,
    537         profile: PathProfile,
    538         repo_local_root: Option<&std::path::Path>,
    539     ) -> anyhow::Result<RadrootsdRuntimeContractOutput> {
    540         runtime_contract_with_selection(
    541             resolver,
    542             &RuntimePathSelection::caller(profile, repo_local_root.map(PathBuf::from)),
    543         )
    544     }
    545 
    546     #[test]
    547     fn nip46_defaults_are_expected() {
    548         let cfg = Nip46Config::default();
    549         assert_eq!(cfg.session_ttl_secs, 900);
    550         assert!(cfg.perms.is_empty());
    551         assert!(!cfg.public_jsonrpc_enabled);
    552         assert!(cfg.nostrconnect_url.is_none());
    553     }
    554 
    555     #[test]
    556     fn rpc_defaults_disable_batches() {
    557         let cfg = RpcConfig::default();
    558         assert_eq!(cfg.addr, "127.0.0.1:7070");
    559         assert_eq!(cfg.batch_request_limit, Some(0));
    560     }
    561 
    562     #[test]
    563     fn transport_publish_defaults_are_expected() {
    564         let paths = default_runtime_paths_for_process().expect("resolve process runtime paths");
    565         let cfg = TransportPublishConfig::default();
    566         assert!(cfg.enabled);
    567         assert_eq!(cfg.connect_timeout_secs, 10);
    568         assert_eq!(cfg.max_event_bytes, 128 * 1024);
    569         assert_eq!(cfg.max_targets_per_request, 20);
    570         assert_eq!(cfg.job_list_limit, 100);
    571         assert_eq!(cfg.max_concurrent_publish_jobs, 8);
    572         assert_eq!(cfg.database_path, paths.transport_publish_database_path);
    573         assert_eq!(cfg.nostr.relay_url_policy, NostrRelayUrlPolicy::Public);
    574         assert!(cfg.nostr.author_relay_discovery_relays.is_empty());
    575         assert!(cfg.nostr.daemon_default_relays.is_empty());
    576     }
    577 
    578     #[test]
    579     fn rpc_addr_prefers_override() {
    580         let mut cfg = Configuration {
    581             service: service_config(),
    582             rpc: RpcConfig {
    583                 addr: "127.0.0.1:1111".to_string(),
    584                 ..RpcConfig::default()
    585             },
    586             rpc_addr: None,
    587             nip46: Nip46Config::default(),
    588             transport_publish: TransportPublishConfig::default(),
    589         };
    590         assert_eq!(cfg.rpc_addr(), "127.0.0.1:1111");
    591         cfg.rpc_addr = Some("127.0.0.1:2222".to_string());
    592         assert_eq!(cfg.rpc_addr(), "127.0.0.1:2222");
    593     }
    594 
    595     #[test]
    596     fn transport_publish_validation_rejects_zero_limits() {
    597         let cfg = TransportPublishConfig {
    598             max_event_bytes: 0,
    599             ..TransportPublishConfig::default()
    600         };
    601         assert!(cfg.validate().is_err());
    602         let cfg = TransportPublishConfig {
    603             max_targets_per_request: 0,
    604             ..TransportPublishConfig::default()
    605         };
    606         assert!(cfg.validate().is_err());
    607         let cfg = TransportPublishConfig {
    608             job_list_limit: 0,
    609             ..TransportPublishConfig::default()
    610         };
    611         assert!(cfg.validate().is_err());
    612         let cfg = TransportPublishConfig {
    613             max_concurrent_publish_jobs: 0,
    614             ..TransportPublishConfig::default()
    615         };
    616         assert!(cfg.validate().is_err());
    617         let cfg = TransportPublishConfig {
    618             connect_timeout_secs: 0,
    619             ..TransportPublishConfig::default()
    620         };
    621         assert!(cfg.validate().is_err());
    622     }
    623 
    624     #[test]
    625     fn authored_profile_accepts_only_strict_metadata_fields() {
    626         let mut metadata = crate::host_nostr::Metadata::new()
    627             .name("radrootsd")
    628             .display_name("Radroots daemon")
    629             .about("local relay publishing")
    630             .nip05("daemon@radroots.example");
    631         metadata.custom.insert("bot".to_owned(), json!(true));
    632         let settings = super::Settings {
    633             metadata,
    634             config: Configuration {
    635                 service: service_config(),
    636                 rpc: RpcConfig::default(),
    637                 rpc_addr: None,
    638                 nip46: Nip46Config::default(),
    639                 transport_publish: TransportPublishConfig::default(),
    640             },
    641         };
    642 
    643         let profile = settings.authored_profile().expect("authored profile");
    644         assert_eq!(profile.name(), "radrootsd");
    645         assert_eq!(profile.display_name(), Some("Radroots daemon"));
    646         assert_eq!(profile.about(), Some("local relay publishing"));
    647         assert_eq!(
    648             profile.nip05().map(Nip05Identifier::as_str),
    649             Some("daemon@radroots.example")
    650         );
    651         assert_eq!(profile.bot(), Some(true));
    652         assert!(profile.picture().is_none());
    653         assert!(profile.banner().is_none());
    654     }
    655 
    656     #[test]
    657     fn authored_profile_rejects_url_only_media() {
    658         let metadata = crate::host_nostr::Metadata::new()
    659             .name("radrootsd")
    660             .picture(url::Url::parse("https://blossom.example/blob").expect("picture URL"));
    661         let settings = super::Settings {
    662             metadata,
    663             config: Configuration {
    664                 service: service_config(),
    665                 rpc: RpcConfig::default(),
    666                 rpc_addr: None,
    667                 nip46: Nip46Config::default(),
    668                 transport_publish: TransportPublishConfig::default(),
    669             },
    670         };
    671 
    672         let error = settings
    673             .authored_profile()
    674             .expect_err("URL-only media must fail closed");
    675         assert!(
    676             error
    677                 .to_string()
    678                 .contains("byte-verified Blossom descriptor")
    679         );
    680     }
    681 
    682     #[test]
    683     fn authored_profile_rejects_missing_invalid_and_unsupported_fields() {
    684         let mut settings = super::Settings {
    685             metadata: crate::host_nostr::Metadata::new(),
    686             config: Configuration {
    687                 service: service_config(),
    688                 rpc: RpcConfig::default(),
    689                 rpc_addr: None,
    690                 nip46: Nip46Config::default(),
    691                 transport_publish: TransportPublishConfig::default(),
    692             },
    693         };
    694         assert!(settings.authored_profile().is_err());
    695 
    696         settings.metadata.name = Some("radrootsd".to_owned());
    697         settings.metadata.nip05 = Some("invalid".to_owned());
    698         assert!(settings.authored_profile().is_err());
    699 
    700         settings.metadata.nip05 = None;
    701         settings.metadata.website = Some("https://radroots.example".to_owned());
    702         let error = settings
    703             .authored_profile()
    704             .expect_err("unsupported fields must fail closed");
    705         assert!(error.to_string().contains("website"));
    706     }
    707 
    708     #[test]
    709     fn runtime_paths_follow_interactive_user_contract() {
    710         let paths = resolve_runtime_paths_with_resolver(
    711             &linux_resolver("/home/treesap"),
    712             PathProfile::InteractiveUser,
    713             None,
    714         )
    715         .expect("resolve interactive-user paths");
    716 
    717         assert_eq!(
    718             paths.config_path,
    719             PathBuf::from("/home/treesap/.radroots/config/services/radrootsd/config.toml")
    720         );
    721         assert_eq!(
    722             paths.logs_dir,
    723             PathBuf::from("/home/treesap/.radroots/logs/services/radrootsd")
    724         );
    725         assert_eq!(
    726             paths.identity_path,
    727             PathBuf::from(
    728                 "/home/treesap/.radroots/secrets/services/radrootsd/identity.secret.json"
    729             )
    730         );
    731         assert_eq!(
    732             paths.transport_publish_database_path,
    733             PathBuf::from(
    734                 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite"
    735             )
    736         );
    737     }
    738 
    739     #[test]
    740     fn runtime_paths_follow_service_host_contract() {
    741         let paths = resolve_runtime_paths_with_resolver(
    742             &linux_resolver("/home/treesap"),
    743             PathProfile::ServiceHost,
    744             None,
    745         )
    746         .expect("resolve service-host paths");
    747 
    748         assert_eq!(
    749             paths.config_path,
    750             PathBuf::from("/etc/radroots/services/radrootsd/config.toml")
    751         );
    752         assert_eq!(
    753             paths.logs_dir,
    754             PathBuf::from("/var/log/radroots/services/radrootsd")
    755         );
    756         assert_eq!(
    757             paths.identity_path,
    758             PathBuf::from("/etc/radroots/secrets/services/radrootsd/identity.secret.json")
    759         );
    760         assert_eq!(
    761             paths.transport_publish_database_path,
    762             PathBuf::from("/var/lib/radroots/services/radrootsd/transport_publish.sqlite")
    763         );
    764     }
    765 
    766     #[test]
    767     fn runtime_paths_follow_repo_local_contract() {
    768         let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/radrootsd");
    769         let paths = resolve_runtime_paths_with_resolver(
    770             &linux_resolver("/home/treesap"),
    771             PathProfile::RepoLocal,
    772             Some(repo_local_root.as_path()),
    773         )
    774         .expect("resolve repo-local paths");
    775 
    776         assert_eq!(
    777             paths.config_path,
    778             repo_local_root.join("config/services/radrootsd/config.toml")
    779         );
    780         assert_eq!(
    781             paths.logs_dir,
    782             repo_local_root.join("logs/services/radrootsd")
    783         );
    784         assert_eq!(
    785             paths.identity_path,
    786             repo_local_root.join("secrets/services/radrootsd/identity.secret.json")
    787         );
    788         assert_eq!(
    789             paths.transport_publish_database_path,
    790             repo_local_root.join("data/services/radrootsd/transport_publish.sqlite")
    791         );
    792     }
    793 
    794     #[test]
    795     fn load_settings_materializes_profile_defaults_when_paths_are_omitted() {
    796         let temp = tempfile::tempdir().expect("tempdir");
    797         let config_path = temp.path().join("radrootsd.toml");
    798         std::fs::write(
    799             &config_path,
    800             r#"
    801 [metadata]
    802 name = "radrootsd-test"
    803 
    804 [config]
    805 relays = ["ws://127.0.0.1:8080"]
    806 
    807 [config.rpc]
    808 addr = "127.0.0.1:7070"
    809 "#,
    810         )
    811         .expect("write config");
    812 
    813         let settings = load_settings_from_path_with_resolver(
    814             &config_path,
    815             &linux_resolver("/home/treesap"),
    816             PathProfile::InteractiveUser,
    817             None,
    818         )
    819         .expect("load settings");
    820 
    821         assert_eq!(
    822             settings.config.service.logs_dir,
    823             "/home/treesap/.radroots/logs/services/radrootsd"
    824         );
    825         assert_eq!(
    826             settings.config.transport_publish.database_path,
    827             PathBuf::from(
    828                 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite"
    829             )
    830         );
    831     }
    832 
    833     #[test]
    834     fn obsolete_transport_publish_config_is_rejected() {
    835         let temp = tempfile::tempdir().expect("tempdir");
    836         let config_path = temp.path().join("radrootsd.toml");
    837         std::fs::write(
    838             &config_path,
    839             r#"
    840 [metadata]
    841 name = "radrootsd-test"
    842 
    843 [config]
    844 relays = []
    845 
    846 [config.transport_publish]
    847 relay_url_policy = "localhost"
    848 "#,
    849         )
    850         .expect("write config");
    851 
    852         let err = load_settings_from_path_with_resolver(
    853             &config_path,
    854             &linux_resolver("/home/treesap"),
    855             PathProfile::InteractiveUser,
    856             None,
    857         )
    858         .expect_err("obsolete transport_publish config should fail");
    859         let err_chain = format!("{err:?}");
    860         assert!(err_chain.contains("unknown field"));
    861         assert!(err_chain.contains("relay_url_policy"));
    862     }
    863 
    864     #[test]
    865     fn runtime_contract_output_matches_interactive_user_contract() {
    866         let contract = runtime_contract_with_resolver(
    867             &linux_resolver("/home/treesap"),
    868             PathProfile::InteractiveUser,
    869             None,
    870         )
    871         .expect("interactive-user contract");
    872 
    873         assert_eq!(contract.active_profile, "interactive_user");
    874         assert_eq!(
    875             contract.path_overrides.subordinate_path_override_keys,
    876             vec![
    877                 "config.service.logs_dir".to_owned(),
    878                 "config.transport_publish.database_path".to_owned(),
    879             ]
    880         );
    881         assert_eq!(
    882             contract.canonical_transport_publish_database_path,
    883             PathBuf::from(
    884                 "/home/treesap/.radroots/data/services/radrootsd/transport_publish.sqlite"
    885             )
    886         );
    887     }
    888 }