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 }