myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

config.rs (113581B)


      1 use std::collections::BTreeSet;
      2 use std::fs;
      3 use std::net::SocketAddr;
      4 use std::path::{Path, PathBuf};
      5 
      6 use nostr::PublicKey;
      7 use radroots_nostr::prelude::RadrootsNostrRelayUrl;
      8 use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions;
      9 use radroots_nostr_signer::prelude::RadrootsNostrSignerApprovalRequirement;
     10 use radroots_runtime_paths::{
     11     RadrootsLegacyPathCandidate, RadrootsMigrationReport, RadrootsPathResolver,
     12     RadrootsRuntimeLegacyPathContract, RadrootsRuntimeMigrationContract,
     13     RadrootsRuntimePathPolicyContract, inspect_legacy_paths, runtime_migration_contract,
     14 };
     15 use serde::{Deserialize, Serialize};
     16 use tracing_subscriber::EnvFilter;
     17 
     18 use crate::error::MycError;
     19 use crate::paths::MycPathOverrideFlags;
     20 pub use crate::paths::{DEFAULT_ENV_PATH, MycPathProfile, MycPathsConfig};
     21 
     22 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     23 #[serde(default, deny_unknown_fields)]
     24 pub struct MycConfig {
     25     pub service: MycServiceConfig,
     26     pub logging: MycLoggingConfig,
     27     pub custody: MycCustodyConfig,
     28     pub paths: MycPathsConfig,
     29     pub persistence: MycPersistenceConfig,
     30     pub audit: MycAuditConfig,
     31     pub observability: MycObservabilityConfig,
     32     pub discovery: MycDiscoveryConfig,
     33     pub policy: MycPolicyConfig,
     34     pub transport: MycTransportConfig,
     35 }
     36 
     37 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     38 #[serde(default, deny_unknown_fields)]
     39 pub struct MycServiceConfig {
     40     pub instance_name: String,
     41 }
     42 
     43 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     44 #[serde(default, deny_unknown_fields)]
     45 pub struct MycLoggingConfig {
     46     pub filter: String,
     47     pub output_dir: Option<PathBuf>,
     48     pub stdout: bool,
     49 }
     50 
     51 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     52 #[serde(default, deny_unknown_fields)]
     53 pub struct MycCustodyConfig {
     54     pub external_command_timeout_secs: u64,
     55 }
     56 
     57 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     58 #[serde(default, deny_unknown_fields)]
     59 pub struct MycPersistenceConfig {
     60     pub signer_state_backend: MycSignerStateBackend,
     61     pub runtime_audit_backend: MycRuntimeAuditBackend,
     62 }
     63 
     64 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     65 #[serde(default, deny_unknown_fields)]
     66 pub struct MycAuditConfig {
     67     pub default_read_limit: usize,
     68     pub max_active_file_bytes: u64,
     69     pub max_archived_files: usize,
     70 }
     71 
     72 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     73 #[serde(default, deny_unknown_fields)]
     74 pub struct MycObservabilityConfig {
     75     pub enabled: bool,
     76     pub bind_addr: SocketAddr,
     77 }
     78 
     79 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     80 #[serde(default, deny_unknown_fields)]
     81 pub struct MycDiscoveryConfig {
     82     pub enabled: bool,
     83     pub domain: Option<String>,
     84     pub handler_identifier: String,
     85     pub app_identity_backend: Option<MycIdentityBackend>,
     86     pub app_identity_path: Option<PathBuf>,
     87     pub app_identity_keyring_account_id: Option<String>,
     88     pub app_identity_keyring_service_name: Option<String>,
     89     pub app_identity_profile_path: Option<PathBuf>,
     90     pub public_relays: Vec<String>,
     91     pub publish_relays: Vec<String>,
     92     pub nostrconnect_url_template: Option<String>,
     93     pub nip05_output_path: Option<PathBuf>,
     94     pub metadata: MycDiscoveryMetadataConfig,
     95 }
     96 
     97 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     98 #[serde(default, deny_unknown_fields)]
     99 pub struct MycDiscoveryMetadataConfig {
    100     pub name: Option<String>,
    101     pub display_name: Option<String>,
    102     pub about: Option<String>,
    103     pub website: Option<String>,
    104     pub picture: Option<String>,
    105 }
    106 
    107 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    108 #[serde(default, deny_unknown_fields)]
    109 pub struct MycTransportConfig {
    110     pub enabled: bool,
    111     pub connect_timeout_secs: u64,
    112     pub relays: Vec<String>,
    113     pub delivery_policy: MycTransportDeliveryPolicy,
    114     pub delivery_quorum: Option<usize>,
    115     pub publish_max_attempts: usize,
    116     pub publish_initial_backoff_millis: u64,
    117     pub publish_max_backoff_millis: u64,
    118 }
    119 
    120 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    121 #[serde(rename_all = "snake_case")]
    122 pub enum MycConnectionApproval {
    123     NotRequired,
    124     ExplicitUser,
    125     Deny,
    126 }
    127 
    128 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    129 #[serde(rename_all = "snake_case")]
    130 pub enum MycIdentityBackend {
    131     EncryptedFile,
    132     HostVault,
    133     ManagedAccount,
    134     ExternalCommand,
    135     PlaintextFile,
    136 }
    137 
    138 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    139 #[serde(rename_all = "snake_case")]
    140 pub enum MycSignerStateBackend {
    141     JsonFile,
    142     Sqlite,
    143 }
    144 
    145 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    146 #[serde(rename_all = "snake_case")]
    147 pub enum MycRuntimeAuditBackend {
    148     JsonlFile,
    149     Sqlite,
    150 }
    151 
    152 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    153 pub struct MycIdentitySourceSpec {
    154     pub backend: MycIdentityBackend,
    155     #[serde(default, skip_serializing_if = "Option::is_none")]
    156     pub path: Option<PathBuf>,
    157     #[serde(default, skip_serializing_if = "Option::is_none")]
    158     pub keyring_account_id: Option<String>,
    159     #[serde(default, skip_serializing_if = "Option::is_none")]
    160     pub keyring_service_name: Option<String>,
    161     #[serde(default, skip_serializing_if = "Option::is_none")]
    162     pub profile_path: Option<PathBuf>,
    163 }
    164 
    165 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    166 pub struct MycRuntimeContractOutput {
    167     pub active_profile: MycPathProfile,
    168     pub allowed_profiles: Vec<MycPathProfile>,
    169     pub default_shared_secret_backend: MycIdentityBackend,
    170     pub allowed_shared_secret_backends: Vec<MycIdentityBackend>,
    171     #[serde(default, skip_serializing_if = "Vec::is_empty")]
    172     pub runtime_specific_custody_modes: Vec<String>,
    173     #[serde(default, skip_serializing_if = "Option::is_none")]
    174     pub host_vault_policy: Option<String>,
    175     pub path_overrides: MycRuntimePathOverrideContractOutput,
    176     pub migration: MycRuntimeMigrationContractOutput,
    177 }
    178 
    179 pub type MycRuntimePathOverrideContractOutput = RadrootsRuntimePathPolicyContract;
    180 pub type MycRuntimeMigrationContractOutput = RadrootsRuntimeMigrationContract;
    181 pub type MycRuntimeLegacyPathOutput = RadrootsRuntimeLegacyPathContract;
    182 
    183 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
    184 #[serde(rename_all = "snake_case")]
    185 pub enum MycTransportDeliveryPolicy {
    186     Any,
    187     Quorum,
    188     All,
    189 }
    190 
    191 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
    192 #[serde(default, deny_unknown_fields)]
    193 pub struct MycPolicyConfig {
    194     pub connection_approval: MycConnectionApproval,
    195     pub trusted_client_pubkeys: Vec<String>,
    196     pub denied_client_pubkeys: Vec<String>,
    197     pub permission_ceiling: RadrootsNostrConnectPermissions,
    198     pub allowed_sign_event_kinds: Vec<u16>,
    199     pub auth_url: Option<String>,
    200     pub auth_pending_ttl_secs: u64,
    201     pub auth_authorized_ttl_secs: Option<u64>,
    202     pub reauth_after_inactivity_secs: Option<u64>,
    203     pub connect_rate_limit_window_secs: Option<u64>,
    204     pub connect_rate_limit_max_attempts: Option<usize>,
    205     pub auth_challenge_rate_limit_window_secs: Option<u64>,
    206     pub auth_challenge_rate_limit_max_attempts: Option<usize>,
    207 }
    208 
    209 impl Default for MycConfig {
    210     fn default() -> Self {
    211         Self::default_with_path_selection(
    212             &RadrootsPathResolver::current(),
    213             MycPathProfile::InteractiveUser,
    214             None,
    215         )
    216         .expect("current process should resolve myc runtime paths")
    217     }
    218 }
    219 
    220 impl Default for MycServiceConfig {
    221     fn default() -> Self {
    222         Self {
    223             instance_name: "myc".to_owned(),
    224         }
    225     }
    226 }
    227 
    228 impl Default for MycLoggingConfig {
    229     fn default() -> Self {
    230         Self {
    231             filter: "info,myc=info".to_owned(),
    232             output_dir: None,
    233             stdout: true,
    234         }
    235     }
    236 }
    237 
    238 impl Default for MycCustodyConfig {
    239     fn default() -> Self {
    240         Self {
    241             external_command_timeout_secs: 10,
    242         }
    243     }
    244 }
    245 
    246 impl Default for MycTransportConfig {
    247     fn default() -> Self {
    248         Self {
    249             enabled: false,
    250             connect_timeout_secs: 10,
    251             relays: Vec::new(),
    252             delivery_policy: MycTransportDeliveryPolicy::Any,
    253             delivery_quorum: None,
    254             publish_max_attempts: 1,
    255             publish_initial_backoff_millis: 250,
    256             publish_max_backoff_millis: 2_000,
    257         }
    258     }
    259 }
    260 
    261 impl Default for MycPersistenceConfig {
    262     fn default() -> Self {
    263         Self {
    264             signer_state_backend: MycSignerStateBackend::JsonFile,
    265             runtime_audit_backend: MycRuntimeAuditBackend::JsonlFile,
    266         }
    267     }
    268 }
    269 
    270 impl Default for MycAuditConfig {
    271     fn default() -> Self {
    272         Self {
    273             default_read_limit: 200,
    274             max_active_file_bytes: 262_144,
    275             max_archived_files: 8,
    276         }
    277     }
    278 }
    279 
    280 impl Default for MycObservabilityConfig {
    281     fn default() -> Self {
    282         Self {
    283             enabled: false,
    284             bind_addr: "127.0.0.1:9460"
    285                 .parse()
    286                 .expect("default observability bind addr"),
    287         }
    288     }
    289 }
    290 
    291 impl Default for MycDiscoveryConfig {
    292     fn default() -> Self {
    293         Self {
    294             enabled: false,
    295             domain: None,
    296             handler_identifier: "myc".to_owned(),
    297             app_identity_backend: None,
    298             app_identity_path: None,
    299             app_identity_keyring_account_id: None,
    300             app_identity_keyring_service_name: None,
    301             app_identity_profile_path: None,
    302             public_relays: Vec::new(),
    303             publish_relays: Vec::new(),
    304             nostrconnect_url_template: None,
    305             nip05_output_path: None,
    306             metadata: MycDiscoveryMetadataConfig::default(),
    307         }
    308     }
    309 }
    310 
    311 impl Default for MycDiscoveryMetadataConfig {
    312     fn default() -> Self {
    313         Self {
    314             name: None,
    315             display_name: None,
    316             about: None,
    317             website: None,
    318             picture: None,
    319         }
    320     }
    321 }
    322 
    323 impl Default for MycPolicyConfig {
    324     fn default() -> Self {
    325         Self {
    326             connection_approval: MycConnectionApproval::ExplicitUser,
    327             trusted_client_pubkeys: Vec::new(),
    328             denied_client_pubkeys: Vec::new(),
    329             permission_ceiling: RadrootsNostrConnectPermissions::default(),
    330             allowed_sign_event_kinds: Vec::new(),
    331             auth_url: None,
    332             auth_pending_ttl_secs: 900,
    333             auth_authorized_ttl_secs: None,
    334             reauth_after_inactivity_secs: None,
    335             connect_rate_limit_window_secs: None,
    336             connect_rate_limit_max_attempts: None,
    337             auth_challenge_rate_limit_window_secs: None,
    338             auth_challenge_rate_limit_max_attempts: None,
    339         }
    340     }
    341 }
    342 
    343 impl Default for MycIdentityBackend {
    344     fn default() -> Self {
    345         Self::EncryptedFile
    346     }
    347 }
    348 
    349 impl MycConnectionApproval {
    350     pub fn into_signer_approval_requirement(self) -> RadrootsNostrSignerApprovalRequirement {
    351         match self {
    352             Self::NotRequired => RadrootsNostrSignerApprovalRequirement::NotRequired,
    353             Self::ExplicitUser | Self::Deny => RadrootsNostrSignerApprovalRequirement::ExplicitUser,
    354         }
    355     }
    356 }
    357 
    358 impl MycTransportDeliveryPolicy {
    359     pub fn as_str(self) -> &'static str {
    360         match self {
    361             Self::Any => "any",
    362             Self::Quorum => "quorum",
    363             Self::All => "all",
    364         }
    365     }
    366 }
    367 
    368 impl MycIdentityBackend {
    369     pub fn as_str(self) -> &'static str {
    370         match self {
    371             Self::EncryptedFile => "encrypted_file",
    372             Self::HostVault => "host_vault",
    373             Self::ManagedAccount => "managed_account",
    374             Self::ExternalCommand => "external_command",
    375             Self::PlaintextFile => "plaintext_file",
    376         }
    377     }
    378 }
    379 
    380 const MYC_ALLOWED_PROFILES: [MycPathProfile; 3] = [
    381     MycPathProfile::InteractiveUser,
    382     MycPathProfile::ServiceHost,
    383     MycPathProfile::RepoLocal,
    384 ];
    385 const MYC_ALLOWED_SHARED_SECRET_BACKENDS: [MycIdentityBackend; 4] = [
    386     MycIdentityBackend::EncryptedFile,
    387     MycIdentityBackend::HostVault,
    388     MycIdentityBackend::ExternalCommand,
    389     MycIdentityBackend::PlaintextFile,
    390 ];
    391 const MYC_RUNTIME_SPECIFIC_CUSTODY_MODES: [&str; 1] = ["managed_account"];
    392 const MYC_DEFAULT_SHARED_SECRET_BACKEND: MycIdentityBackend = MycIdentityBackend::EncryptedFile;
    393 const MYC_HOST_VAULT_POLICY: &str = "desktop";
    394 const MYC_CANONICAL_ROOT_SELECTION: &str = "profile_root_env_or_repo_wrapper";
    395 const MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE: &str = "config_artifact";
    396 const MYC_LEAF_PATH_ENV_POSTURE: &str = "compatibility_break_glass";
    397 const MYC_MIGRATION_IMPORT_HINT: &str = "stop myc, inspect this legacy path, then run an explicit backup/restore, custody import, or manual copy into the canonical destination; myc will not move it on startup";
    398 const MYC_COMPATIBILITY_LEAF_PATH_KEYS: [&str; 6] = [
    399     "MYC_LOGGING_OUTPUT_DIR",
    400     "MYC_PATHS_STATE_DIR",
    401     "MYC_IDENTITY_SIGNER_PATH",
    402     "MYC_IDENTITY_USER_PATH",
    403     "MYC_IDENTITY_DISCOVERY_APP_PATH",
    404     "MYC_DISCOVERY_NIP05_OUTPUT_PATH",
    405 ];
    406 
    407 impl MycRuntimeContractOutput {
    408     pub fn for_active_profile(active_profile: MycPathProfile) -> Self {
    409         Self {
    410             active_profile,
    411             allowed_profiles: MYC_ALLOWED_PROFILES.to_vec(),
    412             default_shared_secret_backend: MYC_DEFAULT_SHARED_SECRET_BACKEND,
    413             allowed_shared_secret_backends: MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec(),
    414             runtime_specific_custody_modes: MYC_RUNTIME_SPECIFIC_CUSTODY_MODES
    415                 .into_iter()
    416                 .map(str::to_owned)
    417                 .collect(),
    418             host_vault_policy: Some(MYC_HOST_VAULT_POLICY.to_owned()),
    419             path_overrides: RadrootsRuntimePathPolicyContract::new(
    420                 MYC_CANONICAL_ROOT_SELECTION,
    421                 MYC_CANONICAL_SUBORDINATE_PATH_OVERRIDE,
    422                 MYC_LEAF_PATH_ENV_POSTURE,
    423                 &MYC_COMPATIBILITY_LEAF_PATH_KEYS,
    424             ),
    425             migration: runtime_migration_contract(RadrootsMigrationReport::empty()),
    426         }
    427     }
    428 }
    429 
    430 impl MycSignerStateBackend {
    431     pub fn as_str(self) -> &'static str {
    432         match self {
    433             Self::JsonFile => "json_file",
    434             Self::Sqlite => "sqlite",
    435         }
    436     }
    437 }
    438 
    439 impl MycRuntimeAuditBackend {
    440     pub fn as_str(self) -> &'static str {
    441         match self {
    442             Self::JsonlFile => "jsonl_file",
    443             Self::Sqlite => "sqlite",
    444         }
    445     }
    446 }
    447 
    448 impl MycConfig {
    449     pub fn allowed_profiles() -> Vec<MycPathProfile> {
    450         MYC_ALLOWED_PROFILES.to_vec()
    451     }
    452 
    453     pub fn default_shared_secret_backend() -> MycIdentityBackend {
    454         MYC_DEFAULT_SHARED_SECRET_BACKEND
    455     }
    456 
    457     pub fn allowed_shared_secret_backends() -> Vec<MycIdentityBackend> {
    458         MYC_ALLOWED_SHARED_SECRET_BACKENDS.to_vec()
    459     }
    460 
    461     pub fn runtime_specific_custody_modes() -> Vec<String> {
    462         MYC_RUNTIME_SPECIFIC_CUSTODY_MODES
    463             .into_iter()
    464             .map(str::to_owned)
    465             .collect()
    466     }
    467 
    468     pub fn host_vault_policy() -> Option<String> {
    469         Some(MYC_HOST_VAULT_POLICY.to_owned())
    470     }
    471 
    472     pub fn runtime_contract_output(&self) -> MycRuntimeContractOutput {
    473         let mut output = MycRuntimeContractOutput::for_active_profile(self.paths.profile);
    474         output.migration =
    475             runtime_migration_contract(inspect_legacy_paths(self.legacy_path_candidates()));
    476         output
    477     }
    478 
    479     fn legacy_path_candidates(&self) -> Vec<RadrootsLegacyPathCandidate> {
    480         vec![
    481             RadrootsLegacyPathCandidate::new(
    482                 "myc_repo_var_v0",
    483                 "legacy myc repo-relative var directory",
    484                 PathBuf::from("var"),
    485                 Some(self.paths.state_dir.clone()),
    486                 MYC_MIGRATION_IMPORT_HINT,
    487             ),
    488             RadrootsLegacyPathCandidate::new(
    489                 "myc_service_var_lib_v0",
    490                 "legacy myc service state root",
    491                 PathBuf::from("/var/lib/myc"),
    492                 Some(self.paths.state_dir.clone()),
    493                 MYC_MIGRATION_IMPORT_HINT,
    494             ),
    495         ]
    496     }
    497 
    498     fn default_with_path_selection(
    499         resolver: &RadrootsPathResolver,
    500         profile: MycPathProfile,
    501         repo_local_root: Option<&Path>,
    502     ) -> Result<Self, MycError> {
    503         let mut config = Self {
    504             service: MycServiceConfig::default(),
    505             logging: MycLoggingConfig::default(),
    506             custody: MycCustodyConfig::default(),
    507             paths: MycPathsConfig::default_with_path_selection(resolver, profile, repo_local_root)?,
    508             persistence: MycPersistenceConfig::default(),
    509             audit: MycAuditConfig::default(),
    510             observability: MycObservabilityConfig::default(),
    511             discovery: MycDiscoveryConfig::default(),
    512             policy: MycPolicyConfig::default(),
    513             transport: MycTransportConfig::default(),
    514         };
    515         crate::paths::apply_path_defaults(&mut config, resolver, &MycPathOverrideFlags::default())?;
    516         Ok(config)
    517     }
    518 
    519     fn process_path_selection() -> Result<(MycPathProfile, Option<PathBuf>), MycError> {
    520         crate::paths::process_path_selection()
    521     }
    522 
    523     fn default_env_path_with_path_selection(
    524         resolver: &RadrootsPathResolver,
    525         profile: MycPathProfile,
    526         repo_local_root: Option<&Path>,
    527     ) -> Result<PathBuf, MycError> {
    528         crate::paths::default_env_path_with_path_selection(resolver, profile, repo_local_root)
    529     }
    530 
    531     pub fn load_from_default_env_path() -> Result<Self, MycError> {
    532         let resolver = RadrootsPathResolver::current();
    533         let (profile, repo_local_root) = Self::process_path_selection()?;
    534         let path = Self::default_env_path_with_path_selection(
    535             &resolver,
    536             profile,
    537             repo_local_root.as_deref(),
    538         )?;
    539         Self::load_from_env_path_with_resolver(path, &resolver)
    540     }
    541 
    542     pub fn load_from_env_path(path: impl AsRef<Path>) -> Result<Self, MycError> {
    543         Self::load_from_env_path_with_resolver(path, &RadrootsPathResolver::current())
    544     }
    545 
    546     fn load_from_env_path_with_resolver(
    547         path: impl AsRef<Path>,
    548         resolver: &RadrootsPathResolver,
    549     ) -> Result<Self, MycError> {
    550         let path = path.as_ref();
    551         let value = fs::read_to_string(path).map_err(|source| MycError::ConfigIo {
    552             path: path.to_path_buf(),
    553             source,
    554         })?;
    555         Self::from_env_str_with_source_and_resolver(&value, path, resolver)
    556     }
    557 
    558     pub fn from_env_str(value: &str) -> Result<Self, MycError> {
    559         Self::from_env_str_with_source_and_resolver(
    560             value,
    561             Path::new("<inline>"),
    562             &RadrootsPathResolver::current(),
    563         )
    564     }
    565 
    566     pub fn to_env_string(&self) -> Result<String, MycError> {
    567         self.validate()?;
    568 
    569         let mut lines = Vec::new();
    570         push_env_line(
    571             &mut lines,
    572             "MYC_SERVICE_INSTANCE_NAME",
    573             self.service.instance_name.as_str(),
    574         );
    575         push_env_line(
    576             &mut lines,
    577             "MYC_LOGGING_FILTER",
    578             self.logging.filter.as_str(),
    579         );
    580         push_optional_path_env_line(
    581             &mut lines,
    582             "MYC_LOGGING_OUTPUT_DIR",
    583             self.logging.output_dir.as_ref(),
    584         );
    585         push_env_line(
    586             &mut lines,
    587             "MYC_LOGGING_STDOUT",
    588             self.logging.stdout.to_string(),
    589         );
    590         push_env_line(
    591             &mut lines,
    592             "MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS",
    593             self.custody.external_command_timeout_secs.to_string(),
    594         );
    595         push_env_line(&mut lines, "MYC_PATHS_PROFILE", self.paths.profile.as_str());
    596         push_optional_path_env_line(
    597             &mut lines,
    598             "MYC_PATHS_REPO_LOCAL_ROOT",
    599             self.paths.repo_local_root.as_ref(),
    600         );
    601         push_env_line(
    602             &mut lines,
    603             "MYC_PATHS_STATE_DIR",
    604             self.paths.state_dir.display().to_string(),
    605         );
    606         push_env_line(
    607             &mut lines,
    608             "MYC_IDENTITY_SIGNER_BACKEND",
    609             self.paths.signer_identity_backend.as_str(),
    610         );
    611         push_env_line(
    612             &mut lines,
    613             "MYC_IDENTITY_SIGNER_PATH",
    614             self.paths.signer_identity_path.display().to_string(),
    615         );
    616         push_optional_string_env_line(
    617             &mut lines,
    618             "MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID",
    619             self.paths.signer_identity_keyring_account_id.as_deref(),
    620         );
    621         push_env_line(
    622             &mut lines,
    623             "MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME",
    624             self.paths.signer_identity_keyring_service_name.as_str(),
    625         );
    626         push_optional_path_env_line(
    627             &mut lines,
    628             "MYC_IDENTITY_SIGNER_PROFILE_PATH",
    629             self.paths.signer_identity_profile_path.as_ref(),
    630         );
    631         push_env_line(
    632             &mut lines,
    633             "MYC_IDENTITY_USER_BACKEND",
    634             self.paths.user_identity_backend.as_str(),
    635         );
    636         push_env_line(
    637             &mut lines,
    638             "MYC_IDENTITY_USER_PATH",
    639             self.paths.user_identity_path.display().to_string(),
    640         );
    641         push_optional_string_env_line(
    642             &mut lines,
    643             "MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID",
    644             self.paths.user_identity_keyring_account_id.as_deref(),
    645         );
    646         push_env_line(
    647             &mut lines,
    648             "MYC_IDENTITY_USER_KEYRING_SERVICE_NAME",
    649             self.paths.user_identity_keyring_service_name.as_str(),
    650         );
    651         push_optional_path_env_line(
    652             &mut lines,
    653             "MYC_IDENTITY_USER_PROFILE_PATH",
    654             self.paths.user_identity_profile_path.as_ref(),
    655         );
    656         push_env_line(
    657             &mut lines,
    658             "MYC_PERSISTENCE_SIGNER_STATE_BACKEND",
    659             self.persistence.signer_state_backend.as_str(),
    660         );
    661         push_env_line(
    662             &mut lines,
    663             "MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND",
    664             self.persistence.runtime_audit_backend.as_str(),
    665         );
    666         push_env_line(
    667             &mut lines,
    668             "MYC_AUDIT_DEFAULT_READ_LIMIT",
    669             self.audit.default_read_limit.to_string(),
    670         );
    671         push_env_line(
    672             &mut lines,
    673             "MYC_AUDIT_MAX_ACTIVE_FILE_BYTES",
    674             self.audit.max_active_file_bytes.to_string(),
    675         );
    676         push_env_line(
    677             &mut lines,
    678             "MYC_AUDIT_MAX_ARCHIVED_FILES",
    679             self.audit.max_archived_files.to_string(),
    680         );
    681         push_env_line(
    682             &mut lines,
    683             "MYC_OBSERVABILITY_ENABLED",
    684             self.observability.enabled.to_string(),
    685         );
    686         push_env_line(
    687             &mut lines,
    688             "MYC_OBSERVABILITY_BIND_ADDR",
    689             self.observability.bind_addr.to_string(),
    690         );
    691         push_env_line(
    692             &mut lines,
    693             "MYC_DISCOVERY_ENABLED",
    694             self.discovery.enabled.to_string(),
    695         );
    696         push_optional_string_env_line(
    697             &mut lines,
    698             "MYC_DISCOVERY_DOMAIN",
    699             self.discovery.domain.as_deref(),
    700         );
    701         push_env_line(
    702             &mut lines,
    703             "MYC_DISCOVERY_HANDLER_IDENTIFIER",
    704             self.discovery.handler_identifier.as_str(),
    705         );
    706         push_optional_string_env_line(
    707             &mut lines,
    708             "MYC_IDENTITY_DISCOVERY_APP_BACKEND",
    709             self.discovery
    710                 .app_identity_backend
    711                 .map(MycIdentityBackend::as_str),
    712         );
    713         push_optional_path_env_line(
    714             &mut lines,
    715             "MYC_IDENTITY_DISCOVERY_APP_PATH",
    716             self.discovery.app_identity_path.as_ref(),
    717         );
    718         push_optional_string_env_line(
    719             &mut lines,
    720             "MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID",
    721             self.discovery.app_identity_keyring_account_id.as_deref(),
    722         );
    723         push_optional_string_env_line(
    724             &mut lines,
    725             "MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME",
    726             self.discovery.app_identity_keyring_service_name.as_deref(),
    727         );
    728         push_optional_path_env_line(
    729             &mut lines,
    730             "MYC_IDENTITY_DISCOVERY_APP_PROFILE_PATH",
    731             self.discovery.app_identity_profile_path.as_ref(),
    732         );
    733         push_env_line(
    734             &mut lines,
    735             "MYC_DISCOVERY_PUBLIC_RELAY_URLS",
    736             self.discovery.public_relays.join(","),
    737         );
    738         push_env_line(
    739             &mut lines,
    740             "MYC_DISCOVERY_PUBLISH_RELAY_URLS",
    741             self.discovery.publish_relays.join(","),
    742         );
    743         push_optional_string_env_line(
    744             &mut lines,
    745             "MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE",
    746             self.discovery.nostrconnect_url_template.as_deref(),
    747         );
    748         push_optional_path_env_line(
    749             &mut lines,
    750             "MYC_DISCOVERY_NIP05_OUTPUT_PATH",
    751             self.discovery.nip05_output_path.as_ref(),
    752         );
    753         push_optional_string_env_line(
    754             &mut lines,
    755             "MYC_DISCOVERY_METADATA_NAME",
    756             self.discovery.metadata.name.as_deref(),
    757         );
    758         push_optional_string_env_line(
    759             &mut lines,
    760             "MYC_DISCOVERY_METADATA_DISPLAY_NAME",
    761             self.discovery.metadata.display_name.as_deref(),
    762         );
    763         push_optional_string_env_line(
    764             &mut lines,
    765             "MYC_DISCOVERY_METADATA_ABOUT",
    766             self.discovery.metadata.about.as_deref(),
    767         );
    768         push_optional_string_env_line(
    769             &mut lines,
    770             "MYC_DISCOVERY_METADATA_WEBSITE",
    771             self.discovery.metadata.website.as_deref(),
    772         );
    773         push_optional_string_env_line(
    774             &mut lines,
    775             "MYC_DISCOVERY_METADATA_PICTURE",
    776             self.discovery.metadata.picture.as_deref(),
    777         );
    778         push_env_line(
    779             &mut lines,
    780             "MYC_POLICY_CONNECTION_APPROVAL",
    781             match self.policy.connection_approval {
    782                 MycConnectionApproval::NotRequired => "not_required",
    783                 MycConnectionApproval::ExplicitUser => "explicit_user",
    784                 MycConnectionApproval::Deny => "deny",
    785             },
    786         );
    787         push_env_line(
    788             &mut lines,
    789             "MYC_POLICY_TRUSTED_CLIENT_PUBKEYS",
    790             self.policy.trusted_client_pubkeys.join(","),
    791         );
    792         push_env_line(
    793             &mut lines,
    794             "MYC_POLICY_DENIED_CLIENT_PUBKEYS",
    795             self.policy.denied_client_pubkeys.join(","),
    796         );
    797         push_env_line(
    798             &mut lines,
    799             "MYC_POLICY_PERMISSION_CEILING",
    800             self.policy.permission_ceiling.to_string(),
    801         );
    802         push_env_line(
    803             &mut lines,
    804             "MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS",
    805             self.policy
    806                 .allowed_sign_event_kinds
    807                 .iter()
    808                 .map(u16::to_string)
    809                 .collect::<Vec<_>>()
    810                 .join(","),
    811         );
    812         push_optional_string_env_line(
    813             &mut lines,
    814             "MYC_POLICY_AUTH_URL",
    815             self.policy.auth_url.as_deref(),
    816         );
    817         push_env_line(
    818             &mut lines,
    819             "MYC_POLICY_AUTH_PENDING_TTL_SECS",
    820             self.policy.auth_pending_ttl_secs.to_string(),
    821         );
    822         push_optional_u64_env_line(
    823             &mut lines,
    824             "MYC_POLICY_AUTHORIZED_TTL_SECS",
    825             self.policy.auth_authorized_ttl_secs,
    826         );
    827         push_optional_u64_env_line(
    828             &mut lines,
    829             "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS",
    830             self.policy.reauth_after_inactivity_secs,
    831         );
    832         push_optional_u64_env_line(
    833             &mut lines,
    834             "MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS",
    835             self.policy.connect_rate_limit_window_secs,
    836         );
    837         push_optional_usize_env_line(
    838             &mut lines,
    839             "MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS",
    840             self.policy.connect_rate_limit_max_attempts,
    841         );
    842         push_optional_u64_env_line(
    843             &mut lines,
    844             "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS",
    845             self.policy.auth_challenge_rate_limit_window_secs,
    846         );
    847         push_optional_usize_env_line(
    848             &mut lines,
    849             "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS",
    850             self.policy.auth_challenge_rate_limit_max_attempts,
    851         );
    852         push_env_line(
    853             &mut lines,
    854             "MYC_TRANSPORT_ENABLED",
    855             self.transport.enabled.to_string(),
    856         );
    857         push_env_line(
    858             &mut lines,
    859             "MYC_TRANSPORT_CONNECT_TIMEOUT_SECS",
    860             self.transport.connect_timeout_secs.to_string(),
    861         );
    862         push_env_line(
    863             &mut lines,
    864             "MYC_TRANSPORT_RELAY_URLS",
    865             self.transport.relays.join(","),
    866         );
    867         push_env_line(
    868             &mut lines,
    869             "MYC_TRANSPORT_DELIVERY_POLICY",
    870             self.transport.delivery_policy.as_str(),
    871         );
    872         push_optional_usize_env_line(
    873             &mut lines,
    874             "MYC_TRANSPORT_DELIVERY_QUORUM",
    875             self.transport.delivery_quorum,
    876         );
    877         push_env_line(
    878             &mut lines,
    879             "MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS",
    880             self.transport.publish_max_attempts.to_string(),
    881         );
    882         push_env_line(
    883             &mut lines,
    884             "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS",
    885             self.transport.publish_initial_backoff_millis.to_string(),
    886         );
    887         push_env_line(
    888             &mut lines,
    889             "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS",
    890             self.transport.publish_max_backoff_millis.to_string(),
    891         );
    892 
    893         Ok(lines.join("\n") + "\n")
    894     }
    895 
    896     pub fn validate(&self) -> Result<(), MycError> {
    897         if self.service.instance_name.trim().is_empty() {
    898             return Err(MycError::InvalidConfig(
    899                 "service.instance_name must not be empty".to_owned(),
    900             ));
    901         }
    902 
    903         if self.logging.filter.trim().is_empty() {
    904             return Err(MycError::InvalidConfig(
    905                 "logging.filter must not be empty".to_owned(),
    906             ));
    907         }
    908 
    909         EnvFilter::try_new(self.logging.filter.clone()).map_err(|source| {
    910             MycError::InvalidLogFilter {
    911                 filter: self.logging.filter.clone(),
    912                 source,
    913             }
    914         })?;
    915 
    916         if let Some(output_dir) = self.logging.output_dir.as_ref() {
    917             if output_dir.as_os_str().is_empty() {
    918                 return Err(MycError::InvalidConfig(
    919                     "logging.output_dir must not be empty when set".to_owned(),
    920                 ));
    921             }
    922         }
    923 
    924         if self.paths.state_dir.as_os_str().is_empty() {
    925             return Err(MycError::InvalidConfig(
    926                 "paths.state_dir must not be empty".to_owned(),
    927             ));
    928         }
    929 
    930         if self.custody.external_command_timeout_secs == 0 {
    931             return Err(MycError::InvalidConfig(
    932                 "custody.external_command_timeout_secs must be greater than zero".to_owned(),
    933             ));
    934         }
    935 
    936         validate_identity_source_config(
    937             "paths.signer_identity",
    938             &self.paths.signer_identity_source(),
    939         )?;
    940         validate_identity_source_config("paths.user_identity", &self.paths.user_identity_source())?;
    941 
    942         if self.audit.default_read_limit == 0 {
    943             return Err(MycError::InvalidConfig(
    944                 "audit.default_read_limit must be greater than zero".to_owned(),
    945             ));
    946         }
    947 
    948         if self.audit.max_active_file_bytes == 0 {
    949             return Err(MycError::InvalidConfig(
    950                 "audit.max_active_file_bytes must be greater than zero".to_owned(),
    951             ));
    952         }
    953 
    954         if !self.observability.bind_addr.ip().is_loopback() {
    955             return Err(MycError::InvalidConfig(
    956                 "observability.bind_addr must use a loopback address".to_owned(),
    957             ));
    958         }
    959 
    960         self.discovery.validate(&self.transport)?;
    961 
    962         if self.transport.connect_timeout_secs == 0 {
    963             return Err(MycError::InvalidConfig(
    964                 "transport.connect_timeout_secs must be greater than zero".to_owned(),
    965             ));
    966         }
    967 
    968         if self.transport.publish_max_attempts == 0 {
    969             return Err(MycError::InvalidConfig(
    970                 "transport.publish_max_attempts must be greater than zero".to_owned(),
    971             ));
    972         }
    973 
    974         if self.transport.publish_initial_backoff_millis == 0 {
    975             return Err(MycError::InvalidConfig(
    976                 "transport.publish_initial_backoff_millis must be greater than zero".to_owned(),
    977             ));
    978         }
    979 
    980         if self.transport.publish_max_backoff_millis == 0 {
    981             return Err(MycError::InvalidConfig(
    982                 "transport.publish_max_backoff_millis must be greater than zero".to_owned(),
    983             ));
    984         }
    985 
    986         if self.transport.publish_initial_backoff_millis > self.transport.publish_max_backoff_millis
    987         {
    988             return Err(MycError::InvalidConfig(
    989                 "transport.publish_max_backoff_millis must be greater than or equal to transport.publish_initial_backoff_millis"
    990                     .to_owned(),
    991             ));
    992         }
    993 
    994         if self.policy.auth_pending_ttl_secs == 0 {
    995             return Err(MycError::InvalidConfig(
    996                 "policy.auth_pending_ttl_secs must be greater than zero".to_owned(),
    997             ));
    998         }
    999         if self
   1000             .policy
   1001             .auth_authorized_ttl_secs
   1002             .is_some_and(|ttl| ttl == 0)
   1003         {
   1004             return Err(MycError::InvalidConfig(
   1005                 "policy.auth_authorized_ttl_secs must be greater than zero when set".to_owned(),
   1006             ));
   1007         }
   1008         if self
   1009             .policy
   1010             .reauth_after_inactivity_secs
   1011             .is_some_and(|ttl| ttl == 0)
   1012         {
   1013             return Err(MycError::InvalidConfig(
   1014                 "policy.reauth_after_inactivity_secs must be greater than zero when set".to_owned(),
   1015             ));
   1016         }
   1017         if (self.policy.auth_authorized_ttl_secs.is_some()
   1018             || self.policy.reauth_after_inactivity_secs.is_some())
   1019             && self.policy.auth_url.is_none()
   1020         {
   1021             return Err(MycError::InvalidConfig(
   1022                 "policy.auth_url must be set when automatic auth TTL policy is configured"
   1023                     .to_owned(),
   1024             ));
   1025         }
   1026         validate_optional_rate_limit(
   1027             "policy.connect_rate_limit",
   1028             self.policy.connect_rate_limit_window_secs,
   1029             self.policy.connect_rate_limit_max_attempts,
   1030         )?;
   1031         validate_optional_rate_limit(
   1032             "policy.auth_challenge_rate_limit",
   1033             self.policy.auth_challenge_rate_limit_window_secs,
   1034             self.policy.auth_challenge_rate_limit_max_attempts,
   1035         )?;
   1036 
   1037         let trusted_client_pubkeys =
   1038             normalize_policy_client_pubkeys(&self.policy.trusted_client_pubkeys)?;
   1039         let denied_client_pubkeys =
   1040             normalize_policy_client_pubkeys(&self.policy.denied_client_pubkeys)?;
   1041         let overlap = trusted_client_pubkeys
   1042             .intersection(&denied_client_pubkeys)
   1043             .cloned()
   1044             .collect::<Vec<_>>();
   1045         if !overlap.is_empty() {
   1046             return Err(MycError::InvalidConfig(format!(
   1047                 "policy trusted and denied client pubkeys overlap: {}",
   1048                 overlap.join(", ")
   1049             )));
   1050         }
   1051 
   1052         match self.transport.delivery_policy {
   1053             MycTransportDeliveryPolicy::Quorum => {
   1054                 let Some(delivery_quorum) = self.transport.delivery_quorum else {
   1055                     return Err(MycError::InvalidConfig(
   1056                         "transport.delivery_quorum must be set when transport.delivery_policy is `quorum`"
   1057                             .to_owned(),
   1058                     ));
   1059                 };
   1060                 if delivery_quorum == 0 {
   1061                     return Err(MycError::InvalidConfig(
   1062                         "transport.delivery_quorum must be greater than zero".to_owned(),
   1063                     ));
   1064                 }
   1065             }
   1066             MycTransportDeliveryPolicy::Any | MycTransportDeliveryPolicy::All => {
   1067                 if self.transport.delivery_quorum.is_some() {
   1068                     return Err(MycError::InvalidConfig(
   1069                         "transport.delivery_quorum is only valid when transport.delivery_policy is `quorum`"
   1070                             .to_owned(),
   1071                     ));
   1072                 }
   1073             }
   1074         }
   1075 
   1076         let parsed_relays = self.transport.parse_relays()?;
   1077         if self.transport.enabled && parsed_relays.is_empty() {
   1078             return Err(MycError::InvalidConfig(
   1079                 "transport.relays must not be empty when transport.enabled is true".to_owned(),
   1080             ));
   1081         }
   1082 
   1083         Ok(())
   1084     }
   1085 
   1086     fn from_env_str_with_source_and_resolver(
   1087         value: &str,
   1088         path: &Path,
   1089         resolver: &RadrootsPathResolver,
   1090     ) -> Result<Self, MycError> {
   1091         let entries = parse_env_entries(value, path)?;
   1092         let (profile, repo_local_root) =
   1093             crate::paths::path_selection_from_entries(entries.as_slice(), path)?;
   1094         let mut config =
   1095             Self::default_with_path_selection(resolver, profile, repo_local_root.as_deref())?;
   1096         let mut path_overrides = MycPathOverrideFlags::default();
   1097         for (key, value, line_number) in entries {
   1098             apply_env_entry(
   1099                 &mut config,
   1100                 &mut path_overrides,
   1101                 key.as_str(),
   1102                 value.as_str(),
   1103                 path,
   1104                 line_number,
   1105             )?;
   1106         }
   1107         crate::paths::apply_path_defaults(&mut config, resolver, &path_overrides)?;
   1108         config.validate()?;
   1109         Ok(config)
   1110     }
   1111 }
   1112 
   1113 fn push_env_line(lines: &mut Vec<String>, key: &str, value: impl ToString) {
   1114     lines.push(format!("{key}={}", value.to_string()));
   1115 }
   1116 
   1117 fn push_optional_string_env_line(lines: &mut Vec<String>, key: &str, value: Option<&str>) {
   1118     if let Some(value) = value {
   1119         push_env_line(lines, key, value);
   1120     }
   1121 }
   1122 
   1123 fn push_optional_path_env_line(lines: &mut Vec<String>, key: &str, value: Option<&PathBuf>) {
   1124     if let Some(value) = value {
   1125         push_env_line(lines, key, value.display().to_string());
   1126     }
   1127 }
   1128 
   1129 fn push_optional_u64_env_line(lines: &mut Vec<String>, key: &str, value: Option<u64>) {
   1130     if let Some(value) = value {
   1131         push_env_line(lines, key, value.to_string());
   1132     }
   1133 }
   1134 
   1135 fn push_optional_usize_env_line(lines: &mut Vec<String>, key: &str, value: Option<usize>) {
   1136     if let Some(value) = value {
   1137         push_env_line(lines, key, value.to_string());
   1138     }
   1139 }
   1140 
   1141 fn parse_env_entries(value: &str, path: &Path) -> Result<Vec<(String, String, usize)>, MycError> {
   1142     let mut seen = BTreeSet::new();
   1143     let mut entries = Vec::new();
   1144 
   1145     for (index, raw_line) in value.lines().enumerate() {
   1146         let line_number = index + 1;
   1147         let line = raw_line.trim();
   1148         if line.is_empty() || line.starts_with('#') {
   1149             continue;
   1150         }
   1151 
   1152         let Some((key_raw, value_raw)) = raw_line.split_once('=') else {
   1153             return Err(config_parse_error(
   1154                 path,
   1155                 line_number,
   1156                 "expected KEY=VALUE assignment",
   1157             ));
   1158         };
   1159         let key = key_raw.trim();
   1160         if key.is_empty() {
   1161             return Err(config_parse_error(
   1162                 path,
   1163                 line_number,
   1164                 "environment variable name must not be empty",
   1165             ));
   1166         }
   1167         if !key.chars().all(|character| {
   1168             character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
   1169         }) {
   1170             return Err(config_parse_error(
   1171                 path,
   1172                 line_number,
   1173                 format!("invalid environment variable name `{key}`"),
   1174             ));
   1175         }
   1176         if !seen.insert(key.to_owned()) {
   1177             return Err(config_parse_error(
   1178                 path,
   1179                 line_number,
   1180                 format!("duplicate environment variable `{key}`"),
   1181             ));
   1182         }
   1183         entries.push((
   1184             key.to_owned(),
   1185             parse_env_value(value_raw.trim(), path, line_number)?,
   1186             line_number,
   1187         ));
   1188     }
   1189 
   1190     Ok(entries)
   1191 }
   1192 
   1193 fn parse_env_value(value: &str, path: &Path, line_number: usize) -> Result<String, MycError> {
   1194     if value.starts_with('"') || value.starts_with('\'') {
   1195         let quote = value.chars().next().expect("quoted env value prefix");
   1196         if !value.ends_with(quote) || value.len() < 2 {
   1197             return Err(config_parse_error(
   1198                 path,
   1199                 line_number,
   1200                 "unterminated quoted environment value",
   1201             ));
   1202         }
   1203         return Ok(value[1..value.len() - 1].to_owned());
   1204     }
   1205     Ok(value.to_owned())
   1206 }
   1207 
   1208 fn apply_env_entry(
   1209     config: &mut MycConfig,
   1210     path_overrides: &mut MycPathOverrideFlags,
   1211     key: &str,
   1212     value: &str,
   1213     path: &Path,
   1214     line_number: usize,
   1215 ) -> Result<(), MycError> {
   1216     match key {
   1217         "MYC_SERVICE_INSTANCE_NAME" => config.service.instance_name = value.to_owned(),
   1218         "MYC_LOGGING_FILTER" => config.logging.filter = value.to_owned(),
   1219         "MYC_LOGGING_OUTPUT_DIR" => {
   1220             config.logging.output_dir = parse_optional_path_env(value);
   1221             path_overrides.logging_output_dir = true;
   1222         }
   1223         "MYC_LOGGING_STDOUT" => {
   1224             config.logging.stdout = parse_bool_env(key, value, path, line_number)?;
   1225         }
   1226         "MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS" => {
   1227             config.custody.external_command_timeout_secs =
   1228                 parse_u64_env(key, value, path, line_number)?;
   1229         }
   1230         "MYC_PATHS_PROFILE" => {
   1231             config.paths.profile =
   1232                 crate::paths::parse_path_profile_env(key, value, path, line_number)?;
   1233         }
   1234         "MYC_PATHS_REPO_LOCAL_ROOT" => {
   1235             config.paths.repo_local_root = parse_optional_path_env(value);
   1236         }
   1237         "MYC_PATHS_STATE_DIR" => {
   1238             config.paths.state_dir = PathBuf::from(value);
   1239             path_overrides.state_dir = true;
   1240         }
   1241         "MYC_IDENTITY_SIGNER_BACKEND" => {
   1242             config.paths.signer_identity_backend =
   1243                 parse_identity_backend_env(key, value, path, line_number)?;
   1244         }
   1245         "MYC_IDENTITY_SIGNER_PATH" => {
   1246             config.paths.signer_identity_path = PathBuf::from(value);
   1247             path_overrides.signer_identity_path = true;
   1248         }
   1249         "MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID" => {
   1250             config.paths.signer_identity_keyring_account_id = parse_optional_string_env(value);
   1251         }
   1252         "MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME" => {
   1253             config.paths.signer_identity_keyring_service_name = value.to_owned();
   1254         }
   1255         "MYC_IDENTITY_SIGNER_PROFILE_PATH" => {
   1256             config.paths.signer_identity_profile_path = parse_optional_path_env(value);
   1257         }
   1258         "MYC_IDENTITY_USER_BACKEND" => {
   1259             config.paths.user_identity_backend =
   1260                 parse_identity_backend_env(key, value, path, line_number)?;
   1261         }
   1262         "MYC_IDENTITY_USER_PATH" => {
   1263             config.paths.user_identity_path = PathBuf::from(value);
   1264             path_overrides.user_identity_path = true;
   1265         }
   1266         "MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID" => {
   1267             config.paths.user_identity_keyring_account_id = parse_optional_string_env(value);
   1268         }
   1269         "MYC_IDENTITY_USER_KEYRING_SERVICE_NAME" => {
   1270             config.paths.user_identity_keyring_service_name = value.to_owned();
   1271         }
   1272         "MYC_IDENTITY_USER_PROFILE_PATH" => {
   1273             config.paths.user_identity_profile_path = parse_optional_path_env(value);
   1274         }
   1275         "MYC_PERSISTENCE_SIGNER_STATE_BACKEND" => {
   1276             config.persistence.signer_state_backend =
   1277                 parse_signer_state_backend_env(key, value, path, line_number)?;
   1278         }
   1279         "MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND" => {
   1280             config.persistence.runtime_audit_backend =
   1281                 parse_runtime_audit_backend_env(key, value, path, line_number)?;
   1282         }
   1283         "MYC_AUDIT_DEFAULT_READ_LIMIT" => {
   1284             config.audit.default_read_limit = parse_usize_env(key, value, path, line_number)?;
   1285         }
   1286         "MYC_AUDIT_MAX_ACTIVE_FILE_BYTES" => {
   1287             config.audit.max_active_file_bytes = parse_u64_env(key, value, path, line_number)?;
   1288         }
   1289         "MYC_AUDIT_MAX_ARCHIVED_FILES" => {
   1290             config.audit.max_archived_files = parse_usize_env(key, value, path, line_number)?;
   1291         }
   1292         "MYC_OBSERVABILITY_ENABLED" => {
   1293             config.observability.enabled = parse_bool_env(key, value, path, line_number)?;
   1294         }
   1295         "MYC_OBSERVABILITY_BIND_ADDR" => {
   1296             config.observability.bind_addr = parse_socket_addr_env(key, value, path, line_number)?;
   1297         }
   1298         "MYC_DISCOVERY_ENABLED" => {
   1299             config.discovery.enabled = parse_bool_env(key, value, path, line_number)?;
   1300         }
   1301         "MYC_DISCOVERY_DOMAIN" => {
   1302             config.discovery.domain = parse_optional_string_env(value);
   1303         }
   1304         "MYC_DISCOVERY_HANDLER_IDENTIFIER" => {
   1305             config.discovery.handler_identifier = value.to_owned();
   1306         }
   1307         "MYC_IDENTITY_DISCOVERY_APP_BACKEND" => {
   1308             config.discovery.app_identity_backend =
   1309                 parse_optional_identity_backend_env(key, value, path, line_number)?;
   1310         }
   1311         "MYC_IDENTITY_DISCOVERY_APP_PATH" => {
   1312             config.discovery.app_identity_path = parse_optional_path_env(value);
   1313             path_overrides.discovery_app_identity_path = true;
   1314         }
   1315         "MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID" => {
   1316             config.discovery.app_identity_keyring_account_id = parse_optional_string_env(value);
   1317         }
   1318         "MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME" => {
   1319             config.discovery.app_identity_keyring_service_name = parse_optional_string_env(value);
   1320         }
   1321         "MYC_IDENTITY_DISCOVERY_APP_PROFILE_PATH" => {
   1322             config.discovery.app_identity_profile_path = parse_optional_path_env(value);
   1323         }
   1324         "MYC_DISCOVERY_PUBLIC_RELAY_URLS" => {
   1325             config.discovery.public_relays = parse_string_list_env(value);
   1326         }
   1327         "MYC_DISCOVERY_PUBLISH_RELAY_URLS" => {
   1328             config.discovery.publish_relays = parse_string_list_env(value);
   1329         }
   1330         "MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE" => {
   1331             config.discovery.nostrconnect_url_template = parse_optional_string_env(value);
   1332         }
   1333         "MYC_DISCOVERY_NIP05_OUTPUT_PATH" => {
   1334             config.discovery.nip05_output_path = parse_optional_path_env(value);
   1335             path_overrides.discovery_nip05_output_path = true;
   1336         }
   1337         "MYC_DISCOVERY_METADATA_NAME" => {
   1338             config.discovery.metadata.name = parse_optional_string_env(value);
   1339         }
   1340         "MYC_DISCOVERY_METADATA_DISPLAY_NAME" => {
   1341             config.discovery.metadata.display_name = parse_optional_string_env(value);
   1342         }
   1343         "MYC_DISCOVERY_METADATA_ABOUT" => {
   1344             config.discovery.metadata.about = parse_optional_string_env(value);
   1345         }
   1346         "MYC_DISCOVERY_METADATA_WEBSITE" => {
   1347             config.discovery.metadata.website = parse_optional_string_env(value);
   1348         }
   1349         "MYC_DISCOVERY_METADATA_PICTURE" => {
   1350             config.discovery.metadata.picture = parse_optional_string_env(value);
   1351         }
   1352         "MYC_POLICY_CONNECTION_APPROVAL" => {
   1353             config.policy.connection_approval =
   1354                 parse_connection_approval_env(key, value, path, line_number)?;
   1355         }
   1356         "MYC_POLICY_TRUSTED_CLIENT_PUBKEYS" => {
   1357             config.policy.trusted_client_pubkeys = parse_string_list_env(value);
   1358         }
   1359         "MYC_POLICY_DENIED_CLIENT_PUBKEYS" => {
   1360             config.policy.denied_client_pubkeys = parse_string_list_env(value);
   1361         }
   1362         "MYC_POLICY_PERMISSION_CEILING" => {
   1363             config.policy.permission_ceiling =
   1364                 parse_permissions_env(key, value, path, line_number)?;
   1365         }
   1366         "MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS" => {
   1367             config.policy.allowed_sign_event_kinds =
   1368                 parse_u16_list_env(key, value, path, line_number)?;
   1369         }
   1370         "MYC_POLICY_AUTH_URL" => {
   1371             config.policy.auth_url = parse_optional_string_env(value);
   1372         }
   1373         "MYC_POLICY_AUTH_PENDING_TTL_SECS" => {
   1374             config.policy.auth_pending_ttl_secs = parse_u64_env(key, value, path, line_number)?;
   1375         }
   1376         "MYC_POLICY_AUTHORIZED_TTL_SECS" => {
   1377             config.policy.auth_authorized_ttl_secs =
   1378                 Some(parse_u64_env(key, value, path, line_number)?);
   1379         }
   1380         "MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS" => {
   1381             config.policy.reauth_after_inactivity_secs =
   1382                 Some(parse_u64_env(key, value, path, line_number)?);
   1383         }
   1384         "MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS" => {
   1385             config.policy.connect_rate_limit_window_secs =
   1386                 Some(parse_u64_env(key, value, path, line_number)?);
   1387         }
   1388         "MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS" => {
   1389             config.policy.connect_rate_limit_max_attempts =
   1390                 Some(parse_usize_env(key, value, path, line_number)?);
   1391         }
   1392         "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS" => {
   1393             config.policy.auth_challenge_rate_limit_window_secs =
   1394                 Some(parse_u64_env(key, value, path, line_number)?);
   1395         }
   1396         "MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS" => {
   1397             config.policy.auth_challenge_rate_limit_max_attempts =
   1398                 Some(parse_usize_env(key, value, path, line_number)?);
   1399         }
   1400         "MYC_TRANSPORT_ENABLED" => {
   1401             config.transport.enabled = parse_bool_env(key, value, path, line_number)?;
   1402         }
   1403         "MYC_TRANSPORT_CONNECT_TIMEOUT_SECS" => {
   1404             config.transport.connect_timeout_secs = parse_u64_env(key, value, path, line_number)?;
   1405         }
   1406         "MYC_TRANSPORT_RELAY_URLS" => {
   1407             config.transport.relays = parse_string_list_env(value);
   1408         }
   1409         "MYC_TRANSPORT_DELIVERY_POLICY" => {
   1410             config.transport.delivery_policy =
   1411                 parse_delivery_policy_env(key, value, path, line_number)?;
   1412         }
   1413         "MYC_TRANSPORT_DELIVERY_QUORUM" => {
   1414             config.transport.delivery_quorum =
   1415                 Some(parse_usize_env(key, value, path, line_number)?);
   1416         }
   1417         "MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS" => {
   1418             config.transport.publish_max_attempts = parse_usize_env(key, value, path, line_number)?;
   1419         }
   1420         "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS" => {
   1421             config.transport.publish_initial_backoff_millis =
   1422                 parse_u64_env(key, value, path, line_number)?;
   1423         }
   1424         "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS" => {
   1425             config.transport.publish_max_backoff_millis =
   1426                 parse_u64_env(key, value, path, line_number)?;
   1427         }
   1428         _ => {
   1429             return Err(config_parse_error(
   1430                 path,
   1431                 line_number,
   1432                 format!("unknown environment variable `{key}`"),
   1433             ));
   1434         }
   1435     }
   1436 
   1437     Ok(())
   1438 }
   1439 
   1440 fn parse_bool_env(
   1441     key: &str,
   1442     value: &str,
   1443     path: &Path,
   1444     line_number: usize,
   1445 ) -> Result<bool, MycError> {
   1446     value.parse::<bool>().map_err(|_| {
   1447         config_parse_error(
   1448             path,
   1449             line_number,
   1450             format!("{key} must be `true` or `false`"),
   1451         )
   1452     })
   1453 }
   1454 
   1455 fn parse_usize_env(
   1456     key: &str,
   1457     value: &str,
   1458     path: &Path,
   1459     line_number: usize,
   1460 ) -> Result<usize, MycError> {
   1461     value.parse::<usize>().map_err(|_| {
   1462         config_parse_error(
   1463             path,
   1464             line_number,
   1465             format!("{key} must be an unsigned integer"),
   1466         )
   1467     })
   1468 }
   1469 
   1470 fn parse_u64_env(key: &str, value: &str, path: &Path, line_number: usize) -> Result<u64, MycError> {
   1471     value.parse::<u64>().map_err(|_| {
   1472         config_parse_error(
   1473             path,
   1474             line_number,
   1475             format!("{key} must be an unsigned integer"),
   1476         )
   1477     })
   1478 }
   1479 
   1480 fn parse_socket_addr_env(
   1481     key: &str,
   1482     value: &str,
   1483     path: &Path,
   1484     line_number: usize,
   1485 ) -> Result<SocketAddr, MycError> {
   1486     value.parse::<SocketAddr>().map_err(|error| {
   1487         config_parse_error(
   1488             path,
   1489             line_number,
   1490             format!("{key} must be a socket address: {error}"),
   1491         )
   1492     })
   1493 }
   1494 
   1495 fn parse_connection_approval_env(
   1496     key: &str,
   1497     value: &str,
   1498     path: &Path,
   1499     line_number: usize,
   1500 ) -> Result<MycConnectionApproval, MycError> {
   1501     match value {
   1502         "not_required" => Ok(MycConnectionApproval::NotRequired),
   1503         "explicit_user" => Ok(MycConnectionApproval::ExplicitUser),
   1504         "deny" => Ok(MycConnectionApproval::Deny),
   1505         _ => Err(config_parse_error(
   1506             path,
   1507             line_number,
   1508             format!("{key} must be `not_required`, `explicit_user`, or `deny`"),
   1509         )),
   1510     }
   1511 }
   1512 
   1513 fn parse_identity_backend_env(
   1514     key: &str,
   1515     value: &str,
   1516     path: &Path,
   1517     line_number: usize,
   1518 ) -> Result<MycIdentityBackend, MycError> {
   1519     match value {
   1520         "encrypted_file" => Ok(MycIdentityBackend::EncryptedFile),
   1521         "host_vault" => Ok(MycIdentityBackend::HostVault),
   1522         "managed_account" => Ok(MycIdentityBackend::ManagedAccount),
   1523         "external_command" => Ok(MycIdentityBackend::ExternalCommand),
   1524         "plaintext_file" => Ok(MycIdentityBackend::PlaintextFile),
   1525         _ => Err(config_parse_error(
   1526             path,
   1527             line_number,
   1528             format!(
   1529                 "{key} must be `encrypted_file`, `host_vault`, `managed_account`, `external_command`, or `plaintext_file`"
   1530             ),
   1531         )),
   1532     }
   1533 }
   1534 
   1535 fn parse_optional_identity_backend_env(
   1536     key: &str,
   1537     value: &str,
   1538     path: &Path,
   1539     line_number: usize,
   1540 ) -> Result<Option<MycIdentityBackend>, MycError> {
   1541     match parse_optional_string_env(value) {
   1542         Some(value) => parse_identity_backend_env(key, value.as_str(), path, line_number).map(Some),
   1543         None => Ok(None),
   1544     }
   1545 }
   1546 
   1547 fn parse_delivery_policy_env(
   1548     key: &str,
   1549     value: &str,
   1550     path: &Path,
   1551     line_number: usize,
   1552 ) -> Result<MycTransportDeliveryPolicy, MycError> {
   1553     match value {
   1554         "any" => Ok(MycTransportDeliveryPolicy::Any),
   1555         "quorum" => Ok(MycTransportDeliveryPolicy::Quorum),
   1556         "all" => Ok(MycTransportDeliveryPolicy::All),
   1557         _ => Err(config_parse_error(
   1558             path,
   1559             line_number,
   1560             format!("{key} must be `any`, `quorum`, or `all`"),
   1561         )),
   1562     }
   1563 }
   1564 
   1565 fn parse_signer_state_backend_env(
   1566     key: &str,
   1567     value: &str,
   1568     path: &Path,
   1569     line_number: usize,
   1570 ) -> Result<MycSignerStateBackend, MycError> {
   1571     match value {
   1572         "json_file" => Ok(MycSignerStateBackend::JsonFile),
   1573         "sqlite" => Ok(MycSignerStateBackend::Sqlite),
   1574         _ => Err(config_parse_error(
   1575             path,
   1576             line_number,
   1577             format!("{key} must be `json_file` or `sqlite`"),
   1578         )),
   1579     }
   1580 }
   1581 
   1582 fn parse_runtime_audit_backend_env(
   1583     key: &str,
   1584     value: &str,
   1585     path: &Path,
   1586     line_number: usize,
   1587 ) -> Result<MycRuntimeAuditBackend, MycError> {
   1588     match value {
   1589         "jsonl_file" => Ok(MycRuntimeAuditBackend::JsonlFile),
   1590         "sqlite" => Ok(MycRuntimeAuditBackend::Sqlite),
   1591         _ => Err(config_parse_error(
   1592             path,
   1593             line_number,
   1594             format!("{key} must be `jsonl_file` or `sqlite`"),
   1595         )),
   1596     }
   1597 }
   1598 
   1599 fn parse_optional_string_env(value: &str) -> Option<String> {
   1600     let value = value.trim();
   1601     if value.is_empty() {
   1602         None
   1603     } else {
   1604         Some(value.to_owned())
   1605     }
   1606 }
   1607 
   1608 fn parse_permissions_env(
   1609     key: &str,
   1610     value: &str,
   1611     path: &Path,
   1612     line_number: usize,
   1613 ) -> Result<RadrootsNostrConnectPermissions, MycError> {
   1614     value
   1615         .parse::<RadrootsNostrConnectPermissions>()
   1616         .map_err(|error| {
   1617             config_parse_error(path, line_number, format!("{key} parse error: {error}"))
   1618         })
   1619 }
   1620 
   1621 fn parse_u16_list_env(
   1622     key: &str,
   1623     value: &str,
   1624     path: &Path,
   1625     line_number: usize,
   1626 ) -> Result<Vec<u16>, MycError> {
   1627     parse_string_list_env(value)
   1628         .into_iter()
   1629         .map(|fragment| {
   1630             fragment.parse::<u16>().map_err(|_| {
   1631                 config_parse_error(
   1632                     path,
   1633                     line_number,
   1634                     format!("{key} must contain only unsigned 16-bit integers"),
   1635                 )
   1636             })
   1637         })
   1638         .collect()
   1639 }
   1640 
   1641 fn validate_optional_rate_limit(
   1642     label: &str,
   1643     window_secs: Option<u64>,
   1644     max_attempts: Option<usize>,
   1645 ) -> Result<(), MycError> {
   1646     match (window_secs, max_attempts) {
   1647         (None, None) => Ok(()),
   1648         (Some(window_secs), Some(max_attempts)) => {
   1649             if window_secs == 0 {
   1650                 return Err(MycError::InvalidConfig(format!(
   1651                     "{label}.window_secs must be greater than zero when set"
   1652                 )));
   1653             }
   1654             if max_attempts == 0 {
   1655                 return Err(MycError::InvalidConfig(format!(
   1656                     "{label}.max_attempts must be greater than zero when set"
   1657                 )));
   1658             }
   1659             Ok(())
   1660         }
   1661         _ => Err(MycError::InvalidConfig(format!(
   1662             "{label}.window_secs and {label}.max_attempts must be set together"
   1663         ))),
   1664     }
   1665 }
   1666 
   1667 fn normalize_policy_client_pubkeys(values: &[String]) -> Result<BTreeSet<String>, MycError> {
   1668     values
   1669         .iter()
   1670         .map(|value| {
   1671             let public_key = PublicKey::parse(value)
   1672                 .or_else(|_| PublicKey::from_hex(value))
   1673                 .map_err(|_| {
   1674                     MycError::InvalidConfig(format!(
   1675                         "policy client pubkey `{value}` is not a valid nostr public key"
   1676                     ))
   1677                 })?;
   1678             Ok(public_key.to_hex())
   1679         })
   1680         .collect()
   1681 }
   1682 
   1683 pub(crate) fn parse_optional_path_env(value: &str) -> Option<PathBuf> {
   1684     parse_optional_string_env(value).map(PathBuf::from)
   1685 }
   1686 
   1687 fn parse_string_list_env(value: &str) -> Vec<String> {
   1688     value
   1689         .split(',')
   1690         .map(str::trim)
   1691         .filter(|entry| !entry.is_empty())
   1692         .map(ToOwned::to_owned)
   1693         .collect()
   1694 }
   1695 
   1696 pub(crate) fn config_parse_error(
   1697     path: &Path,
   1698     line_number: usize,
   1699     message: impl Into<String>,
   1700 ) -> MycError {
   1701     MycError::ConfigParse {
   1702         path: path.to_path_buf(),
   1703         line_number,
   1704         message: message.into(),
   1705     }
   1706 }
   1707 
   1708 fn validate_identity_source_config(
   1709     label: &str,
   1710     source: &MycIdentitySourceSpec,
   1711 ) -> Result<(), MycError> {
   1712     match source.backend {
   1713         MycIdentityBackend::EncryptedFile | MycIdentityBackend::PlaintextFile => {
   1714             let Some(path) = source.path.as_ref() else {
   1715                 return Err(MycError::InvalidConfig(format!(
   1716                     "{label}.path must be set when backend is `{}`",
   1717                     source.backend.as_str()
   1718                 )));
   1719             };
   1720             if path.as_os_str().is_empty() {
   1721                 return Err(MycError::InvalidConfig(format!(
   1722                     "{label}.path must not be empty when backend is `{}`",
   1723                     source.backend.as_str()
   1724                 )));
   1725             }
   1726             if source.keyring_account_id.is_some() {
   1727                 return Err(MycError::InvalidConfig(format!(
   1728                     "{label}.keyring_account_id must not be set when backend is `{}`",
   1729                     source.backend.as_str()
   1730                 )));
   1731             }
   1732             if source.keyring_service_name.is_some() {
   1733                 return Err(MycError::InvalidConfig(format!(
   1734                     "{label}.keyring_service_name must not be set when backend is `{}`",
   1735                     source.backend.as_str()
   1736                 )));
   1737             }
   1738             if source.profile_path.is_some() {
   1739                 return Err(MycError::InvalidConfig(format!(
   1740                     "{label}.profile_path must not be set when backend is `{}`",
   1741                     source.backend.as_str()
   1742                 )));
   1743             }
   1744         }
   1745         MycIdentityBackend::ExternalCommand => {
   1746             let Some(path) = source.path.as_ref() else {
   1747                 return Err(MycError::InvalidConfig(format!(
   1748                     "{label}.path must be set when backend is `external_command`"
   1749                 )));
   1750             };
   1751             if path.as_os_str().is_empty() {
   1752                 return Err(MycError::InvalidConfig(format!(
   1753                     "{label}.path must not be empty when backend is `external_command`"
   1754                 )));
   1755             }
   1756             if source.keyring_account_id.is_some() {
   1757                 return Err(MycError::InvalidConfig(format!(
   1758                     "{label}.keyring_account_id must not be set when backend is `external_command`"
   1759                 )));
   1760             }
   1761             if source.keyring_service_name.is_some() {
   1762                 return Err(MycError::InvalidConfig(format!(
   1763                     "{label}.keyring_service_name must not be set when backend is `external_command`"
   1764                 )));
   1765             }
   1766             if source.profile_path.is_some() {
   1767                 return Err(MycError::InvalidConfig(format!(
   1768                     "{label}.profile_path must not be set when backend is `external_command`"
   1769                 )));
   1770             }
   1771         }
   1772         MycIdentityBackend::HostVault => {
   1773             let Some(account_id) = source.keyring_account_id.as_deref() else {
   1774                 return Err(MycError::InvalidConfig(format!(
   1775                     "{label}.keyring_account_id must be set when backend is `host_vault`"
   1776                 )));
   1777             };
   1778             let _ = radroots_identity::RadrootsIdentityId::parse(account_id).map_err(|_| {
   1779                 MycError::InvalidConfig(format!(
   1780                     "{label}.keyring_account_id must be a valid nostr public identity id"
   1781                 ))
   1782             })?;
   1783             let Some(service_name) = source.keyring_service_name.as_deref() else {
   1784                 return Err(MycError::InvalidConfig(format!(
   1785                     "{label}.keyring_service_name must be set when backend is `host_vault`"
   1786                 )));
   1787             };
   1788             if service_name.trim().is_empty() {
   1789                 return Err(MycError::InvalidConfig(format!(
   1790                     "{label}.keyring_service_name must not be empty when backend is `host_vault`"
   1791                 )));
   1792             }
   1793             if let Some(profile_path) = source.profile_path.as_ref()
   1794                 && profile_path.as_os_str().is_empty()
   1795             {
   1796                 return Err(MycError::InvalidConfig(format!(
   1797                     "{label}.profile_path must not be empty when set"
   1798                 )));
   1799             }
   1800         }
   1801         MycIdentityBackend::ManagedAccount => {
   1802             let Some(path) = source.path.as_ref() else {
   1803                 return Err(MycError::InvalidConfig(format!(
   1804                     "{label}.path must be set when backend is `managed_account`"
   1805                 )));
   1806             };
   1807             if path.as_os_str().is_empty() {
   1808                 return Err(MycError::InvalidConfig(format!(
   1809                     "{label}.path must not be empty when backend is `managed_account`"
   1810                 )));
   1811             }
   1812             let Some(service_name) = source.keyring_service_name.as_deref() else {
   1813                 return Err(MycError::InvalidConfig(format!(
   1814                     "{label}.keyring_service_name must be set when backend is `managed_account`"
   1815                 )));
   1816             };
   1817             if service_name.trim().is_empty() {
   1818                 return Err(MycError::InvalidConfig(format!(
   1819                     "{label}.keyring_service_name must not be empty when backend is `managed_account`"
   1820                 )));
   1821             }
   1822             if source.keyring_account_id.is_some() {
   1823                 return Err(MycError::InvalidConfig(format!(
   1824                     "{label}.keyring_account_id must not be set when backend is `managed_account`"
   1825                 )));
   1826             }
   1827             if source.profile_path.is_some() {
   1828                 return Err(MycError::InvalidConfig(format!(
   1829                     "{label}.profile_path must not be set when backend is `managed_account`"
   1830                 )));
   1831             }
   1832         }
   1833     }
   1834 
   1835     Ok(())
   1836 }
   1837 
   1838 impl MycTransportConfig {
   1839     pub fn parse_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1840         self.relays
   1841             .iter()
   1842             .map(|value| {
   1843                 RadrootsNostrRelayUrl::parse(value).map_err(|source| {
   1844                     MycError::InvalidConfig(format!(
   1845                         "transport.relays contains invalid relay url `{value}`: {source}"
   1846                     ))
   1847                 })
   1848             })
   1849             .collect()
   1850     }
   1851 }
   1852 
   1853 impl MycDiscoveryConfig {
   1854     pub fn app_identity_source(&self) -> Option<MycIdentitySourceSpec> {
   1855         let backend = match (self.app_identity_backend, self.app_identity_path.as_ref()) {
   1856             (Some(backend), _) => Some(backend),
   1857             (None, Some(_)) => Some(MycIdentityBackend::EncryptedFile),
   1858             (None, None) => None,
   1859         }?;
   1860 
   1861         Some(MycIdentitySourceSpec {
   1862             backend,
   1863             path: match backend {
   1864                 MycIdentityBackend::EncryptedFile
   1865                 | MycIdentityBackend::PlaintextFile
   1866                 | MycIdentityBackend::ManagedAccount
   1867                 | MycIdentityBackend::ExternalCommand => self.app_identity_path.clone(),
   1868                 MycIdentityBackend::HostVault => None,
   1869             },
   1870             keyring_account_id: match backend {
   1871                 MycIdentityBackend::EncryptedFile
   1872                 | MycIdentityBackend::PlaintextFile
   1873                 | MycIdentityBackend::ManagedAccount
   1874                 | MycIdentityBackend::ExternalCommand => None,
   1875                 MycIdentityBackend::HostVault => self.app_identity_keyring_account_id.clone(),
   1876             },
   1877             keyring_service_name: match backend {
   1878                 MycIdentityBackend::EncryptedFile
   1879                 | MycIdentityBackend::PlaintextFile
   1880                 | MycIdentityBackend::ExternalCommand => None,
   1881                 MycIdentityBackend::HostVault | MycIdentityBackend::ManagedAccount => {
   1882                     self.app_identity_keyring_service_name.clone()
   1883                 }
   1884             },
   1885             profile_path: match backend {
   1886                 MycIdentityBackend::EncryptedFile
   1887                 | MycIdentityBackend::PlaintextFile
   1888                 | MycIdentityBackend::ManagedAccount
   1889                 | MycIdentityBackend::ExternalCommand => None,
   1890                 MycIdentityBackend::HostVault => self.app_identity_profile_path.clone(),
   1891             },
   1892         })
   1893     }
   1894 
   1895     pub fn parse_public_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1896         parse_discovery_relays(&self.public_relays, "discovery.public_relays")
   1897     }
   1898 
   1899     pub fn parse_publish_relays(&self) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1900         parse_discovery_relays(&self.publish_relays, "discovery.publish_relays")
   1901     }
   1902 
   1903     pub fn resolved_public_relays(
   1904         &self,
   1905         transport: &MycTransportConfig,
   1906     ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1907         let relays = if self.public_relays.is_empty() {
   1908             transport.parse_relays()?
   1909         } else {
   1910             self.parse_public_relays()?
   1911         };
   1912         Ok(normalize_discovery_relays(relays))
   1913     }
   1914 
   1915     pub fn resolved_publish_relays(
   1916         &self,
   1917         transport: &MycTransportConfig,
   1918     ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1919         let relays = if self.publish_relays.is_empty() {
   1920             self.resolved_public_relays(transport)?
   1921         } else {
   1922             self.parse_publish_relays()?
   1923         };
   1924         Ok(normalize_discovery_relays(relays))
   1925     }
   1926 
   1927     fn validate(&self, transport: &MycTransportConfig) -> Result<(), MycError> {
   1928         if !self.enabled {
   1929             return Ok(());
   1930         }
   1931 
   1932         let domain = self.domain.as_deref().ok_or_else(|| {
   1933             MycError::InvalidConfig(
   1934                 "discovery.domain must be set when discovery.enabled is true".to_owned(),
   1935             )
   1936         })?;
   1937         validate_discovery_domain(domain)?;
   1938 
   1939         if self.handler_identifier.trim().is_empty() {
   1940             return Err(MycError::InvalidConfig(
   1941                 "discovery.handler_identifier must not be empty when discovery.enabled is true"
   1942                     .to_owned(),
   1943             ));
   1944         }
   1945 
   1946         if let Some(source) = self.app_identity_source() {
   1947             validate_identity_source_config("discovery.app_identity", &source)?;
   1948         }
   1949 
   1950         if let Some(template) = self.nostrconnect_url_template.as_deref() {
   1951             validate_nostrconnect_url_template(template)?;
   1952         }
   1953 
   1954         if let Some(path) = self.nip05_output_path.as_ref() {
   1955             if path.as_os_str().is_empty() {
   1956                 return Err(MycError::InvalidConfig(
   1957                     "discovery.nip05_output_path must not be empty".to_owned(),
   1958                 ));
   1959             }
   1960         }
   1961 
   1962         if self.resolved_public_relays(transport)?.is_empty() {
   1963             return Err(MycError::InvalidConfig(
   1964                 "discovery requires at least one public relay hint via discovery.public_relays or transport.relays".to_owned(),
   1965             ));
   1966         }
   1967 
   1968         let _ = self.resolved_publish_relays(transport)?;
   1969         Ok(())
   1970     }
   1971 }
   1972 
   1973 fn parse_discovery_relays(
   1974     values: &[String],
   1975     field_name: &str,
   1976 ) -> Result<Vec<RadrootsNostrRelayUrl>, MycError> {
   1977     values
   1978         .iter()
   1979         .map(|value| {
   1980             RadrootsNostrRelayUrl::parse(value).map_err(|source| {
   1981                 MycError::InvalidConfig(format!(
   1982                     "{field_name} contains invalid relay url `{value}`: {source}"
   1983                 ))
   1984             })
   1985         })
   1986         .collect()
   1987 }
   1988 
   1989 fn normalize_discovery_relays(
   1990     mut relays: Vec<RadrootsNostrRelayUrl>,
   1991 ) -> Vec<RadrootsNostrRelayUrl> {
   1992     relays.sort_by(|left, right| left.as_str().cmp(right.as_str()));
   1993     relays.dedup_by(|left, right| left.as_str() == right.as_str());
   1994     relays
   1995 }
   1996 
   1997 fn validate_discovery_domain(domain: &str) -> Result<(), MycError> {
   1998     let trimmed = domain.trim();
   1999     if trimmed.is_empty()
   2000         || trimmed.contains("://")
   2001         || trimmed.contains('/')
   2002         || trimmed.contains('?')
   2003         || trimmed.contains('#')
   2004         || trimmed.chars().any(char::is_whitespace)
   2005     {
   2006         return Err(MycError::InvalidConfig(format!(
   2007             "discovery.domain must be a bare host name without scheme or path: `{domain}`"
   2008         )));
   2009     }
   2010     Ok(())
   2011 }
   2012 
   2013 fn validate_nostrconnect_url_template(template: &str) -> Result<(), MycError> {
   2014     let trimmed = template.trim();
   2015     if trimmed.is_empty() {
   2016         return Err(MycError::InvalidConfig(
   2017             "discovery.nostrconnect_url_template must not be empty when set".to_owned(),
   2018         ));
   2019     }
   2020     if !trimmed.contains("<nostrconnect>") {
   2021         return Err(MycError::InvalidConfig(
   2022             "discovery.nostrconnect_url_template must contain the `<nostrconnect>` placeholder"
   2023                 .to_owned(),
   2024         ));
   2025     }
   2026     let candidate = trimmed.replace("<nostrconnect>", "nostrconnect%3A%2F%2Fclient");
   2027     let url = nostr::Url::parse(&candidate).map_err(|source| {
   2028         MycError::InvalidConfig(format!(
   2029             "discovery.nostrconnect_url_template is invalid: {source}"
   2030         ))
   2031     })?;
   2032 
   2033     match url.scheme() {
   2034         "https" => Ok(()),
   2035         "http" if discovery_host_is_local(url.host_str()) => Ok(()),
   2036         _ => Err(MycError::InvalidConfig(
   2037             "discovery.nostrconnect_url_template must use `https://`, except loopback hosts may use `http://`".to_owned(),
   2038         )),
   2039     }
   2040 }
   2041 
   2042 fn discovery_host_is_local(host: Option<&str>) -> bool {
   2043     matches!(host, Some("localhost" | "127.0.0.1" | "::1"))
   2044 }
   2045 
   2046 #[cfg(test)]
   2047 mod tests {
   2048     use std::fs;
   2049 
   2050     use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform};
   2051 
   2052     use super::*;
   2053 
   2054     fn linux_resolver(home: &str) -> RadrootsPathResolver {
   2055         RadrootsPathResolver::new(
   2056             RadrootsPlatform::Linux,
   2057             RadrootsHostEnvironment {
   2058                 home_dir: Some(PathBuf::from(home)),
   2059                 ..RadrootsHostEnvironment::default()
   2060             },
   2061         )
   2062     }
   2063 
   2064     #[test]
   2065     fn default_config_is_stable() {
   2066         let resolver = linux_resolver("/home/treesap");
   2067         let config = MycConfig::default_with_path_selection(
   2068             &resolver,
   2069             MycPathProfile::InteractiveUser,
   2070             None,
   2071         )
   2072         .expect("default config");
   2073         assert_eq!(config.service.instance_name, "myc");
   2074         assert_eq!(config.logging.filter, "info,myc=info");
   2075         assert_eq!(config.paths.profile, MycPathProfile::InteractiveUser);
   2076         assert_eq!(config.paths.repo_local_root, None);
   2077         assert_eq!(
   2078             config.paths.config_env_path,
   2079             PathBuf::from("/home/treesap/.radroots/config/services/myc/config.env")
   2080         );
   2081         assert_eq!(
   2082             config.paths.run_dir,
   2083             PathBuf::from("/home/treesap/.radroots/run/services/myc")
   2084         );
   2085         assert_eq!(
   2086             config.logging.output_dir,
   2087             Some(PathBuf::from("/home/treesap/.radroots/logs/services/myc"))
   2088         );
   2089         assert!(config.logging.stdout);
   2090         assert_eq!(
   2091             config.paths.state_dir,
   2092             PathBuf::from("/home/treesap/.radroots/data/services/myc/state")
   2093         );
   2094         assert_eq!(
   2095             config.paths.signer_identity_backend,
   2096             MycIdentityBackend::EncryptedFile
   2097         );
   2098         assert_eq!(
   2099             config.paths.signer_identity_path,
   2100             PathBuf::from("/home/treesap/.radroots/secrets/services/myc/signer-identity.json")
   2101         );
   2102         assert_eq!(config.paths.signer_identity_keyring_account_id, None);
   2103         assert_eq!(
   2104             config.paths.signer_identity_keyring_service_name,
   2105             "org.radroots.myc.signer"
   2106         );
   2107         assert_eq!(config.paths.signer_identity_profile_path, None);
   2108         assert_eq!(
   2109             config.paths.user_identity_backend,
   2110             MycIdentityBackend::EncryptedFile
   2111         );
   2112         assert_eq!(
   2113             config.paths.user_identity_path,
   2114             PathBuf::from("/home/treesap/.radroots/secrets/services/myc/user-identity.json")
   2115         );
   2116         assert_eq!(config.paths.user_identity_keyring_account_id, None);
   2117         assert_eq!(
   2118             config.paths.user_identity_keyring_service_name,
   2119             "org.radroots.myc.user"
   2120         );
   2121         assert_eq!(config.paths.user_identity_profile_path, None);
   2122         assert_eq!(
   2123             config.persistence.signer_state_backend,
   2124             MycSignerStateBackend::JsonFile
   2125         );
   2126         assert_eq!(
   2127             config.persistence.runtime_audit_backend,
   2128             MycRuntimeAuditBackend::JsonlFile
   2129         );
   2130         assert_eq!(
   2131             config.policy.connection_approval,
   2132             MycConnectionApproval::ExplicitUser
   2133         );
   2134         assert!(config.policy.trusted_client_pubkeys.is_empty());
   2135         assert!(config.policy.denied_client_pubkeys.is_empty());
   2136         assert!(config.policy.permission_ceiling.is_empty());
   2137         assert!(config.policy.allowed_sign_event_kinds.is_empty());
   2138         assert!(config.policy.auth_url.is_none());
   2139         assert_eq!(config.policy.auth_pending_ttl_secs, 900);
   2140         assert_eq!(config.policy.auth_authorized_ttl_secs, None);
   2141         assert_eq!(config.policy.reauth_after_inactivity_secs, None);
   2142         assert_eq!(config.policy.connect_rate_limit_window_secs, None);
   2143         assert_eq!(config.policy.connect_rate_limit_max_attempts, None);
   2144         assert_eq!(config.policy.auth_challenge_rate_limit_window_secs, None);
   2145         assert_eq!(config.policy.auth_challenge_rate_limit_max_attempts, None);
   2146         assert_eq!(config.audit.default_read_limit, 200);
   2147         assert_eq!(config.audit.max_active_file_bytes, 262_144);
   2148         assert_eq!(config.audit.max_archived_files, 8);
   2149         assert!(!config.observability.enabled);
   2150         assert_eq!(
   2151             config.observability.bind_addr,
   2152             "127.0.0.1:9460"
   2153                 .parse()
   2154                 .expect("default observability bind addr")
   2155         );
   2156         assert!(!config.discovery.enabled);
   2157         assert_eq!(config.discovery.handler_identifier, "myc");
   2158         assert!(config.discovery.domain.is_none());
   2159         assert_eq!(config.discovery.app_identity_backend, None);
   2160         assert!(config.discovery.app_identity_path.is_none());
   2161         assert!(config.discovery.public_relays.is_empty());
   2162         assert!(config.discovery.publish_relays.is_empty());
   2163         assert!(config.discovery.nostrconnect_url_template.is_none());
   2164         assert_eq!(
   2165             config.discovery.nip05_output_path,
   2166             Some(PathBuf::from(
   2167                 "/home/treesap/.radroots/data/services/myc/public/.well-known/nostr.json"
   2168             ))
   2169         );
   2170         assert!(!config.transport.enabled);
   2171         assert_eq!(config.transport.connect_timeout_secs, 10);
   2172         assert!(config.transport.relays.is_empty());
   2173         assert_eq!(
   2174             config.transport.delivery_policy,
   2175             MycTransportDeliveryPolicy::Any
   2176         );
   2177         assert_eq!(config.transport.delivery_quorum, None);
   2178         assert_eq!(config.transport.publish_max_attempts, 1);
   2179         assert_eq!(config.transport.publish_initial_backoff_millis, 250);
   2180         assert_eq!(config.transport.publish_max_backoff_millis, 2_000);
   2181     }
   2182 
   2183     #[test]
   2184     fn parse_config_from_env_overrides_defaults() {
   2185         let resolver = linux_resolver("/home/treesap");
   2186         let config = MycConfig::from_env_str_with_source_and_resolver(
   2187             r#"
   2188 MYC_SERVICE_INSTANCE_NAME=myc-dev
   2189 MYC_LOGGING_FILTER=debug,myc=trace
   2190 MYC_LOGGING_OUTPUT_DIR=/tmp/myc-logs
   2191 MYC_LOGGING_STDOUT=false
   2192 MYC_PATHS_STATE_DIR=/tmp/myc
   2193 MYC_IDENTITY_SIGNER_BACKEND=encrypted_file
   2194 MYC_IDENTITY_SIGNER_PATH=/tmp/myc-identity.json
   2195 MYC_IDENTITY_USER_BACKEND=encrypted_file
   2196 MYC_IDENTITY_USER_PATH=/tmp/myc-user.json
   2197 MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file
   2198 MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file
   2199 MYC_AUDIT_DEFAULT_READ_LIMIT=50
   2200 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096
   2201 MYC_AUDIT_MAX_ARCHIVED_FILES=3
   2202 MYC_OBSERVABILITY_ENABLED=true
   2203 MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550
   2204 MYC_DISCOVERY_ENABLED=true
   2205 MYC_DISCOVERY_DOMAIN=myc.example.com
   2206 MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main
   2207 MYC_IDENTITY_DISCOVERY_APP_BACKEND=encrypted_file
   2208 MYC_IDENTITY_DISCOVERY_APP_PATH=/tmp/myc-app.json
   2209 MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.discovery.example.com
   2210 MYC_DISCOVERY_PUBLISH_RELAY_URLS=wss://relay.publish.example.com
   2211 MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect>
   2212 MYC_DISCOVERY_NIP05_OUTPUT_PATH=/tmp/nostr.json
   2213 MYC_DISCOVERY_METADATA_NAME=myc
   2214 MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza
   2215 MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer
   2216 MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com
   2217 MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png
   2218 MYC_POLICY_CONNECTION_APPROVAL=not_required
   2219 MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=1111111111111111111111111111111111111111111111111111111111111111
   2220 MYC_POLICY_DENIED_CLIENT_PUBKEYS=2222222222222222222222222222222222222222222222222222222222222222
   2221 MYC_POLICY_PERMISSION_CEILING=nip04_encrypt,sign_event:1
   2222 MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS=1,7
   2223 MYC_POLICY_AUTH_URL=https://auth.example.com/challenge
   2224 MYC_POLICY_AUTH_PENDING_TTL_SECS=300
   2225 MYC_POLICY_AUTHORIZED_TTL_SECS=3600
   2226 MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600
   2227 MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60
   2228 MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5
   2229 MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120
   2230 MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3
   2231 MYC_TRANSPORT_ENABLED=true
   2232 MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15
   2233 MYC_TRANSPORT_RELAY_URLS=wss://relay.example.com,wss://relay2.example.com
   2234 MYC_TRANSPORT_DELIVERY_POLICY=quorum
   2235 MYC_TRANSPORT_DELIVERY_QUORUM=2
   2236 MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS=4
   2237 MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=100
   2238 MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800
   2239             "#,
   2240             Path::new("inline.env"),
   2241             &resolver,
   2242         )
   2243         .expect("config");
   2244 
   2245         assert_eq!(config.service.instance_name, "myc-dev");
   2246         assert_eq!(config.logging.filter, "debug,myc=trace");
   2247         assert_eq!(config.paths.profile, MycPathProfile::InteractiveUser);
   2248         assert_eq!(
   2249             config.paths.config_env_path,
   2250             PathBuf::from("/home/treesap/.radroots/config/services/myc/config.env")
   2251         );
   2252         assert_eq!(
   2253             config.paths.run_dir,
   2254             PathBuf::from("/home/treesap/.radroots/run/services/myc")
   2255         );
   2256         assert_eq!(
   2257             config.logging.output_dir,
   2258             Some(PathBuf::from("/tmp/myc-logs"))
   2259         );
   2260         assert!(!config.logging.stdout);
   2261         assert_eq!(config.paths.state_dir, PathBuf::from("/tmp/myc"));
   2262         assert_eq!(
   2263             config.paths.signer_identity_backend,
   2264             MycIdentityBackend::EncryptedFile
   2265         );
   2266         assert_eq!(
   2267             config.paths.signer_identity_path,
   2268             PathBuf::from("/tmp/myc-identity.json")
   2269         );
   2270         assert_eq!(
   2271             config.paths.user_identity_backend,
   2272             MycIdentityBackend::EncryptedFile
   2273         );
   2274         assert_eq!(
   2275             config.paths.user_identity_path,
   2276             PathBuf::from("/tmp/myc-user.json")
   2277         );
   2278         assert_eq!(
   2279             config.persistence.signer_state_backend,
   2280             MycSignerStateBackend::JsonFile
   2281         );
   2282         assert_eq!(
   2283             config.persistence.runtime_audit_backend,
   2284             MycRuntimeAuditBackend::JsonlFile
   2285         );
   2286         assert_eq!(config.audit.default_read_limit, 50);
   2287         assert_eq!(config.audit.max_active_file_bytes, 4096);
   2288         assert_eq!(config.audit.max_archived_files, 3);
   2289         assert!(config.observability.enabled);
   2290         assert_eq!(
   2291             config.observability.bind_addr,
   2292             "127.0.0.1:9550".parse().expect("observability bind addr")
   2293         );
   2294         assert!(config.discovery.enabled);
   2295         assert_eq!(config.discovery.domain.as_deref(), Some("myc.example.com"));
   2296         assert_eq!(config.discovery.handler_identifier, "myc-main");
   2297         assert_eq!(
   2298             config.discovery.app_identity_backend,
   2299             Some(MycIdentityBackend::EncryptedFile)
   2300         );
   2301         assert_eq!(
   2302             config.discovery.app_identity_path,
   2303             Some(PathBuf::from("/tmp/myc-app.json"))
   2304         );
   2305         assert_eq!(
   2306             config.discovery.public_relays,
   2307             vec!["wss://relay.discovery.example.com".to_owned()]
   2308         );
   2309         assert_eq!(
   2310             config.discovery.publish_relays,
   2311             vec!["wss://relay.publish.example.com".to_owned()]
   2312         );
   2313         assert_eq!(
   2314             config.discovery.nostrconnect_url_template.as_deref(),
   2315             Some("https://myc.example.com/connect/<nostrconnect>")
   2316         );
   2317         assert_eq!(
   2318             config.discovery.nip05_output_path,
   2319             Some(PathBuf::from("/tmp/nostr.json"))
   2320         );
   2321         assert_eq!(config.discovery.metadata.name.as_deref(), Some("myc"));
   2322         assert_eq!(
   2323             config.discovery.metadata.display_name.as_deref(),
   2324             Some("Mycorrhiza")
   2325         );
   2326         assert_eq!(
   2327             config.policy.connection_approval,
   2328             MycConnectionApproval::NotRequired
   2329         );
   2330         assert_eq!(
   2331             config.policy.trusted_client_pubkeys,
   2332             vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()]
   2333         );
   2334         assert_eq!(
   2335             config.policy.denied_client_pubkeys,
   2336             vec!["2222222222222222222222222222222222222222222222222222222222222222".to_owned()]
   2337         );
   2338         assert_eq!(
   2339             config.policy.permission_ceiling.to_string(),
   2340             "nip04_encrypt,sign_event:1"
   2341         );
   2342         assert_eq!(config.policy.allowed_sign_event_kinds, vec![1, 7]);
   2343         assert_eq!(
   2344             config.policy.auth_url.as_deref(),
   2345             Some("https://auth.example.com/challenge")
   2346         );
   2347         assert_eq!(config.policy.auth_pending_ttl_secs, 300);
   2348         assert_eq!(config.policy.auth_authorized_ttl_secs, Some(3600));
   2349         assert_eq!(config.policy.reauth_after_inactivity_secs, Some(600));
   2350         assert_eq!(config.policy.connect_rate_limit_window_secs, Some(60));
   2351         assert_eq!(config.policy.connect_rate_limit_max_attempts, Some(5));
   2352         assert_eq!(
   2353             config.policy.auth_challenge_rate_limit_window_secs,
   2354             Some(120)
   2355         );
   2356         assert_eq!(
   2357             config.policy.auth_challenge_rate_limit_max_attempts,
   2358             Some(3)
   2359         );
   2360         assert!(config.transport.enabled);
   2361         assert_eq!(config.transport.connect_timeout_secs, 15);
   2362         assert_eq!(
   2363             config.transport.relays,
   2364             vec![
   2365                 "wss://relay.example.com".to_owned(),
   2366                 "wss://relay2.example.com".to_owned()
   2367             ]
   2368         );
   2369         assert_eq!(
   2370             config.transport.delivery_policy,
   2371             MycTransportDeliveryPolicy::Quorum
   2372         );
   2373         assert_eq!(config.transport.delivery_quorum, Some(2));
   2374         assert_eq!(config.transport.publish_max_attempts, 4);
   2375         assert_eq!(config.transport.publish_initial_backoff_millis, 100);
   2376         assert_eq!(config.transport.publish_max_backoff_millis, 800);
   2377     }
   2378 
   2379     #[test]
   2380     fn service_host_profile_uses_canonical_defaults() {
   2381         let resolver = linux_resolver("/home/treesap");
   2382         let config =
   2383             MycConfig::default_with_path_selection(&resolver, MycPathProfile::ServiceHost, None)
   2384                 .expect("service-host config");
   2385 
   2386         assert_eq!(config.paths.profile, MycPathProfile::ServiceHost);
   2387         assert_eq!(
   2388             config.paths.config_env_path,
   2389             PathBuf::from("/etc/radroots/services/myc/config.env")
   2390         );
   2391         assert_eq!(
   2392             config.logging.output_dir,
   2393             Some(PathBuf::from("/var/log/radroots/services/myc"))
   2394         );
   2395         assert_eq!(
   2396             config.paths.run_dir,
   2397             PathBuf::from("/run/radroots/services/myc")
   2398         );
   2399         assert_eq!(
   2400             config.paths.state_dir,
   2401             PathBuf::from("/var/lib/radroots/services/myc/state")
   2402         );
   2403         assert_eq!(
   2404             config.paths.signer_identity_path,
   2405             PathBuf::from("/etc/radroots/secrets/services/myc/signer-identity.json")
   2406         );
   2407         assert_eq!(
   2408             config.paths.user_identity_path,
   2409             PathBuf::from("/etc/radroots/secrets/services/myc/user-identity.json")
   2410         );
   2411         assert_eq!(
   2412             config.discovery.nip05_output_path,
   2413             Some(PathBuf::from(
   2414                 "/var/lib/radroots/services/myc/public/.well-known/nostr.json"
   2415             ))
   2416         );
   2417     }
   2418 
   2419     #[test]
   2420     fn repo_local_profile_uses_explicit_repo_local_root() {
   2421         let resolver = linux_resolver("/home/treesap");
   2422         let repo_local_root = PathBuf::from("/repo/.local/radroots/dev/myc");
   2423         let config = MycConfig::default_with_path_selection(
   2424             &resolver,
   2425             MycPathProfile::RepoLocal,
   2426             Some(repo_local_root.as_path()),
   2427         )
   2428         .expect("repo-local config");
   2429 
   2430         assert_eq!(config.paths.profile, MycPathProfile::RepoLocal);
   2431         assert_eq!(config.paths.repo_local_root, Some(repo_local_root.clone()));
   2432         assert_eq!(
   2433             config.paths.config_env_path,
   2434             repo_local_root.join("config/services/myc/config.env")
   2435         );
   2436         assert_eq!(
   2437             config.logging.output_dir,
   2438             Some(repo_local_root.join("logs/services/myc"))
   2439         );
   2440         assert_eq!(
   2441             config.paths.run_dir,
   2442             repo_local_root.join("run/services/myc")
   2443         );
   2444         assert_eq!(
   2445             config.paths.state_dir,
   2446             repo_local_root.join("data/services/myc/state")
   2447         );
   2448         assert_eq!(
   2449             config.paths.signer_identity_path,
   2450             repo_local_root.join("secrets/services/myc/signer-identity.json")
   2451         );
   2452         assert_eq!(
   2453             config.paths.user_identity_path,
   2454             repo_local_root.join("secrets/services/myc/user-identity.json")
   2455         );
   2456         assert_eq!(
   2457             config.discovery.nip05_output_path,
   2458             Some(repo_local_root.join("data/services/myc/public/.well-known/nostr.json"))
   2459         );
   2460     }
   2461 
   2462     #[test]
   2463     fn load_from_missing_env_path_fails() {
   2464         let temp = tempfile::tempdir().expect("tempdir");
   2465         let err = MycConfig::load_from_env_path(temp.path().join("missing.env"))
   2466             .expect_err("missing env");
   2467 
   2468         assert!(err.to_string().contains("config io error"));
   2469     }
   2470 
   2471     #[test]
   2472     fn parse_rejects_unknown_env_keys() {
   2473         let err = MycConfig::from_env_str(
   2474             r#"
   2475 MYC_SERVICE_INSTANCE_NAME=myc-dev
   2476 MYC_UNKNOWN=nope
   2477             "#,
   2478         )
   2479         .expect_err("unknown key");
   2480 
   2481         assert!(err.to_string().contains("config parse error"));
   2482     }
   2483 
   2484     #[test]
   2485     fn parse_rejects_retired_env_keys() {
   2486         for key in [
   2487             "MYC_PATHS_SIGNER_IDENTITY_BACKEND",
   2488             "MYC_PATHS_SIGNER_IDENTITY_PATH",
   2489             "MYC_PATHS_USER_IDENTITY_BACKEND",
   2490             "MYC_PATHS_USER_IDENTITY_PATH",
   2491             "MYC_DISCOVERY_APP_IDENTITY_BACKEND",
   2492             "MYC_DISCOVERY_APP_IDENTITY_PATH",
   2493             "MYC_DISCOVERY_PUBLIC_RELAYS",
   2494             "MYC_DISCOVERY_PUBLISH_RELAYS",
   2495             "MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE",
   2496             "MYC_TRANSPORT_RELAYS",
   2497             "MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS",
   2498             "MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MILLIS",
   2499         ] {
   2500             let err = MycConfig::from_env_str(format!("{key}=value\n").as_str())
   2501                 .expect_err("retired key should be rejected");
   2502             assert!(err.to_string().contains("unknown environment variable"));
   2503             assert!(err.to_string().contains(key));
   2504         }
   2505     }
   2506 
   2507     #[test]
   2508     fn parse_rejects_retired_identity_backend_aliases() {
   2509         for value in ["filesystem", "os_keyring"] {
   2510             let err =
   2511                 MycConfig::from_env_str(format!("MYC_IDENTITY_SIGNER_BACKEND={value}\n").as_str())
   2512                     .expect_err("retired backend alias should be rejected");
   2513             assert!(
   2514                 err.to_string()
   2515                     .contains("MYC_IDENTITY_SIGNER_BACKEND must be")
   2516             );
   2517         }
   2518     }
   2519 
   2520     #[test]
   2521     fn validate_rejects_enabled_transport_without_relays() {
   2522         let mut config = MycConfig::default();
   2523         config.transport.enabled = true;
   2524 
   2525         let err = config.validate().expect_err("missing relays");
   2526         assert!(err.to_string().contains("transport.relays"));
   2527     }
   2528 
   2529     #[test]
   2530     fn validate_rejects_zero_audit_read_limit() {
   2531         let mut config = MycConfig::default();
   2532         config.audit.default_read_limit = 0;
   2533 
   2534         let err = config.validate().expect_err("invalid audit read limit");
   2535         assert!(err.to_string().contains("audit.default_read_limit"));
   2536     }
   2537 
   2538     #[test]
   2539     fn validate_rejects_zero_external_command_timeout() {
   2540         let mut config = MycConfig::default();
   2541         config.custody.external_command_timeout_secs = 0;
   2542 
   2543         let err = config.validate().expect_err("invalid custody timeout");
   2544         assert!(
   2545             err.to_string()
   2546                 .contains("custody.external_command_timeout_secs")
   2547         );
   2548     }
   2549 
   2550     #[test]
   2551     fn validate_rejects_non_loopback_observability_bind_addr() {
   2552         let mut config = MycConfig::default();
   2553         config.observability.enabled = true;
   2554         config.observability.bind_addr = "0.0.0.0:9460"
   2555             .parse()
   2556             .expect("non-loopback observability bind addr");
   2557 
   2558         let err = config
   2559             .validate()
   2560             .expect_err("non-loopback observability bind addr should be rejected");
   2561         assert!(
   2562             err.to_string()
   2563                 .contains("observability.bind_addr must use a loopback address")
   2564         );
   2565     }
   2566 
   2567     #[test]
   2568     fn discovery_validation_requires_domain_and_relays_when_enabled() {
   2569         let mut config = MycConfig::default();
   2570         config.discovery.enabled = true;
   2571         config.transport.enabled = true;
   2572         config.transport.relays = vec!["wss://relay.example.com".to_owned()];
   2573 
   2574         let err = config.validate().expect_err("missing discovery domain");
   2575         assert!(err.to_string().contains("discovery.domain"));
   2576 
   2577         config.discovery.domain = Some("myc.example.com".to_owned());
   2578         config.transport.relays.clear();
   2579         let err = config.validate().expect_err("missing relay hints");
   2580         assert!(err.to_string().contains("at least one public relay hint"));
   2581     }
   2582 
   2583     #[test]
   2584     fn discovery_validation_allows_localhost_http_nostrconnect_template() {
   2585         let mut config = MycConfig::default();
   2586         config.discovery.enabled = true;
   2587         config.discovery.domain = Some("localhost".to_owned());
   2588         config.discovery.public_relays = vec!["ws://localhost:8080".to_owned()];
   2589         config.discovery.nostrconnect_url_template =
   2590             Some("http://localhost/connect?uri=<nostrconnect>".to_owned());
   2591 
   2592         config.validate().expect("localhost http template");
   2593     }
   2594 
   2595     #[test]
   2596     fn discovery_validation_rejects_invalid_nostrconnect_template() {
   2597         let mut config = MycConfig::default();
   2598         config.discovery.enabled = true;
   2599         config.discovery.domain = Some("myc.example.com".to_owned());
   2600         config.discovery.public_relays = vec!["wss://relay.example.com".to_owned()];
   2601         config.discovery.nostrconnect_url_template = Some("http://bad.example.com".to_owned());
   2602 
   2603         let err = config.validate().expect_err("invalid discovery template");
   2604         assert!(
   2605             err.to_string()
   2606                 .contains("discovery.nostrconnect_url_template")
   2607         );
   2608     }
   2609 
   2610     #[test]
   2611     fn validate_rejects_invalid_delivery_policy_settings() {
   2612         let mut config = MycConfig::default();
   2613         config.transport.enabled = true;
   2614         config.transport.relays = vec!["wss://relay.example.com".to_owned()];
   2615         config.transport.delivery_policy = MycTransportDeliveryPolicy::Quorum;
   2616 
   2617         let err = config
   2618             .validate()
   2619             .expect_err("missing quorum should be rejected");
   2620         assert!(err.to_string().contains("transport.delivery_quorum"));
   2621 
   2622         config.transport.delivery_quorum = Some(0);
   2623         let err = config
   2624             .validate()
   2625             .expect_err("zero quorum should be rejected");
   2626         assert!(err.to_string().contains("greater than zero"));
   2627 
   2628         config.transport.delivery_policy = MycTransportDeliveryPolicy::Any;
   2629         config.transport.delivery_quorum = Some(1);
   2630         let err = config
   2631             .validate()
   2632             .expect_err("quorum on non-quorum policy should be rejected");
   2633         assert!(err.to_string().contains("only valid"));
   2634     }
   2635 
   2636     #[test]
   2637     fn validate_rejects_invalid_publish_retry_settings() {
   2638         let mut config = MycConfig::default();
   2639         config.transport.publish_max_attempts = 0;
   2640         let err = config.validate().expect_err("zero attempts");
   2641         assert!(err.to_string().contains("publish_max_attempts"));
   2642 
   2643         config.transport.publish_max_attempts = 1;
   2644         config.transport.publish_initial_backoff_millis = 0;
   2645         let err = config.validate().expect_err("zero initial backoff");
   2646         assert!(err.to_string().contains("publish_initial_backoff_millis"));
   2647 
   2648         config.transport.publish_initial_backoff_millis = 10;
   2649         config.transport.publish_max_backoff_millis = 0;
   2650         let err = config.validate().expect_err("zero max backoff");
   2651         assert!(err.to_string().contains("publish_max_backoff_millis"));
   2652 
   2653         config.transport.publish_max_backoff_millis = 5;
   2654         let err = config
   2655             .validate()
   2656             .expect_err("max backoff less than initial");
   2657         assert!(err.to_string().contains("greater than or equal"));
   2658     }
   2659 
   2660     #[test]
   2661     fn validate_rejects_overlapping_policy_client_lists() {
   2662         let mut config = MycConfig::default();
   2663         config.policy.trusted_client_pubkeys =
   2664             vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()];
   2665         config.policy.denied_client_pubkeys =
   2666             vec!["1111111111111111111111111111111111111111111111111111111111111111".to_owned()];
   2667 
   2668         let err = config
   2669             .validate()
   2670             .expect_err("overlapping policy client lists");
   2671         assert!(err.to_string().contains("overlap"));
   2672     }
   2673 
   2674     #[test]
   2675     fn validate_requires_auth_url_for_auth_ttl_policy() {
   2676         let mut config = MycConfig::default();
   2677         config.policy.auth_authorized_ttl_secs = Some(60);
   2678 
   2679         let err = config.validate().expect_err("missing auth url");
   2680         assert!(err.to_string().contains("policy.auth_url"));
   2681     }
   2682 
   2683     #[test]
   2684     fn validate_requires_complete_rate_limit_pairs() {
   2685         let mut config = MycConfig::default();
   2686         config.policy.connect_rate_limit_window_secs = Some(60);
   2687 
   2688         let err = config
   2689             .validate()
   2690             .expect_err("incomplete connect rate limit");
   2691         assert!(err.to_string().contains("policy.connect_rate_limit"));
   2692 
   2693         let mut config = MycConfig::default();
   2694         config.policy.auth_challenge_rate_limit_max_attempts = Some(2);
   2695 
   2696         let err = config
   2697             .validate()
   2698             .expect_err("incomplete auth challenge rate limit");
   2699         assert!(err.to_string().contains("policy.auth_challenge_rate_limit"));
   2700     }
   2701 
   2702     #[test]
   2703     fn parse_and_validate_host_vault_identity_backends() {
   2704         let config = MycConfig::from_env_str(
   2705             r#"
   2706 MYC_IDENTITY_SIGNER_BACKEND=host_vault
   2707 MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111
   2708 MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer
   2709 MYC_IDENTITY_USER_BACKEND=host_vault
   2710 MYC_IDENTITY_USER_KEYRING_ACCOUNT_ID=2222222222222222222222222222222222222222222222222222222222222222
   2711 MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user
   2712 MYC_DISCOVERY_ENABLED=true
   2713 MYC_DISCOVERY_DOMAIN=myc.example.com
   2714 MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com
   2715 MYC_IDENTITY_DISCOVERY_APP_BACKEND=host_vault
   2716 MYC_IDENTITY_DISCOVERY_APP_KEYRING_ACCOUNT_ID=3333333333333333333333333333333333333333333333333333333333333333
   2717 MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
   2718             "#,
   2719         )
   2720         .expect("config");
   2721 
   2722         assert_eq!(
   2723             config.paths.signer_identity_backend,
   2724             MycIdentityBackend::HostVault
   2725         );
   2726         assert_eq!(
   2727             config.paths.signer_identity_keyring_account_id.as_deref(),
   2728             Some("1111111111111111111111111111111111111111111111111111111111111111")
   2729         );
   2730         assert_eq!(
   2731             config.paths.user_identity_backend,
   2732             MycIdentityBackend::HostVault
   2733         );
   2734         assert_eq!(
   2735             config.discovery.app_identity_backend,
   2736             Some(MycIdentityBackend::HostVault)
   2737         );
   2738         assert_eq!(
   2739             config
   2740                 .discovery
   2741                 .app_identity_keyring_service_name
   2742                 .as_deref(),
   2743             Some("org.radroots.myc.test.discovery")
   2744         );
   2745     }
   2746 
   2747     #[test]
   2748     fn parse_and_validate_managed_account_identity_backends() {
   2749         let config = MycConfig::from_env_str(
   2750             r#"
   2751 MYC_IDENTITY_SIGNER_BACKEND=managed_account
   2752 MYC_IDENTITY_SIGNER_PATH=/var/lib/myc/custody/signer-accounts.json
   2753 MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer
   2754 MYC_IDENTITY_USER_BACKEND=managed_account
   2755 MYC_IDENTITY_USER_PATH=/var/lib/myc/custody/user-accounts.json
   2756 MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user
   2757 MYC_DISCOVERY_ENABLED=true
   2758 MYC_DISCOVERY_DOMAIN=myc.example.com
   2759 MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com
   2760 MYC_IDENTITY_DISCOVERY_APP_BACKEND=managed_account
   2761 MYC_IDENTITY_DISCOVERY_APP_PATH=/var/lib/myc/custody/discovery-accounts.json
   2762 MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
   2763             "#,
   2764         )
   2765         .expect("config");
   2766 
   2767         assert_eq!(
   2768             config.paths.signer_identity_backend,
   2769             MycIdentityBackend::ManagedAccount
   2770         );
   2771         assert_eq!(
   2772             config.paths.signer_identity_source().path,
   2773             Some(PathBuf::from("/var/lib/myc/custody/signer-accounts.json"))
   2774         );
   2775         assert_eq!(
   2776             config
   2777                 .paths
   2778                 .signer_identity_source()
   2779                 .keyring_service_name
   2780                 .as_deref(),
   2781             Some("org.radroots.myc.test.signer")
   2782         );
   2783         assert_eq!(
   2784             config.paths.user_identity_backend,
   2785             MycIdentityBackend::ManagedAccount
   2786         );
   2787         assert_eq!(
   2788             config.discovery.app_identity_backend,
   2789             Some(MycIdentityBackend::ManagedAccount)
   2790         );
   2791         assert_eq!(
   2792             config
   2793                 .discovery
   2794                 .app_identity_source()
   2795                 .expect("app identity source")
   2796                 .path,
   2797             Some(PathBuf::from(
   2798                 "/var/lib/myc/custody/discovery-accounts.json"
   2799             ))
   2800         );
   2801     }
   2802 
   2803     #[test]
   2804     fn parse_and_validate_external_command_identity_backends() {
   2805         let config = MycConfig::from_env_str(
   2806             r#"
   2807 MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=21
   2808 MYC_IDENTITY_SIGNER_BACKEND=external_command
   2809 MYC_IDENTITY_SIGNER_PATH=/usr/local/libexec/myc-signer-helper
   2810 MYC_IDENTITY_USER_BACKEND=external_command
   2811 MYC_IDENTITY_USER_PATH=/usr/local/libexec/myc-user-helper
   2812 MYC_DISCOVERY_ENABLED=true
   2813 MYC_DISCOVERY_DOMAIN=myc.example.com
   2814 MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.example.com
   2815 MYC_IDENTITY_DISCOVERY_APP_BACKEND=external_command
   2816 MYC_IDENTITY_DISCOVERY_APP_PATH=/usr/local/libexec/myc-discovery-helper
   2817             "#,
   2818         )
   2819         .expect("config");
   2820 
   2821         assert_eq!(
   2822             config.paths.signer_identity_backend,
   2823             MycIdentityBackend::ExternalCommand
   2824         );
   2825         assert_eq!(
   2826             config.paths.signer_identity_source().path,
   2827             Some(PathBuf::from("/usr/local/libexec/myc-signer-helper"))
   2828         );
   2829         assert_eq!(
   2830             config.paths.user_identity_backend,
   2831             MycIdentityBackend::ExternalCommand
   2832         );
   2833         assert_eq!(
   2834             config.discovery.app_identity_backend,
   2835             Some(MycIdentityBackend::ExternalCommand)
   2836         );
   2837         assert_eq!(config.custody.external_command_timeout_secs, 21);
   2838         assert_eq!(
   2839             config
   2840                 .discovery
   2841                 .app_identity_source()
   2842                 .expect("app identity source")
   2843                 .path,
   2844             Some(PathBuf::from("/usr/local/libexec/myc-discovery-helper"))
   2845         );
   2846     }
   2847 
   2848     #[test]
   2849     fn example_env_parses_and_validates() {
   2850         let example =
   2851             fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".env.example"))
   2852                 .expect("read example config");
   2853 
   2854         let resolver = linux_resolver("/home/treesap");
   2855         let config = MycConfig::from_env_str_with_source_and_resolver(
   2856             &example,
   2857             Path::new(".env.example"),
   2858             &resolver,
   2859         )
   2860         .expect("example config");
   2861 
   2862         assert_eq!(config.service.instance_name, "myc");
   2863         assert_eq!(config.paths.profile, MycPathProfile::ServiceHost);
   2864         assert!(config.discovery.enabled);
   2865         assert_eq!(config.discovery.domain.as_deref(), Some("myc.radroots.org"));
   2866         assert_eq!(config.discovery.handler_identifier, "myc");
   2867         assert_eq!(
   2868             config.logging.output_dir,
   2869             Some(PathBuf::from("/var/log/radroots/services/myc"))
   2870         );
   2871         assert_eq!(
   2872             config.paths.config_env_path,
   2873             PathBuf::from("/etc/radroots/services/myc/config.env")
   2874         );
   2875         assert_eq!(
   2876             config.paths.state_dir,
   2877             PathBuf::from("/var/lib/radroots/services/myc/state")
   2878         );
   2879         assert_eq!(
   2880             config.paths.signer_identity_path,
   2881             PathBuf::from("/etc/radroots/secrets/services/myc/signer-identity.json")
   2882         );
   2883         assert_eq!(
   2884             config.paths.user_identity_path,
   2885             PathBuf::from("/etc/radroots/secrets/services/myc/user-identity.json")
   2886         );
   2887         assert_eq!(config.custody.external_command_timeout_secs, 10);
   2888         assert_eq!(
   2889             config.transport.delivery_policy,
   2890             MycTransportDeliveryPolicy::Any
   2891         );
   2892         assert_eq!(
   2893             config.policy.connection_approval,
   2894             MycConnectionApproval::ExplicitUser
   2895         );
   2896         assert_eq!(
   2897             config.persistence.signer_state_backend,
   2898             MycSignerStateBackend::JsonFile
   2899         );
   2900         assert_eq!(
   2901             config.persistence.runtime_audit_backend,
   2902             MycRuntimeAuditBackend::JsonlFile
   2903         );
   2904         assert_eq!(config.policy.auth_pending_ttl_secs, 900);
   2905         assert_eq!(config.transport.delivery_quorum, None);
   2906         assert_eq!(config.transport.publish_max_attempts, 1);
   2907         assert_eq!(config.transport.publish_initial_backoff_millis, 250);
   2908         assert_eq!(config.transport.publish_max_backoff_millis, 2_000);
   2909         assert_eq!(
   2910             config.discovery.nip05_output_path,
   2911             Some(PathBuf::from(
   2912                 "/var/lib/radroots/services/myc/public/.well-known/nostr.json"
   2913             ))
   2914         );
   2915     }
   2916 
   2917     #[test]
   2918     fn env_renderer_roundtrips_current_config_surface() {
   2919         let config = MycConfig::from_env_str(
   2920             r#"
   2921 MYC_SERVICE_INSTANCE_NAME=myc-dev
   2922 MYC_LOGGING_FILTER=debug,myc=trace
   2923 MYC_LOGGING_OUTPUT_DIR=/tmp/myc logs
   2924 MYC_LOGGING_STDOUT=false
   2925 MYC_CUSTODY_EXTERNAL_COMMAND_TIMEOUT_SECS=17
   2926 MYC_PATHS_STATE_DIR=/tmp/myc state
   2927 MYC_IDENTITY_SIGNER_BACKEND=host_vault
   2928 MYC_IDENTITY_SIGNER_PATH=/tmp/ignored-signer.json
   2929 MYC_IDENTITY_SIGNER_KEYRING_ACCOUNT_ID=1111111111111111111111111111111111111111111111111111111111111111
   2930 MYC_IDENTITY_SIGNER_KEYRING_SERVICE_NAME=org.radroots.myc.test.signer
   2931 MYC_IDENTITY_SIGNER_PROFILE_PATH=/tmp/signer-profile.json
   2932 MYC_IDENTITY_USER_BACKEND=plaintext_file
   2933 MYC_IDENTITY_USER_PATH=/tmp/myc-user.json
   2934 MYC_IDENTITY_USER_KEYRING_SERVICE_NAME=org.radroots.myc.test.user
   2935 MYC_PERSISTENCE_SIGNER_STATE_BACKEND=json_file
   2936 MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=jsonl_file
   2937 MYC_AUDIT_DEFAULT_READ_LIMIT=50
   2938 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=4096
   2939 MYC_AUDIT_MAX_ARCHIVED_FILES=3
   2940 MYC_OBSERVABILITY_ENABLED=true
   2941 MYC_OBSERVABILITY_BIND_ADDR=127.0.0.1:9550
   2942 MYC_DISCOVERY_ENABLED=true
   2943 MYC_DISCOVERY_DOMAIN=myc.example.com
   2944 MYC_DISCOVERY_HANDLER_IDENTIFIER=myc-main
   2945 MYC_IDENTITY_DISCOVERY_APP_BACKEND=plaintext_file
   2946 MYC_IDENTITY_DISCOVERY_APP_PATH=/tmp/myc-app.json
   2947 MYC_IDENTITY_DISCOVERY_APP_KEYRING_SERVICE_NAME=org.radroots.myc.test.discovery
   2948 MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.discovery.example.com
   2949 MYC_DISCOVERY_PUBLISH_RELAY_URLS=wss://relay.publish.example.com
   2950 MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect>
   2951 MYC_DISCOVERY_NIP05_OUTPUT_PATH=/tmp/nostr.json
   2952 MYC_DISCOVERY_METADATA_NAME=myc
   2953 MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza
   2954 MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer
   2955 MYC_DISCOVERY_METADATA_WEBSITE=https://myc.example.com
   2956 MYC_DISCOVERY_METADATA_PICTURE=https://myc.example.com/logo.png
   2957 MYC_POLICY_CONNECTION_APPROVAL=not_required
   2958 MYC_POLICY_TRUSTED_CLIENT_PUBKEYS=1111111111111111111111111111111111111111111111111111111111111111
   2959 MYC_POLICY_DENIED_CLIENT_PUBKEYS=2222222222222222222222222222222222222222222222222222222222222222
   2960 MYC_POLICY_PERMISSION_CEILING=nip04_encrypt,sign_event:1
   2961 MYC_POLICY_ALLOWED_SIGN_EVENT_KINDS=1,7
   2962 MYC_POLICY_AUTH_URL=https://auth.example.com/challenge
   2963 MYC_POLICY_AUTH_PENDING_TTL_SECS=300
   2964 MYC_POLICY_AUTHORIZED_TTL_SECS=3600
   2965 MYC_POLICY_REAUTH_AFTER_INACTIVITY_SECS=600
   2966 MYC_POLICY_CONNECT_RATE_LIMIT_WINDOW_SECS=60
   2967 MYC_POLICY_CONNECT_RATE_LIMIT_MAX_ATTEMPTS=5
   2968 MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_WINDOW_SECS=120
   2969 MYC_POLICY_AUTH_CHALLENGE_RATE_LIMIT_MAX_ATTEMPTS=3
   2970 MYC_TRANSPORT_ENABLED=true
   2971 MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=15
   2972 MYC_TRANSPORT_RELAY_URLS=wss://relay.example.com,wss://relay2.example.com
   2973 MYC_TRANSPORT_DELIVERY_POLICY=quorum
   2974 MYC_TRANSPORT_DELIVERY_QUORUM=2
   2975 MYC_TRANSPORT_PUBLISH_MAX_ATTEMPTS=4
   2976 MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=100
   2977 MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800
   2978             "#,
   2979         )
   2980         .expect("config");
   2981 
   2982         let rendered = config.to_env_string().expect("render env");
   2983         let reparsed = MycConfig::from_env_str(&rendered).expect("reparse rendered env");
   2984 
   2985         assert!(rendered.contains("MYC_IDENTITY_SIGNER_BACKEND=host_vault"));
   2986         assert!(rendered.contains("MYC_IDENTITY_USER_BACKEND=plaintext_file"));
   2987         assert!(rendered.contains("MYC_IDENTITY_DISCOVERY_APP_BACKEND=plaintext_file"));
   2988         assert!(
   2989             rendered.contains("MYC_DISCOVERY_PUBLIC_RELAY_URLS=wss://relay.discovery.example.com")
   2990         );
   2991         assert!(
   2992             rendered.contains("MYC_DISCOVERY_PUBLISH_RELAY_URLS=wss://relay.publish.example.com")
   2993         );
   2994         assert!(rendered.contains("MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://myc.example.com/connect/<nostrconnect>"));
   2995         assert!(
   2996             rendered.contains(
   2997                 "MYC_TRANSPORT_RELAY_URLS=wss://relay.example.com,wss://relay2.example.com"
   2998             )
   2999         );
   3000         assert!(rendered.contains("MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MS=100"));
   3001         assert!(rendered.contains("MYC_TRANSPORT_PUBLISH_MAX_BACKOFF_MS=800"));
   3002         assert!(!rendered.contains("MYC_PATHS_SIGNER_IDENTITY"));
   3003         assert!(!rendered.contains("MYC_PATHS_USER_IDENTITY"));
   3004         assert!(!rendered.contains("MYC_DISCOVERY_APP_IDENTITY"));
   3005         assert!(!rendered.contains("MYC_DISCOVERY_PUBLIC_RELAYS"));
   3006         assert!(!rendered.contains("MYC_DISCOVERY_PUBLISH_RELAYS"));
   3007         assert!(!rendered.contains("MYC_DISCOVERY_NOSTRCONNECT_URL_TEMPLATE"));
   3008         assert!(!rendered.contains("MYC_TRANSPORT_RELAYS"));
   3009         assert!(!rendered.contains("_MILLIS"));
   3010         assert_eq!(reparsed, config);
   3011     }
   3012 
   3013     #[test]
   3014     fn parse_runtime_audit_backend_supports_sqlite() {
   3015         let config = MycConfig::from_env_str(
   3016             r#"
   3017 MYC_IDENTITY_SIGNER_PATH=/tmp/signer.json
   3018 MYC_IDENTITY_USER_PATH=/tmp/user.json
   3019 MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite
   3020             "#,
   3021         )
   3022         .expect("config");
   3023 
   3024         assert_eq!(
   3025             config.persistence.runtime_audit_backend,
   3026             MycRuntimeAuditBackend::Sqlite
   3027         );
   3028         assert!(
   3029             config
   3030                 .to_env_string()
   3031                 .expect("render env")
   3032                 .contains("MYC_PERSISTENCE_RUNTIME_AUDIT_BACKEND=sqlite")
   3033         );
   3034     }
   3035 
   3036     #[test]
   3037     fn parse_signer_state_backend_supports_sqlite() {
   3038         let config = MycConfig::from_env_str(
   3039             r#"
   3040 MYC_IDENTITY_SIGNER_PATH=/tmp/signer.json
   3041 MYC_IDENTITY_USER_PATH=/tmp/user.json
   3042 MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite
   3043             "#,
   3044         )
   3045         .expect("config");
   3046 
   3047         assert_eq!(
   3048             config.persistence.signer_state_backend,
   3049             MycSignerStateBackend::Sqlite
   3050         );
   3051         assert!(
   3052             config
   3053                 .to_env_string()
   3054                 .expect("render env")
   3055                 .contains("MYC_PERSISTENCE_SIGNER_STATE_BACKEND=sqlite")
   3056         );
   3057     }
   3058 
   3059     #[test]
   3060     fn runtime_contract_output_matches_shared_runtime_contract() {
   3061         let config = MycConfig::default();
   3062         let contract = config.runtime_contract_output();
   3063 
   3064         assert_eq!(contract.active_profile, MycPathProfile::InteractiveUser);
   3065         assert_eq!(contract.allowed_profiles, MycConfig::allowed_profiles());
   3066         assert_eq!(
   3067             contract.default_shared_secret_backend,
   3068             MycConfig::default_shared_secret_backend()
   3069         );
   3070         assert_eq!(
   3071             contract.allowed_shared_secret_backends,
   3072             MycConfig::allowed_shared_secret_backends()
   3073         );
   3074         assert_eq!(
   3075             contract.runtime_specific_custody_modes,
   3076             MycConfig::runtime_specific_custody_modes()
   3077         );
   3078         assert_eq!(contract.host_vault_policy, MycConfig::host_vault_policy());
   3079         assert_eq!(
   3080             contract.path_overrides.canonical_root_selection,
   3081             "profile_root_env_or_repo_wrapper"
   3082         );
   3083         assert_eq!(
   3084             contract.path_overrides.canonical_subordinate_path_override,
   3085             "config_artifact"
   3086         );
   3087         assert_eq!(
   3088             contract.path_overrides.leaf_path_env_posture,
   3089             "compatibility_break_glass"
   3090         );
   3091         assert_eq!(
   3092             contract.path_overrides.compatibility_leaf_path_keys,
   3093             [
   3094                 "MYC_LOGGING_OUTPUT_DIR",
   3095                 "MYC_PATHS_STATE_DIR",
   3096                 "MYC_IDENTITY_SIGNER_PATH",
   3097                 "MYC_IDENTITY_USER_PATH",
   3098                 "MYC_IDENTITY_DISCOVERY_APP_PATH",
   3099                 "MYC_DISCOVERY_NIP05_OUTPUT_PATH"
   3100             ]
   3101         );
   3102         assert_eq!(
   3103             contract.migration.posture,
   3104             "explicit_operator_import_required"
   3105         );
   3106         assert_eq!(contract.migration.silent_startup_relocation, false);
   3107         assert_eq!(
   3108             contract.migration.compatibility_window,
   3109             "detect_and_report_only"
   3110         );
   3111     }
   3112 }